diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 1e46424bee..226d6294b0 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2692,7 +2692,23 @@ export type CollapseCertainty = "Committed" | "Conditional"; export type FamilyCollapseState = | { type: "Unscheduled" } | { type: "Mixed" } - | { type: "Scheduled"; data: CollapseCertainty }; + | { + type: "Scheduled"; + data: { + certainty: CollapseCertainty; + /** + * The seat the engine will ask to name the collapse count (CR 732.2a's "specified number + * of times") — the loop's CONTROLLER. It is emitted because it is NOT recoverable from + * `UnboundedFamilyView.player`, which is the ATTRIBUTION seat: for `Life`/`DamageDealt`/ + * `LibraryDelta`/`Poison` axes that is the VICTIM, who is never asked. + * + * `undefined` means the family's scheduled axes name TWO OR MORE distinct seats — never + * "nobody". One glyph cannot address two players, so the badge falls back to the + * seat-neutral voice instead of picking a winner. + */ + prompted?: PlayerId; + }; + }; /** * One `∞` badge's engine-owned state, keyed per seat and per display family. Mirrors @@ -2716,6 +2732,40 @@ export interface UnboundedFamilyView { state: FamilyCollapseState; } +/** Mirrors `engine::game::derived_views::CounterMagnitude`. Absent on the wire ⇒ `"Finite"`. */ +export type CounterMagnitude = "Finite" | "Unbounded"; + +/** + * One renderable counter row on one object. Mirrors + * `engine::game::derived_views::CounterRowView`. + * + * `counter` matches the object's `counters` map key (`CounterType`'s serde spelling — e.g. + * `"charge"`, `"P1P1"`). `count` is the object's LIVE count and is engine-supplied because a row + * may legitimately have no entry in that map at all: a pair the loop pumps from `0 -> 1` is + * registered while the object still carries none, so the count is `0` and there is nothing to join + * back to. Re-deriving it here would also be the FE inferring game state. That `count: 0` case is + * `"Unbounded"`-only — the finite pass drops zero entries, the unbounded pass does not. + */ +export interface CounterRowView { + counter: CounterType; + count: number; + magnitude?: CounterMagnitude; +} + +/** + * Every counter row one object renders, PRE-PARTITIONED by the engine. Mirrors + * `engine::game::derived_views::ObjectCounterDisplay`. + * + * CR 306.5c: `loyalty` is the loyalty TOTAL row for an object that has a loyalty characteristic + * (loyalty IS its loyalty-counter count); everything else is a `pills` row, including a loyalty + * counter on an object with no loyalty. Loyalty ABILITY COST badges are never unbounded (CR 606.4 + * — a cost is a number of loyalty counters to pay, not a total). + */ +export interface ObjectCounterDisplay { + pills?: CounterRowView[]; + loyalty?: CounterRowView; +} + /** Mirrors `engine::analysis::loop_check::WinKind` (unit variants → bare strings). */ export type WinKind = | "LethalDamage" @@ -2966,12 +3016,13 @@ export interface DerivedViews { * deviation from it. What matters to the FE is only that the mark is still live there, so `∞` is current engine * state, not a stale mark. Render it. * - * ONE EXCEPTION: "stays populated" is about the ACCEPT, not about the board. A TOKEN-axis row - * is still dropped if its entire registered pile leaves the battlefield during that window — - * the engine will not render an `∞` beside an already-empty pile. Counter-axis rows are not - * dropped that way (the engine has no per-axis backing authority for them yet), so do not - * generalize the exception. Either way the accepted collapse itself is never cancelled: the row - * may vanish and the boundary still cashes the axis out. Do not infer a cancellation from a + * ONE EXCEPTION, ON TWO CONJUNCTS THAT MUST BOTH HOLD: an object-backed row (a TOKEN axis, or a + * COUNTER axis with registered targets) is dropped when (1) no accepted collapse names that axis + * AND (2) its entire registered board backing has left the battlefield — the engine will not + * render an `∞` beside an already-empty pile. Once the table has ACCEPTED, conjunct (1) fails and + * the row survives its backing dying, because CR 732.2c takes the shortcut at the last accept and + * the growth still lands. Either way the accepted collapse itself is never cancelled: the row may + * vanish and the boundary still cashes the axis out. Do not infer a cancellation from a * disappearing row — a row's disappearance says nothing about the collapse. What the FE IS told * about the collapse arrives on `unbounded_families` below, and only there. */ @@ -2992,16 +3043,29 @@ export interface DerivedViews { */ unbounded_pile?: ObjectId[]; /** - * CR 732.2a / CR 701.34a: per-object `∞` counter channel — for each battlefield - * object (keyed by ObjectId-as-string), the counter-type keys whose preserved - * `Generic` counters an accepted counter-growth loop (proliferate charge, burden) - * pumps unboundedly. Each value string matches the object's `counters` map key - * (e.g. `"charge"`). The FE renders `∞` (not `×N`) on any counter pill whose type - * is in this set, and never re-derives which counters are unbounded. Empty/omitted - * when no counter-growth loop is active. Mirrors - * `engine::game::derived_views::DerivedViews::unbounded_counters`. + * CR 122.1 + CR 732.2a: the COMPLETE per-object counter-display projection, keyed by + * ObjectId-as-string — every counter row every display surface renders, for every + * object that has one, in ANY zone (a Skullbriar-class permanent keeps its counters in + * the graveyard per CR 113.6b; a suspended card carries time counters in exile per + * CR 702.62b). + * + * CONTRACT FOR CONSUMERS: render `pills` in the order given; never sort, never filter, + * never read `obj.counters`; `magnitude` absent means `"Finite"`. The engine already + * partitioned loyalty (CR 306.5c), deduplicated across seats, and ordered the rows (`∞` + * first, then `CounterType` order). + * + * ZERO COUNTS ARE DROPPED IN THE FINITE PASS ONLY. `counter_display_views`' FINITE pass + * admits through `positive_counter_entries` (CR 122.1 — a zero map entry is not a marker), + * so no `"Finite"` row ever carries `count: 0`. The UNBOUNDED pass has NO zero filter: it + * reads the live count for a REGISTERED pair, so an `"Unbounded"` row legitimately carries + * `count: 0` for a pair the loop pumps `0 -> 1`. A consumer that filters on `count > 0` + * therefore deletes real `∞` rows — which is why consumers filter nothing. + * + * An object with no renderable row is absent from this map; the whole field is omitted + * when no object has one. Mirrors + * `engine::game::derived_views::DerivedViews::counter_display`. */ - unbounded_counters?: Record; + counter_display?: Record; } /** Mirrors `engine::types::game_state::NextSpellModifier` (serde tag="type"). */ diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index c2006787a2..5abc2aaf0a 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -14,7 +14,7 @@ import { useCardHover } from "../../hooks/useCardHover.ts"; import { useIsCompactHeight } from "../../hooks/useIsCompactHeight.ts"; import { useIsMobile } from "../../hooks/useIsMobile.ts"; import { useLongPress } from "../../hooks/useLongPress.ts"; -import { useUnboundedCounterTypes } from "../../hooks/useUnboundedCounterTypes.ts"; +import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { renderDescription } from "../../utils/description.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; @@ -303,7 +303,7 @@ export const PermanentCard = memo(function PermanentCard({ const isCopiedPermanent = useGameStore((s) => (s.gameState?.derived?.copied_permanents ?? []).includes(objectId), ); - const unboundedCounterTypes = useUnboundedCounterTypes(objectId); + const counterDisplay = useCounterDisplay(objectId); const isManaPaymentPreviewSource = useGameStore((s) => s.manaPaymentPreviewSourceIds.includes(objectId), ); @@ -603,8 +603,9 @@ export const PermanentCard = memo(function PermanentCard({ ? undefined : gameObjects?.[String(temporaryCantBeBlockedSourceId)]?.name; - // Filter out loyalty counters — shown separately as the loyalty badge - const counters = Object.entries(obj.counters).filter((entry): entry is [string, number] => entry[1] != null && entry[0] !== "loyalty"); + // CR 306.5c: the engine already split the loyalty TOTAL out of the pill strip, so this site + // classifies nothing — it renders the rows it is given, in the order it is given them. + const counters = pillsOf(counterDisplay); // Tap rotation: 17deg in MTGA mode (or compact-height), 90deg in classic mode const tapBaseOpacity = (isCompactHeight || tapRotation === "mtga") && obj.tapped ? 0.85 : 1; @@ -920,6 +921,7 @@ export const PermanentCard = memo(function PermanentCard({ - {counters.map(([type, count]) => { + {counters.map((row) => { + const type = row.counter; const iconClass = counterIconClass(type); // CR 732.2a / CR 701.34a: an accepted counter-growth loop pumps this // counter unboundedly — render ∞ instead of the (still-finite) real count. - const isUnbounded = unboundedCounterTypes.includes(type); + const unbounded = isUnbounded(row); return ( )} - {formatCounterType(type)} {isUnbounded ? "∞" : `x${count}`} + {formatCounterType(type)} {unbounded ? "∞" : `x${row.count}`} ); diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index 47c2e69b51..bc5ef8a9db 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -340,13 +340,15 @@ describe("PermanentCard", () => { }); // CR 732.2a / CR 701.34a: an accepted counter-growth ∞ loop (Kilo proliferate → Pentad - // charge) marks the pumped counter in `derived.unbounded_counters`; the pill renders ∞ + // charge) annotates the pumped row in `derived.counter_display`; the pill renders ∞ // instead of the (still-finite) real count. Matched pair — the ONLY difference between the - // two cases is the presence of the engine mark, so it is the discriminator. + // two cases is the row's `magnitude`, so it is the discriminator. it("renders ∞ on a counter the engine marks as unbounded", () => { const gameState = makeState(); gameState.objects[1].counters = { charge: 4 }; - gameState.derived = { unbounded_counters: { 1: ["charge"] } }; + gameState.derived = { + counter_display: { 1: { pills: [{ counter: "charge", count: 4, magnitude: "Unbounded" }] } }, + }; useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); const { container } = renderPermanent(); @@ -358,7 +360,8 @@ describe("PermanentCard", () => { it("renders the finite ×N count when the counter is not marked unbounded", () => { const gameState = makeState(); gameState.objects[1].counters = { charge: 4 }; - gameState.derived = {}; // no unbounded_counters mark + // `magnitude` omitted exactly as the engine omits the serde default. + gameState.derived = { counter_display: { 1: { pills: [{ counter: "charge", count: 4 }] } } }; useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); const { container } = renderPermanent(); @@ -367,6 +370,103 @@ describe("PermanentCard", () => { expect(container.textContent).not.toContain("∞"); }); + // THE NO-FALLBACK MATCHED PAIR. `counter_display` is the SINGLE authority: an object carrying + // real counters with no projection entry renders NO pill. This is the only test that catches a + // render site re-introducing `Object.entries(obj.counters)`, and it is worthless without its + // positive twin — alone it would also pass on a component that rendered nothing at all. + it("renders no pill for an object with counters but no projection entry", () => { + const gameState = makeState(); + gameState.objects[1].counters = { charge: 4 }; + gameState.derived = {}; // a frame that arrived without `derived.counter_display` + useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + + const { container } = renderPermanent(); + + expect(container.textContent).not.toContain("x4"); + expect(container.textContent).not.toContain("∞"); + }); + + it("renders the pill for that SAME object once the projection carries it", () => { + const gameState = makeState(); + gameState.objects[1].counters = { charge: 4 }; + gameState.derived = { counter_display: { 1: { pills: [{ counter: "charge", count: 4 }] } } }; + useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + + const { container } = renderPermanent(); + + expect(container.textContent).toContain("x4"); + }); + + // THE `0 -> 1` ROW, at the render layer. The engine registers a pumped pair while the + // object carries NONE of that counter, so the row's `count` is 0 and there is no entry in + // `obj.counters` to join back to. Before the channel published rows, this pill could not be + // drawn at all — the display had nothing to hang `∞` on. + // + // DISCRIMINATOR: the finite `burden` pill in the SAME frame proves the component did not + // simply start rendering `∞` for everything, and it is a positive reach-guard for the + // negative assertion below — without it, "no x0" would pass on a card that rendered no + // pills whatsoever. + it("renders ∞ for a marked counter the object does not yet carry (count 0)", () => { + const gameState = makeState(); + gameState.objects[1].counters = { burden: 2 }; + gameState.derived = { + counter_display: { + 1: { + pills: [ + { counter: "charge", count: 0, magnitude: "Unbounded" }, + { counter: "burden", count: 2 }, + ], + }, + }, + }; + useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + + const { container } = renderPermanent(); + + expect(container.textContent).toContain("∞"); + expect(container.textContent).toContain("x2"); + expect(container.textContent).not.toContain("x0"); + }); + + // CR 306.5c: a planeswalker's loyalty IS its loyalty-counter count, so the engine routes that + // row to `loyalty` rather than to `pills` and an `Unbounded` one means the TOTAL is unbounded. + // The partition is engine-side now, so there is no ∞ pill beside a stale numeric badge. + it("renders ∞ on the loyalty TOTAL badge when the engine marks a loyalty row", () => { + const gameState = makeState(); + gameState.objects[1].loyalty = 4; + gameState.objects[1].counters = { loyalty: 4 }; + gameState.derived = { + counter_display: { 1: { loyalty: { counter: "loyalty", count: 4, magnitude: "Unbounded" } } }, + }; + useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + + const { container } = renderPermanent(); + const badge = container.querySelector('[data-loyalty-badge="total"]') as HTMLElement; + + expect(badge).toBeInTheDocument(); + expect(badge.textContent).toContain("∞"); + expect(badge.textContent).not.toContain("4"); + // The DOM attribute stays truthful — selectors keep working. + expect(badge.getAttribute("data-loyalty-value")).toBe("4"); + }); + + it("renders the finite loyalty total when no loyalty row is marked", () => { + const gameState = makeState(); + gameState.objects[1].loyalty = 4; + gameState.objects[1].counters = { loyalty: 4 }; + gameState.derived = { + counter_display: { 1: { loyalty: { counter: "loyalty", count: 4 } } }, + }; + useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + + const { container } = renderPermanent(); + const badge = container.querySelector('[data-loyalty-badge="total"]') as HTMLElement; + + expect(badge).toBeInTheDocument(); + expect(badge.textContent).toContain("4"); + expect(badge.textContent).not.toContain("∞"); + }); + it("lifts the permanent tree above siblings while keeping attachments behind the host", () => { const { container } = renderPermanent(); const host = container.querySelector('[data-object-id="1"]') as HTMLElement; diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx index 33d38d3eed..83ba960f9a 100644 --- a/client/src/components/card/ArtCropCard.tsx +++ b/client/src/components/card/ArtCropCard.tsx @@ -5,7 +5,7 @@ import type { PTColor } from "../../viewmodel/cardProps"; import { useCardImage } from "../../hooks/useCardImage.ts"; import { useIsCompactHeight } from "../../hooks/useIsCompactHeight.ts"; import { useIsMobile } from "../../hooks/useIsMobile.ts"; -import { useUnboundedCounterTypes } from "../../hooks/useUnboundedCounterTypes.ts"; +import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; import { CARD_BACK_URL } from "../../services/scryfall.ts"; import { useGameStore } from "../../stores/gameStore.ts"; @@ -29,7 +29,7 @@ const PT_COLORS: Record = { export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardProps) { const { t } = useTranslation("game"); const obj = useGameStore((s) => s.gameState?.objects[objectId]); - const unboundedCounterTypes = useUnboundedCounterTypes(objectId); + const counterDisplay = useCounterDisplay(objectId); const isMobile = useIsMobile(); const inspectObject = useUiStore((s) => s.inspectObject); const isCompactHeight = useIsCompactHeight(); @@ -79,8 +79,9 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr // Kamigawa flip card stores its alternative half in the same slot and has no // face 1 to inspect. Use the engine-provided layout discriminant. const hasDfc = !obj.face_down && hasOtherPrintedFace(obj); - // Filter out loyalty counters — shown separately as the loyalty badge - const counters = Object.entries(obj.counters).filter((entry): entry is [string, number] => entry[1] != null && entry[0] !== "loyalty"); + // CR 306.5c: the engine already split the loyalty TOTAL out of the pill strip, so this site + // classifies nothing — it renders the rows it is given, in the order it is given them. + const counters = pillsOf(counterDisplay); const devotionValue = obj.devotion ?? null; // --- Dynamic Text Sizing Logic --- let ptNumClass = "text-[14px]"; @@ -204,22 +205,23 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr {/* Top-right overlay stack: counter badges kept clear of the bottom P/T and loyalty badges. */}
- {counters.map(([type, count]) => { + {counters.map((row) => { + const type = row.counter; // CR 732.2a / CR 701.34a: an accepted counter-growth loop pumps this // counter unboundedly — render ∞ instead of the (still-finite) real count. - const isUnbounded = unboundedCounterTypes.includes(type); + const unbounded = isUnbounded(row); return ( - {isUnbounded ? "∞" : count} + {unbounded ? "∞" : row.count} ); @@ -301,6 +303,7 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr - type === "loyalty" || count == null ? [] : [[type, count] as const], - ); const keywords = sortKeywords(obj.keywords); const colorsChanged = obj.color.length !== obj.base_color.length || @@ -1341,10 +1338,12 @@ function CardInfoPanel({ const transientContinuousEffects = useGameStore( (s) => s.gameState?.transient_continuous_effects, ); - // CR 732.2a / CR 701.34a: the ∞ mark for this object. Same engine channel - // PermanentCard's pill and ArtCropCard's badge read — every counter display mode - // must agree, or the ∞ silently drops in one of them. - const unboundedCounterTypes = useUnboundedCounterTypes(obj.id); + // CR 122.1 + CR 306.5c: the engine's complete counter projection for this object. Same channel + // PermanentCard's pill and ArtCropCard's badge read — every counter display mode must agree, or + // a row silently drops in one of them. The loyalty TOTAL is already partitioned out by the + // engine; this site renders no loyalty badge, so it takes the pills alone. + const counterDisplay = useCounterDisplay(obj.id); + const counters = pillsOf(counterDisplay); const deref = { objects, transientContinuousEffects }; const keywordSources = buildGrantedKeywordSources(attribution, obj.id, deref); const ptSources = buildPTSources(attribution, obj.id, deref); @@ -1512,15 +1511,16 @@ function CardInfoPanel({ {/* Counters */} {counters.length > 0 && (
- {counters.map(([type, count]) => { + {counters.map((row) => { + const type = row.counter; // CR 732.2a / CR 701.34a: an accepted counter-growth loop pumps this counter // unboundedly — render ∞ instead of the (still-finite) real count, and tell the // tooltip so its summary can't contradict the row. - const isUnbounded = unboundedCounterTypes.includes(type); + const unbounded = isUnbounded(row); return ( - + - {formatCounterType(type)}: {isUnbounded ? "∞" : count} + {formatCounterType(type)}: {unbounded ? "∞" : row.count} ); diff --git a/client/src/components/card/__tests__/ArtCropCard.test.tsx b/client/src/components/card/__tests__/ArtCropCard.test.tsx index 4486d26690..c7dda5ed9c 100644 --- a/client/src/components/card/__tests__/ArtCropCard.test.tsx +++ b/client/src/components/card/__tests__/ArtCropCard.test.tsx @@ -118,6 +118,9 @@ describe("ArtCropCard", () => { useGameStore.setState({ gameState: { objects: { [token.id]: token }, + // The counter badge is engine-projected, so the frame must carry the projection to have + // a badge at all — this site renders `counter_display`, never `obj.counters`. + derived: { counter_display: { [token.id]: { pills: [{ counter: "p1p1", count: 1 }] } } }, } as never, }); @@ -320,7 +323,11 @@ describe("ArtCropCard", () => { useGameStore.setState({ gameState: { objects: { [permanent.id]: permanent }, - derived: { unbounded_counters: { [permanent.id]: ["charge"] } }, + derived: { + counter_display: { + [permanent.id]: { pills: [{ counter: "charge", count: 2, magnitude: "Unbounded" }] }, + }, + }, } as never, }); @@ -331,13 +338,39 @@ describe("ArtCropCard", () => { expect(screen.queryByText("2")).not.toBeInTheDocument(); }); + // CR 122.1: a ZERO-count unbounded row is a shape the engine really emits — the unbounded pass + // of `counter_display_views` publishes its live count with no zero filter (only the finite pass + // runs `positive_counter_entries`). That is the `0 → 1` case, so it must still render as ∞; a + // `count > 0` filter over the projected rows here would silently delete real ∞ badges. + it("renders ∞ for a zero-count unbounded counter (CR 122.1 art-crop mode)", () => { + mockUseCardImage.mockReturnValue({ src: "card.png", isLoading: false, isRotated: false, isFlip: false }); + const permanent = pentadWithCharge(); + useGameStore.setState({ + gameState: { + objects: { [permanent.id]: permanent }, + derived: { + counter_display: { + [permanent.id]: { pills: [{ counter: "charge", count: 0, magnitude: "Unbounded" }] }, + }, + }, + } as never, + }); + + render(); + + expect(screen.getByText("∞")).toBeInTheDocument(); + }); + it("renders the finite count when the counter is NOT marked unbounded (discriminator)", () => { mockUseCardImage.mockReturnValue({ src: "card.png", isLoading: false, isRotated: false, isFlip: false }); const permanent = pentadWithCharge(); useGameStore.setState({ gameState: { objects: { [permanent.id]: permanent }, - derived: { unbounded_counters: {} }, + // `magnitude` omitted exactly as the engine omits the serde default. + derived: { + counter_display: { [permanent.id]: { pills: [{ counter: "charge", count: 2 }] } }, + }, } as never, }); @@ -356,7 +389,11 @@ describe("ArtCropCard", () => { useGameStore.setState({ gameState: { objects: { [permanent.id]: permanent }, - derived: { unbounded_counters: { [permanent.id]: ["charge"] } }, + derived: { + counter_display: { + [permanent.id]: { pills: [{ counter: "charge", count: 2, magnitude: "Unbounded" }] }, + }, + }, } as never, }); @@ -373,7 +410,10 @@ describe("ArtCropCard", () => { useGameStore.setState({ gameState: { objects: { [permanent.id]: permanent }, - derived: { unbounded_counters: {} }, + // `magnitude` omitted exactly as the engine omits the serde default. + derived: { + counter_display: { [permanent.id]: { pills: [{ counter: "charge", count: 2 }] } }, + }, } as never, }); @@ -382,4 +422,41 @@ describe("ArtCropCard", () => { expect(screen.getByText(/2 \S+ counters/i)).toBeInTheDocument(); expect(screen.queryByText(/∞ \S+ counters/i)).not.toBeInTheDocument(); }); + + // THE NO-FALLBACK MATCHED PAIR. `counter_display` is the SINGLE authority: an object carrying + // real counters with no projection entry renders NO badge. This is what catches this render + // site re-introducing `Object.entries(obj.counters)`, and it is worthless without its positive + // twin — alone it would also pass on a component that rendered nothing at all. + it("renders no counter badge for an object with counters but no projection entry", () => { + mockUseCardImage.mockReturnValue({ src: "card.png", isLoading: false, isRotated: false, isFlip: false }); + const permanent = pentadWithCharge(); + useGameStore.setState({ + gameState: { + objects: { [permanent.id]: permanent }, + derived: {}, // a frame that arrived without `derived.counter_display` + } as never, + }); + + render(); + + expect(screen.queryByText("2")).not.toBeInTheDocument(); + expect(screen.queryByText("∞")).not.toBeInTheDocument(); + }); + + it("renders the badge for that SAME object once the projection carries it", () => { + mockUseCardImage.mockReturnValue({ src: "card.png", isLoading: false, isRotated: false, isFlip: false }); + const permanent = pentadWithCharge(); + useGameStore.setState({ + gameState: { + objects: { [permanent.id]: permanent }, + derived: { + counter_display: { [permanent.id]: { pills: [{ counter: "charge", count: 2 }] } }, + }, + } as never, + }); + + render(); + + expect(screen.getByText("2")).toBeInTheDocument(); + }); }); diff --git a/client/src/components/card/__tests__/CardPreview.test.tsx b/client/src/components/card/__tests__/CardPreview.test.tsx index 235e0070fa..ae5f5d43f6 100644 --- a/client/src/components/card/__tests__/CardPreview.test.tsx +++ b/client/src/components/card/__tests__/CardPreview.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { GameObject } from "../../../adapter/types.ts"; +import type { GameObject, ObjectCounterDisplay } from "../../../adapter/types.ts"; import { useCardImage } from "../../../hooks/useCardImage.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; @@ -565,8 +565,8 @@ describe("CardPreview blocked abilities", () => { // CR 732.2a / CR 701.34a: the hover status box under the full card render is the // THIRD counter render site (after PermanentCard's pill and ArtCropCard's badge). -// An accepted counter-growth ∞ loop marks the pumped counter in -// `derived.unbounded_counters` and deliberately leaves the object's real count +// An accepted counter-growth ∞ loop annotates the pumped row in +// `derived.counter_display` and deliberately leaves the object's real count // finite (engine.rs `materialize_object_growth_shortcut`: "the object's real // counter count is NOT mutated ... this only marks the pill to render ∞"), so a // site that reads `obj.counters` alone shows a stale pre-shortcut number. @@ -581,37 +581,62 @@ describe("CardPreview blocked abilities", () => { // Matched pair: the ONLY difference between the two cases is the engine mark, so // it is the discriminator. describe("CardPreview unbounded counters", () => { - function inspectPentadPrism(unbounded: string[] | null) { + // `null` means "a frame that arrived with no `derived.counter_display` at all" — the object's + // own `counters` map stays populated either way, so a site that fell back to it would show a + // pill in that case. It must not. + function inspectPentadPrism(display: ObjectCounterDisplay | null) { const object = battlefieldObject({ id: 409, name: "Pentad Prism", counters: { charge: 2 }, }); const gameState = gameStateWithObject(object); - gameState.derived = unbounded ? { unbounded_counters: { 409: unbounded } } : {}; + gameState.derived = display ? { counter_display: { 409: display } } : {}; useGameStore.setState({ gameState, spellCosts: {} }); useUiStore.setState({ inspectedObjectId: object.id, altHeld: false }); return render(); } it("renders ∞ for a counter the engine marks as unbounded", () => { - const { container } = inspectPentadPrism(["charge"]); + const { container } = inspectPentadPrism({ + pills: [{ counter: "charge", count: 2, magnitude: "Unbounded" }], + }); expect(container.textContent).toContain("charge: ∞"); expect(container.textContent).not.toContain("charge: 2"); }); it("renders the finite count when the counter is not marked unbounded", () => { - const { container } = inspectPentadPrism(null); + // `magnitude` omitted exactly as the engine omits the serde default. + const { container } = inspectPentadPrism({ pills: [{ counter: "charge", count: 2 }] }); expect(container.textContent).toContain("charge: 2"); expect(container.textContent).not.toContain("∞"); }); + // THE NO-FALLBACK MATCHED PAIR. `counter_display` is the SINGLE authority: an object carrying + // real counters with no projection entry renders NO row. This is what catches this render site + // re-introducing `Object.entries(obj.counters)`, and it is worthless without its positive twin + // — alone it would also pass on a panel that rendered nothing at all. + it("renders no counter row for an object with counters but no projection entry", () => { + const { container } = inspectPentadPrism(null); + + expect(container.textContent).not.toContain("charge"); + expect(container.textContent).not.toContain("∞"); + }); + + it("renders the row for that SAME object once the projection carries it", () => { + const { container } = inspectPentadPrism({ pills: [{ counter: "charge", count: 2 }] }); + + expect(container.textContent).toContain("charge: 2"); + }); + // LOW: the ∞ row and its TOOLTIP must agree — a badge saying ∞ over a tooltip // interpolating the finite count contradicts itself (mirrors ArtCropCard.test.tsx:353). it("the ∞ status row's tooltip agrees with the badge", () => { - const { container } = inspectPentadPrism(["charge"]); + const { container } = inspectPentadPrism({ + pills: [{ counter: "charge", count: 2, magnitude: "Unbounded" }], + }); // `GameplayTooltip` renders its lines through `createPortal(…, document.body)`, so the // summary is NOT inside `container` — query it via `screen`, exactly as the tooltip @@ -622,21 +647,35 @@ describe("CardPreview unbounded counters", () => { expect(screen.queryByText(/2 charge counters/i)).not.toBeInTheDocument(); }); - // KNOWN GAP (F2), not desired behaviour: `grown_generic_counter_targets` - // (analysis/resource.rs:1330-1331) reads the BEFORE count off the live state, so the - // engine can mark an (object, counter) pair the object does not carry. Every display - // mode iterates `obj.counters`, so such a mark renders nowhere. The frontend must NOT - // synthesize a counter row the engine says does not exist; if the ∞ should be visible - // there, the ENGINE must decide it. - it("KNOWN GAP: a marked counter type the object does not carry renders nowhere (F2)", () => { - const { container } = inspectPentadPrism(["oil"]); + // REGRESSION (F2) — this test used to assert the GAP, and now asserts its fix. The ∞ counter + // targets are derived by `analysis::resource::grown_beneficial_counter_deltas` over the two + // frames `game::engine::drive_one_period_frames` produces, and its BEFORE frame is a clone of + // the LIVE state. A pair that grows 0 → 1 across the driven period is therefore registered + // while the live object carries none of that counter. While the channel published bare counter + // TYPES, every display mode iterated `obj.counters` and such a mark rendered NOWHERE — a real, + // accepted, registered ∞ that was invisible. + // + // The engine now publishes a self-sufficient ROW carrying the live count (`0` when absent), so + // the display renders it without synthesizing anything: the frontend still must NOT invent a + // counter row the engine did not publish, and it does not — the ENGINE decided this row exists. + // Pinned end-to-end on the engine side by + // `loop_counter_growth::plus_one_counter_growth_registers_a_target_the_bearer_does_not_yet_carry`. + // + // DISCRIMINATOR: `charge: 2` in the same frame is the paired positive — it proves the finite + // path still renders finitely, so this is not a component that started drawing ∞ for + // everything, and it makes the `oil: 0` negative non-vacuous. + it("renders a marked counter the object does not carry, with count 0 (F2 regression)", () => { + const { container } = inspectPentadPrism({ + pills: [ + { counter: "oil", count: 0, magnitude: "Unbounded" }, + { counter: "charge", count: 2 }, + ], + }); + expect(container.textContent).toContain("oil: ∞"); expect(container.textContent).toContain("charge: 2"); - // "nowhere" is asserted against `document.body`, not `container`: `GameplayTooltip` - // portals its summary lines out of the RTL container (GameplayTooltip.tsx:86-107), so - // a container-scoped negative could not see a tooltip that DID render the ∞. - expect(document.body.textContent).not.toContain("∞"); - expect(document.body.textContent).not.toContain("oil"); + // The ∞ must not be bought by showing a bogus finite count for a counter that is absent. + expect(container.textContent).not.toContain("oil: 0"); }); }); diff --git a/client/src/components/controls/AttackTargetPicker.tsx b/client/src/components/controls/AttackTargetPicker.tsx index 69dedd6872..719f8834ee 100644 --- a/client/src/components/controls/AttackTargetPicker.tsx +++ b/client/src/components/controls/AttackTargetPicker.tsx @@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next"; import type { AttackTarget, GameObject, ObjectId, PlayerId } from "../../adapter/types.ts"; import { getSeatColor } from "../../hooks/useSeatColor.ts"; +import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts"; import { useInspectHoverProps } from "../../hooks/useInspectHoverProps.ts"; import { usePlayerId } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; @@ -673,14 +674,6 @@ function objectPtLabel(obj: GameObject | undefined): string | null { return `${obj.power}/${obj.toughness}`; } -function objectCounterChips(obj: GameObject | undefined): Array<{ type: string; count: number }> { - if (!obj) return []; - return Object.entries(obj.counters) - .filter((entry): entry is [string, number] => entry[1] != null && entry[1] > 0 && entry[0] !== "loyalty") - .sort(([a], [b]) => a.localeCompare(b)) - .map(([type, count]) => ({ type, count })); -} - function RestoreTab({ onClick }: { onClick: () => void }) { const { t } = useTranslation("game"); return ( @@ -831,7 +824,11 @@ interface StackLabelProps { /** Stack name + count badge + P/T + counter chips, with inspect-on-hover. */ function StackLabel({ stack, t, hoverProps }: StackLabelProps) { const ptLabel = objectPtLabel(stack.representative ?? undefined); - const counters = objectCounterChips(stack.representative ?? undefined); + // CR 122.1 + CR 306.5c: the engine's counter-display projection is the single authority — + // it already dropped zero FINITE rows, split the loyalty total out, and ordered the pills. Keyed on + // `ids[0]` because that is exactly the object `representative` is defined as (combat.ts), so + // the hook call is unconditional and the chips match the row set `groupKey` grouped on. + const counters = pillsOf(useCounterDisplay(stack.ids[0])); return (
@@ -853,13 +850,19 @@ function StackLabel({ stack, t, hoverProps }: StackLabelProps) {
{counters.length > 0 && (
- {counters.map(({ type, count }) => ( - - - {formatCounterType(type)} x{count} - - - ))} + {counters.map((row) => { + const type = row.counter; + // CR 732.2a / CR 701.34a: an accepted counter-growth loop pumps this counter + // unboundedly — render ∞ instead of the (still-finite) real count. + const unbounded = isUnbounded(row); + return ( + + + {formatCounterType(type)} {unbounded ? "∞" : `x${row.count}`} + + + ); + })}
)}
diff --git a/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx b/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx index 8f2bc0ae51..b3d747fa77 100644 --- a/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx +++ b/client/src/components/controls/__tests__/AttackTargetPicker.test.tsx @@ -205,6 +205,105 @@ describe("AttackTargetPicker", () => { expect(screen.getAllByText("×2").length).toBeGreaterThan(0); }); + it("renders counter chips from the engine projection, never the raw counters map (CR 122.1)", () => { + // The raw map and the projection DISAGREE on every axis: a differing count, a row the map + // does not carry at all, and a map entry the projection dropped. Only a StackLabel reading + // `derived.counter_display` can satisfy all three, so any reintroduced `obj.counters` read + // (or a join back to it) fails here. + const goblin = makeCreature(101, "Goblin"); + useGameStore.setState({ + gameState: buildGameState({ + players: buildPlayers([0, 1, 2]), + seat_order: [0, 1, 2], + objects: buildObjectMap({ ...goblin, counters: { charge: 99, stun: 2 } }), + derived: { + counter_display: { + "101": { + // Row order is the engine's, not this file's: `counter_display_views` runs the + // `Unbounded` pass first, then the `Finite` one through a `BTreeMap`, so + // `Unbounded` rows lead and each class is ordered by `CounterType`'s DECLARATION + // `Ord` — where `Lore` precedes `Generic(_)`. No assertion below depends on it + // (order is pinned engine-side), but a fixture in a different order than the + // engine can ever emit is a false picture of the frame this site receives. + pills: [ + // CR 122.1: a zero-count UNBOUNDED row IS a shape the engine emits — the + // unbounded pass publishes its live count with no zero filter, so this is the + // `0 → 1` case. It must still render (as ∞), which is what fails if this site + // ever reintroduces a `count > 0` filter over the projected rows. + { counter: "quest", count: 0, magnitude: "Unbounded" }, + // CR 122.1: engine-supplied row with NO entry in the raw map, so it is + // unreachable by any client-side derivation from `obj.counters`. Count is + // nonzero on purpose: the FINITE pass of `counter_display_views` runs + // `positive_counter_entries`, so a zero-count Finite row is a shape the engine + // provably never emits. + { counter: "lore", count: 3 }, + { counter: "charge", count: 4 }, + ], + }, + }, + }, + }), + }); + render( + , + ); + enterDistribute(); + + // Projection count wins over the raw map's disagreeing count. + expect(screen.getAllByText("charge x4").length).toBeGreaterThan(0); + expect(screen.queryAllByText("charge x99")).toHaveLength(0); + // Projection-only row renders even though the raw map has no such key. + expect(screen.getAllByText("lore x3").length).toBeGreaterThan(0); + // Zero-count unbounded row survives to the screen as ∞ — a `count > 0` filter deletes it. + expect(screen.getAllByText("quest ∞").length).toBeGreaterThan(0); + // Raw-map-only entry the projection dropped must NOT render. + expect(screen.queryAllByText("stun x2")).toHaveLength(0); + }); + + it("visibly distinguishes two same-named stacks that differ only in counter magnitude (CR 732.2a)", () => { + // The MED regression: `groupKey` splits these two attackers because their engine counter + // rows differ, but with a raw-map-fed StackLabel both stacks render an IDENTICAL name and + // an IDENTICAL `charge x1` chip — a split with no visible cause. The counters maps are + // byte-identical, so the ONLY channel that can tell them apart is the projection's + // `magnitude`. + const a = makeCreature(101, "Goblin"); + const b = makeCreature(102, "Goblin"); + useGameStore.setState({ + gameState: buildGameState({ + players: buildPlayers([0, 1, 2]), + seat_order: [0, 1, 2], + objects: buildObjectMap( + { ...a, counters: { charge: 1 } }, + { ...b, counters: { charge: 1 } }, + ), + derived: { + counter_display: { + "101": { pills: [{ counter: "charge", count: 1, magnitude: "Unbounded" }] }, + "102": { pills: [{ counter: "charge", count: 1 }] }, + }, + }, + }), + }); + render( + , + ); + enterDistribute(); + + // Two separate stacks, each rendering its own chip, and the two chips DIFFER. + expect(screen.getAllByText("charge ∞").length).toBeGreaterThan(0); + expect(screen.getAllByText("charge x1").length).toBeGreaterThan(0); + }); + it("steppers claim the lowest-id unassigned member deterministically", () => { const { onConfirm } = renderPicker(); enterDistribute(); diff --git a/client/src/components/hud/BattlefieldPeekPopover.tsx b/client/src/components/hud/BattlefieldPeekPopover.tsx index 61af24e8f4..3919e66ae1 100644 --- a/client/src/components/hud/BattlefieldPeekPopover.tsx +++ b/client/src/components/hud/BattlefieldPeekPopover.tsx @@ -58,6 +58,9 @@ export function BattlefieldPeekPopover({ // Threaded into groupByName so the peek renders `∞` (not `×N`) exactly like the // main board (see buildPlayerBattlefieldView in gameStateView.ts). const unboundedPile = useGameStore((s) => s.gameState?.derived?.unbounded_pile); + // CR 122.1: the engine's counter-display projection is part of the group IDENTITY, so the peek + // splits exactly where the main board does. Store-owned ref or `undefined` — no allocation. + const counterDisplay = useGameStore((s) => s.gameState?.derived?.counter_display); if (!battlefield || !objects) return null; const owned = battlefield @@ -82,7 +85,7 @@ export function BattlefieldPeekPopover({ .map((id) => objects[id]) .filter((obj): obj is NonNullable => obj != null); const unboundedPileIds = new Set(unboundedPile ?? []); - const groups = groupByName(candidateObjects, undefined, unboundedPileIds); + const groups = groupByName(candidateObjects, undefined, unboundedPileIds, counterDisplay); // Sort legal targets to the front during targeting so the cap can never // hide a card the player needs to see. In idle mode the order from // `partitionByType` (creatures → planeswalkers → support) is preserved diff --git a/client/src/components/hud/DialogAttachmentCard.tsx b/client/src/components/hud/DialogAttachmentCard.tsx index 0d70705b90..03714cbc6e 100644 --- a/client/src/components/hud/DialogAttachmentCard.tsx +++ b/client/src/components/hud/DialogAttachmentCard.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import type { ObjectId } from "../../adapter/types.ts"; import { dispatchAction } from "../../game/dispatch.ts"; import { useCardHover } from "../../hooks/useCardHover.ts"; +import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts"; import { useCanActForWaitingState, usePlayerId, waitingPlayer } from "../../hooks/usePlayerId.ts"; import { cardImageLookup } from "../../services/cardImageLookup.ts"; import { useGameStore } from "../../stores/gameStore.ts"; @@ -108,14 +109,15 @@ export function DialogAttachmentCard({ objectId, widthPx, onDismiss }: Props) { const { handlers, firedRef } = useCardHover(objectId); + // CR 122.1 + CR 306.5c: the engine's counter-display projection is the single authority — + // it already dropped zero FINITE rows, split the loyalty total out of the pill strip, and + // ordered the pills, so nothing is filtered or sorted here. Called ABOVE the `!obj` early + // return: a hook below it would be conditional and break render order (`tsc` cannot see it). + const counters = pillsOf(useCounterDisplay(objectId)); + if (!obj) return null; const lookup = cardImageLookup(obj); - // Non-loyalty counters only — loyalty applies to planeswalkers, never Auras. - const counters = Object.entries(obj.counters).filter( - (entry): entry is [string, number] => - entry[0] !== "loyalty" && entry[1] != null && entry[1] > 0, - ); const sizeVars: CSSProperties = { "--card-w": `${widthPx}px`, @@ -211,15 +213,21 @@ export function DialogAttachmentCard({ objectId, widthPx, onDismiss }: Props) { )} {counters.length > 0 && (
- {counters.map(([type, count]) => ( - - - {formatCounterType(type)} x{count} - - - ))} + {counters.map((row) => { + const type = row.counter; + // CR 732.2a / CR 701.34a: an accepted counter-growth loop pumps this counter + // unboundedly — render ∞ instead of the (still-finite) real count. + const unbounded = isUnbounded(row); + return ( + + + {formatCounterType(type)} {unbounded ? "∞" : `x${row.count}`} + + + ); + })}
)}
diff --git a/client/src/components/hud/HudBadges.tsx b/client/src/components/hud/HudBadges.tsx index fc9132e641..dd4718dfd0 100644 --- a/client/src/components/hud/HudBadges.tsx +++ b/client/src/components/hud/HudBadges.tsx @@ -17,6 +17,8 @@ import type { UnboundedFamily, } from "../../adapter/types.ts"; import { useGameStore } from "../../stores/gameStore.ts"; +import { usePlayerId } from "../../hooks/usePlayerId.ts"; +import { useSpectatorMode } from "../../hooks/useSpectatorMode.ts"; import { getKeywordDisplayText } from "../../viewmodel/keywordProps.ts"; interface StatusBadgeProps { @@ -477,15 +479,39 @@ export function UnboundedBadge({ }) { const { t } = useTranslation("game"); const resource = t(UNBOUNDED_FAMILY_LABEL_KEY[family]); + // Both hooks are called UNCONDITIONALLY, before any branch — rules of hooks. No render site + // changed: the badge resolves the viewer itself rather than taking it as a prop. + const viewer = usePlayerId(); + const spectating = useSpectatorMode(); // A `Committed` scheduled collapse is an accepted-but-unapplied bound, and N is named at the - // next step/phase end by the loop's CONTROLLER — who is not necessarily the seat this badge sits - // on. `Conditional` promises no bound at all, which is why it gets its own copy. The row is - // keyed by the engine's attribution player, which for `Life`/`DamageDealt`/`LibraryDelta`/ - // `Poison` axes is the victim, and the badge also renders on opponent HUDs. So the copy stays in - // the passive voice: a second-person promise here would be addressed to the wrong seat in both - // cases. Emitting the prompted seat is the engine's to add, and is owned by the follow-on PR. + // next step/phase end by the loop's CONTROLLER — who is NOT necessarily the seat this badge sits + // on: the row is keyed by the engine's attribution player, which for `Life`/`DamageDealt`/ + // `LibraryDelta`/`Poison` axes is the victim, and the badge also renders on opponent HUDs. The + // engine now publishes that controller as `state.data.prompted`, so the copy can address the + // seat that will actually be asked, and falls back to the passive voice for everyone else. + // `Conditional` promises no bound at all, which is why it keeps its own copy in both voices. // The window itself is CR 732.2c's advance to the shortcut's ending point; this only reports // what the engine says is pending. + // + // `usePlayerId()` is the RAW seat, mirroring `useTurnStatus`'s documented rule — `prompted` is a + // seat, so it compares against seat identity and NOT against `usePerspectivePlayerId()`, which + // returns the seat whose turn is being controlled. TWO BOUNDS ARE DISCLOSED, not fixed here: + // (i) under a turn-control effect where viewer V controls seat A's turn and A is prompted, V + // personally answers the prompt but reads the third-person copy. Conservative by + // construction — the same fallback the `prompted === undefined` case takes, and never a + // false "you". Closing it needs the engine to publish "seat X may submit for seat Y", + // which is a turn-control question, not a CR 732 one. + // (ii) `game::turns` raises ONE `PayAmountChoice` for the controller's WHOLE stash, so a + // tokens+counters+life collapse shows the second-person badge on THREE families for ONE + // joint count. The copy says "you'll name the count", which is true of every family that + // count collapses, rather than "you'll choose how many of these", which would not be. + // + // The spectator gate is REQUIRED, not defensive: `usePlayerId()` returns `PLAYER_ID` (0) in + // spectate mode — never `SPECTATOR_PLAYER_ID` — so a loop prompted to seat 0 would otherwise + // read "you'll name the count" to every spectator. `useSpectatorMode()`'s predicate is exactly + // the union of `useCanActForWaitingState`'s two spectator gates, so this badge is never more + // permissive than the submit authority it is describing. + const you = !spectating && state.type === "Scheduled" && state.data.prompted === viewer; const title = ((): string => { switch (state.type) { case "Unscheduled": @@ -493,9 +519,14 @@ export function UnboundedBadge({ case "Mixed": return t("badges.unboundedMixedTooltip", { resource }); case "Scheduled": - return state.data === "Committed" - ? t("badges.unboundedScheduledTooltip", { resource }) - : t("badges.unboundedConditionalTooltip", { resource }); + return state.data.certainty === "Committed" + ? t(you ? "badges.unboundedScheduledYouTooltip" : "badges.unboundedScheduledTooltip", { + resource, + }) + : t( + you ? "badges.unboundedConditionalYouTooltip" : "badges.unboundedConditionalTooltip", + { resource }, + ); } })(); return ( @@ -520,8 +551,10 @@ export function UnboundedBadge({ case "Unscheduled": case "Mixed": return "∞"; + // The GLYPH is not person-dependent: `∞→N` / `∞→?` says what will land, not who is + // asked. Only the tooltip changes voice. case "Scheduled": - return state.data === "Committed" + return state.data.certainty === "Committed" ? t("badges.unboundedScheduledGlyph") : t("badges.unboundedConditionalGlyph"); } diff --git a/client/src/components/hud/__tests__/DialogAttachmentCard.test.tsx b/client/src/components/hud/__tests__/DialogAttachmentCard.test.tsx index 3239777d91..8507f211ac 100644 --- a/client/src/components/hud/__tests__/DialogAttachmentCard.test.tsx +++ b/client/src/components/hud/__tests__/DialogAttachmentCard.test.tsx @@ -94,12 +94,15 @@ function seed(options: { abilities?: unknown[]; legalActionsByObject?: Record; waitingFor?: WaitingFor; + counters?: GameObject["counters"]; + derived?: GameState["derived"]; }) { - const curse = makeCurse(options.abilities ?? [{ effect: { type: "Tap" } }]); + const base = makeCurse(options.abilities ?? [{ effect: { type: "Tap" } }]); + const curse = options.counters ? { ...base, counters: options.counters } : base; const waitingFor = options.waitingFor ?? buildPriorityWaitingFor(); useGameStore.setState({ gameMode: "local", - gameState: makeState(curse, waitingFor), + gameState: { ...makeState(curse, waitingFor), derived: options.derived }, waitingFor, legalActions: [], legalActionsByObject: options.legalActionsByObject ?? {}, @@ -209,4 +212,47 @@ describe("DialogAttachmentCard activation gate", () => { expect(useUiStore.getState().pendingAbilityChoice).toBeNull(); expect(benign.onDismiss).toHaveBeenCalledTimes(1); }); + + // FU-B: this site now consumes `derived.counter_display` with NO raw-map fallback and no + // filter. The raw map and the projection DISAGREE on every axis, so a reintroduced + // `Object.entries(obj.counters)` read (or a join back to it) fails at least one assertion. + // + // MEASURED (drop side): restoring the raw-map read fails with + // `Unable to find an element with the text: charge x4` (the projection count is gone, + // `charge x99` renders instead). + it("renders finite pills from the engine projection, never the raw counters map (CR 122.1)", () => { + seed({ + counters: { charge: 99, stun: 2 }, + derived: { counter_display: { [String(CURSE_ID)]: { pills: [{ counter: "charge", count: 4 }] } } }, + }); + + // Projection count wins over the raw map's disagreeing count. + expect(screen.getByText("charge x4")).toBeTruthy(); + expect(screen.queryByText("charge x99")).toBeNull(); + // Raw-map-only entry the projection dropped must NOT render. + expect(screen.queryByText("stun x2")).toBeNull(); + }); + + // CR 732.2a / CR 701.34a: the pill the superseded raw-map renderer could not express at all. + // `count: 0` is deliberate — the UNBOUNDED pass of `counter_display_views` has no zero filter, + // so a 0 -> 1 growth loop legitimately publishes an Unbounded row whose live count is still 0. + // + // MEASURED (drop side): restoring the raw-map read fails with + // `Unable to find an element with the text: charge ∞` — the map has no `charge` key at all, + // and even seeded it could only ever render a finite `x0`, which the old `> 0` filter dropped. + it("renders an Unbounded pill as ∞ even at count 0 (CR 732.2a)", () => { + seed({ + counters: {}, + derived: { + counter_display: { + [String(CURSE_ID)]: { + pills: [{ counter: "charge", count: 0, magnitude: "Unbounded" }], + }, + }, + }, + }); + + expect(screen.getByText("charge ∞")).toBeTruthy(); + expect(screen.queryByText("charge x0")).toBeNull(); + }); }); diff --git a/client/src/components/hud/__tests__/UnboundedBadge.test.tsx b/client/src/components/hud/__tests__/UnboundedBadge.test.tsx index e2917f128e..a3746c3141 100644 --- a/client/src/components/hud/__tests__/UnboundedBadge.test.tsx +++ b/client/src/components/hud/__tests__/UnboundedBadge.test.tsx @@ -28,12 +28,19 @@ import tokenWire from "../../../test/fixtures/unbounded-token-wire.json"; import { PlayerHud } from "../PlayerHud.tsx"; const PLAIN_TOKENS = "Unbounded tokens (∞)"; -// Passive voice on purpose: the badge renders on opponent HUDs, and a victim-attributed axis puts -// it on the victim's seat while the loop's CONTROLLER is the one prompted to name N — so any -// second-person phrasing here is addressed to the wrong player. +// TWO VOICES, and which one renders is an engine fact, not a style choice. The engine publishes +// `state.data.prompted` — the loop's CONTROLLER, the seat that will be asked to name N. The badge +// says "you" only when that seat IS the viewer AND the viewer is not spectating; otherwise it +// keeps the passive voice, because the row is keyed by the ATTRIBUTION player, which for a +// victim-attributed axis is the victim, and the badge also renders on opponent HUDs. const COMMITTED_COUNTERS = "Unbounded counters (∞) — collapse pending; a finite amount will be chosen"; const CONDITIONAL_TOKENS = "Unbounded tokens (∞) — collapse pending; this may stay unbounded"; +const COMMITTED_COUNTERS_YOU = "Unbounded counters (∞) — collapse pending; you'll name the count"; +const CONDITIONAL_TOKENS_YOU = + "Unbounded tokens (∞) — collapse pending; this may stay unbounded, and you'll name the count if it doesn't"; +const COMMITTED_TOKENS_YOU = "Unbounded tokens (∞) — collapse pending; you'll name the count"; +const COMMITTED_TOKENS = "Unbounded tokens (∞) — collapse pending; a finite amount will be chosen"; const MIXED_COUNTERS = "Unbounded counters (∞) — part of this group has a pending collapse; part remains unbounded"; const PLAIN_COUNTERS = "Unbounded counters (∞)"; @@ -47,8 +54,10 @@ const fam = ( describe("UnboundedBadge + usePlayerDesignations", () => { beforeEach(() => { - useMultiplayerStore.setState({ activePlayerId: 0 }); - useGameStore.setState({ gameState: buildGameState() }); + useMultiplayerStore.setState({ activePlayerId: 0, isSpectator: false }); + // `gameMode` is reset explicitly because U8/U9/U10 set it, and a leaked `"spectate"` would + // silently suppress every second-person assertion in the rows that follow. + useGameStore.setState({ gameMode: null, gameState: buildGameState() }); }); afterEach(() => { @@ -68,9 +77,17 @@ describe("UnboundedBadge + usePlayerDesignations", () => { // engine frames, two different glyphs. A component that mapped every `Scheduled` to the // scheduled glyph passes the second half and fails the first; one that never renders `∞→N` // fails the second. + // + // BOTH LABELS ARE SECOND-PERSON, and the HONEST BOUND for that is worth stating: both goldens + // carry `prompted: 0`, and the default test viewer is seat 0, so this fixture pins "the viewer + // really is the seat that will be asked ⇒ address them" and NOTHING about the divergent case. + // It CANNOT witness a prompted seat that differs from the badge's seat — the token golden's + // only axis is `TokensCreated`, an aggregate axis that attributes to its own controller, so + // badge seat == prompted seat == viewer by construction. U8 composes the divergence, and the + // engine-side pin is `two_controllers_draining_one_victim_do_not_cross_schedule` arms B/C. seed(tokenWire as unknown as DerivedViews); expect(screen.getAllByLabelText(/Unbounded/)).toHaveLength(1); - const conditional = screen.getByLabelText(CONDITIONAL_TOKENS); + const conditional = screen.getByLabelText(CONDITIONAL_TOKENS_YOU); expect(conditional).toBeInTheDocument(); expect(conditional.textContent).toContain("∞→?"); expect(conditional.textContent).not.toContain("∞→N"); @@ -79,7 +96,7 @@ describe("UnboundedBadge + usePlayerDesignations", () => { // frame in the suite. cleanup(); seed(counterWire as unknown as DerivedViews); - const committed = screen.getByLabelText(COMMITTED_COUNTERS); + const committed = screen.getByLabelText(COMMITTED_COUNTERS_YOU); expect(committed).toBeInTheDocument(); expect(committed.textContent).toContain("∞→N"); }); @@ -103,10 +120,12 @@ describe("UnboundedBadge + usePlayerDesignations", () => { it("U3/families: the engine's rows are rendered one badge per family, unmodified", () => { // COMPOSED — no single golden frame carries two families. The point of the row is that the FE // performs no fold at all now: two engine rows in, two badges out, each with its own state. + // `prompted` is deliberately OMITTED on both rows: this row is about family fan-out, not about + // voice, and an omitted seat renders the third person for everyone (pinned by U9). seed({ unbounded_families: [ - fam("tokens", { type: "Scheduled", data: "Conditional" }), - fam("counters", { type: "Scheduled", data: "Committed" }), + fam("tokens", { type: "Scheduled", data: { certainty: "Conditional" } }), + fam("counters", { type: "Scheduled", data: { certainty: "Committed" } }), ], } as DerivedViews); expect(screen.getAllByLabelText(/Unbounded/)).toHaveLength(2); @@ -147,7 +166,7 @@ describe("UnboundedBadge + usePlayerDesignations", () => { // above are not satisfied by a component that renders a bare `∞` for everything. cleanup(); seed(counterWire as unknown as DerivedViews); - expect(screen.getByLabelText(COMMITTED_COUNTERS).textContent).toContain("∞→N"); + expect(screen.getByLabelText(COMMITTED_COUNTERS_YOU).textContent).toContain("∞→N"); }); it("U4/viewer: another seat's SCHEDULED family does not schedule this seat's badge", () => { @@ -158,7 +177,8 @@ describe("UnboundedBadge + usePlayerDesignations", () => { seed({ unbounded_families: [ fam("tokens", { type: "Unscheduled" }, 0), - fam("tokens", { type: "Scheduled", data: "Committed" }, 1), + // `prompted` omitted — this row tests the SEAT FILTER, not the voice. + fam("tokens", { type: "Scheduled", data: { certainty: "Committed" } }, 1), ], } as DerivedViews); expect(screen.getAllByLabelText(/Unbounded/)).toHaveLength(1); @@ -171,10 +191,75 @@ describe("UnboundedBadge + usePlayerDesignations", () => { cleanup(); seed({ unbounded_families: [ - fam("tokens", { type: "Scheduled", data: "Committed" }, 0), + fam("tokens", { type: "Scheduled", data: { certainty: "Committed" } }, 0), fam("tokens", { type: "Unscheduled" }, 1), ], } as DerivedViews); expect(screen.getByLabelText(/collapse pending; a finite amount will be chosen/)).toBeInTheDocument(); }); + + it("U8/agency: the badge addresses the prompted seat, and only the prompted seat", () => { + // COMPOSED, and a MATCHED PAIR inside one `it` so neither half can stand alone. Identical + // family row; the ONLY thing that changes is `prompted`. Reds from both sides: + // - delete the `you` branch ⇒ the second-person half below fails; + // - hardcode the second person ⇒ the third-person half below fails. + // + // Seat 2 is deliberately NOT a seat this viewer can be: the badge sits on seat 0's HUD while + // the engine says seat 2 will be asked. That is the divergent shape the goldens cannot + // produce, and it is exactly the case a naive "the badge is on my HUD, so it's my prompt" + // implementation gets wrong. + act(() => { + useGameStore.setState({ gameMode: "online" }); + useMultiplayerStore.setState({ activePlayerId: 0 }); + }); + seed({ + unbounded_families: [fam("tokens", { type: "Scheduled", data: { certainty: "Committed", prompted: 2 } }, 0)], + } as DerivedViews); + expect(screen.getByLabelText(COMMITTED_TOKENS)).toBeInTheDocument(); + expect(screen.queryByLabelText(COMMITTED_TOKENS_YOU)).toBeNull(); + + // MATCHED POSITIVE: same row, same viewer, prompted seat is now the viewer. + cleanup(); + seed({ + unbounded_families: [fam("tokens", { type: "Scheduled", data: { certainty: "Committed", prompted: 0 } }, 0)], + } as DerivedViews); + expect(screen.getByLabelText(COMMITTED_TOKENS_YOU)).toBeInTheDocument(); + expect(screen.queryByLabelText(COMMITTED_TOKENS)).toBeNull(); + }); + + it("U9/ambiguous: an omitted prompted seat reads third person even for the viewer", () => { + // COMPOSED. `prompted` is omitted when the family's scheduled axes name TWO OR MORE distinct + // seats (the engine's seat meet fell to ⊥) — never "nobody". One glyph cannot address two + // players, so the badge must fall back to the seat-neutral voice rather than pick a winner, + // and it must do so even though the viewer is one of the candidates. + act(() => { + useGameStore.setState({ gameMode: "online" }); + useMultiplayerStore.setState({ activePlayerId: 0 }); + }); + seed({ + unbounded_families: [fam("tokens", { type: "Scheduled", data: { certainty: "Committed" } }, 0)], + } as DerivedViews); + expect(screen.getByLabelText(COMMITTED_TOKENS)).toBeInTheDocument(); + expect(screen.queryByLabelText(COMMITTED_TOKENS_YOU)).toBeNull(); + }); + + it("U10/spectator: a spectator reads third person even when the prompted seat equals their resolved id", () => { + // COMPOSED, and this is the FALSE-POSITIVE GATE — the one error class this design must not + // have. `usePlayerId()` returns `PLAYER_ID` (0) in spectate mode, NOT `SPECTATOR_PLAYER_ID`, + // so a loop prompted to seat 0 resolves `prompted === viewer` for EVERY spectator watching. + // Only the `!spectating &&` conjunct stops the badge telling a spectator they will name the + // count. + // + // REVERT-PROBE: drop `!spectating &&` from the `you` predicate ⇒ this reds, because the + // equality it guards genuinely holds here. U8's second-person half is the matched positive + // proving the gate does not simply suppress every "you". + act(() => { + useGameStore.setState({ gameMode: "spectate" }); + }); + seed({ + unbounded_families: [fam("tokens", { type: "Scheduled", data: { certainty: "Committed", prompted: 0 } }, 0)], + } as DerivedViews); + expect(screen.getByLabelText(COMMITTED_TOKENS)).toBeInTheDocument(); + expect(screen.queryByLabelText(COMMITTED_TOKENS_YOU)).toBeNull(); + }); }); diff --git a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx index a5e8429a74..4bed27913e 100644 --- a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx +++ b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx @@ -81,6 +81,20 @@ describe("LoopShortcutModal", () => { type: "DeclareShortcut", data: { count: { Fixed: 1 }, template: null }, }); + // §1b (`fixedCount_one`): CR 732.2b makes a proposal an upper bound, so the modal says + // "at most" — the ruled wording. Fails against the pre-§1b catalog ("Repeat once."). + expect(screen.getByText("Repeat at most once.")).toBeInTheDocument(); + }); + + // §1b (`fixedCount_other`, CR 732.2c): post-fix the object-growth offer seeds + // Fixed(MAX_SHORTCUT_CYCLES), and the modal echoes it verbatim — so the ceiling must render with + // the "at most" wording. Covers the other plural leaf and the {{count}} interpolation; the + // pre-§1b catalog renders "Repeat 1000 times." and fails. + it("renders the ceiling with the at-most wording (§1b)", () => { + seed(buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 1000 } } })); + render(); + + expect(screen.getByText("Repeat at most 1000 times.")).toBeInTheDocument(); }); // T3: display-only — a ConvokeTaps point renders a read-only info line and NO diff --git a/client/src/components/ui/LoyaltyBadge.tsx b/client/src/components/ui/LoyaltyBadge.tsx index b74b127f31..4348144570 100644 --- a/client/src/components/ui/LoyaltyBadge.tsx +++ b/client/src/components/ui/LoyaltyBadge.tsx @@ -7,12 +7,23 @@ type LoyaltyBadgeProps = { kind: "cost" | "total"; size?: "default" | "battlefield"; reinforcedTopRim?: boolean; + isUnbounded?: boolean; className?: string; style?: CSSProperties; }; -function loyaltyText(amount: number, kind: LoyaltyBadgeProps["kind"]): string { - if (kind === "total") return String(amount); +function loyaltyText( + amount: number, + kind: LoyaltyBadgeProps["kind"], + isUnbounded: boolean, +): string { + // CR 306.5c: a planeswalker's loyalty IS its loyalty-counter count, so an accepted + // counter-growth loop on that counter makes the TOTAL unbounded. + // CR 606.4: a loyalty ABILITY COST is a number of loyalty counters to put on or + // remove, shown by the ability's loyalty symbol — a different game fact from the + // total, and never unbounded. The `kind === "total"` guard makes an `∞` cost badge + // structurally unrepresentable, whatever a caller passes. + if (kind === "total") return isUnbounded ? "∞" : String(amount); if (amount > 0) return `+${amount}`; return String(amount).replace("-", "−"); } @@ -35,10 +46,14 @@ export function LoyaltyBadge({ kind, size = "default", reinforcedTopRim = false, + isUnbounded = false, className, style, }: LoyaltyBadgeProps) { - const text = loyaltyText(amount, kind); + // `aria-label={text}` below, so the accessible name becomes "∞" automatically. + // `data-loyalty-value={amount}` deliberately stays the real number: the DOM attribute + // stays truthful and existing selectors keep working. + const text = loyaltyText(amount, kind, isUnbounded); const iconClass = loyaltyShapeClass(amount, kind); return ( diff --git a/client/src/components/ui/__tests__/LoyaltyBadge.test.tsx b/client/src/components/ui/__tests__/LoyaltyBadge.test.tsx index 10a414d9a5..ed28b7e669 100644 --- a/client/src/components/ui/__tests__/LoyaltyBadge.test.tsx +++ b/client/src/components/ui/__tests__/LoyaltyBadge.test.tsx @@ -63,4 +63,42 @@ describe("LoyaltyBadge", () => { "drop-shadow(-1px -1px 0 rgba(255,255,255,0.8)) drop-shadow(0 -1.25px 0 #e2e8f0) drop-shadow(1px 1px 1px rgba(15,23,42,0.98))", ); }); + + // CR 306.5c: a planeswalker's loyalty IS the number of loyalty counters on it, so an accepted + // counter-growth loop on that counter makes the TOTAL unbounded and the badge renders ∞. + it("renders ∞ instead of the finite amount for an unbounded loyalty TOTAL", () => { + render(); + + expect(screen.getByText("∞")).toBeInTheDocument(); + expect(screen.queryByText("4")).not.toBeInTheDocument(); + // Accessibility is preserved, not simplified away: `aria-label` follows the text. + expect(screen.getByRole("img", { name: "∞" })).toBeInTheDocument(); + // …while the DOM attribute stays truthful, so existing selectors keep working. + expect(screen.getByRole("img", { name: "∞" })).toHaveAttribute("data-loyalty-value", "4"); + }); + + it("renders the finite total when the same badge is not marked unbounded (matched pair)", () => { + render(); + + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.queryByText("∞")).not.toBeInTheDocument(); + }); + + // CR 606.4: a loyalty ABILITY COST is a number of loyalty counters to put on or remove, shown + // by the ability's loyalty symbol — a different game fact from the total, and never unbounded. + // Rendering ∞ on an activation cost would be a rules-visible bug. This is the revert-probe for + // the `kind === "total"` guard: delete it and this test reds. + it("NEVER renders ∞ on a loyalty COST badge, even when isUnbounded is passed", () => { + render(); + + expect(screen.getByText("+4")).toBeInTheDocument(); + expect(screen.queryByText("∞")).not.toBeInTheDocument(); + }); + + it("NEVER renders ∞ on a NEGATIVE loyalty COST badge either", () => { + render(); + + expect(screen.getByText("−4")).toBeInTheDocument(); + expect(screen.queryByText("∞")).not.toBeInTheDocument(); + }); }); diff --git a/client/src/hooks/useCounterDisplay.ts b/client/src/hooks/useCounterDisplay.ts new file mode 100644 index 0000000000..62b5bb2448 --- /dev/null +++ b/client/src/hooks/useCounterDisplay.ts @@ -0,0 +1,92 @@ +import type { CounterRowView, ObjectCounterDisplay, ObjectId } from "../adapter/types.ts"; +import { useGameStore } from "../stores/gameStore.ts"; + +// CR 732.2a / CR 701.34a: stable empty refs so an object with no counter row (the dominant +// case) never re-renders on identity churn. +const EMPTY_DISPLAY: ObjectCounterDisplay = {}; +const EMPTY_PILLS: ReadonlyArray = []; + +/** + * CR 122.1 + CR 306.5c: every counter row this object renders, exactly as the engine + * partitioned and ordered them. + * + * The engine's `counter_display` projection is the SINGLE authority for counter display. It + * already split the loyalty TOTAL out of the pill strip (CR 306.5c), deduplicated across seats, + * and ordered the rows (`∞` first, then `CounterType` order). So this hook joins nothing, filters + * nothing, sorts nothing, and interprets no counter type — it is one keyed lookup. + * + * ZERO COUNTS ARE DROPPED IN THE FINITE PASS ONLY, and a consumer that re-filters on + * `count > 0` therefore deletes real rows. `counter_display_views`' finite pass admits through + * `positive_counter_entries` (CR 122.1 — a zero map entry is not a marker), so no `Finite` row + * carries `count: 0`; its UNBOUNDED pass has NO zero filter and reads the live count for a + * REGISTERED pair, so an `Unbounded` row legitimately carries `count: 0` for a pair the loop + * pumps `0 -> 1`. + * + * THERE IS NO FALLBACK TO `objects[id].counters`, AND ONE MUST NOT BE ADDED — not here, not in a + * render site, not in `groupKey`. A frame that arrives with no `derived` renders NO counter pills + * at all, where the superseded hook still rendered the finite ones. That is the correct outcome + * of deleting a second authority: `adapter/types.ts` states of `derived` that "Consumers MUST + * treat absence as 'no data' and MUST NOT synthesize grouped values client-side — that's a + * CLAUDE.md violation", and the deleted fallback was itself a standing violation of that + * contract. The consequence is that a dropped-`derived` adapter regression — `ws-adapter.ts` + * records a real past one — now fails VISIBLY instead of silently half-correct. + * + * ZUSTAND v5 HAZARD, eliminated rather than mitigated: there is no equality argument and no + * `shallow` default in v5 — the selector result IS React's `getSnapshot` return, compared with + * `Object.is`. A selector that ALLOCATES returns a fresh reference on every store read, fails + * React's getSnapshot cache check, and produces "The result of getSnapshot should be cached to + * avoid an infinite loop" plus a render loop. `tsc` cannot see it. The single selector below + * returns only a store-owned ref or a module constant, so there is nothing left to memoize. + * + * SUPERSEDED — kept as a record of why, because deleting it would invite the same design again. + * This hook's doc once prescribed an `.every()` intersection through `groupByName`/`AttackerStack`, + * mirroring `isUnboundedPile`. That solves only the FALSE-`∞` half: `.every()` degrades a group + * whose members disagree to `×N`, which HIDES a real `∞` and contradicts the polarity + * `derive_views` states for this subsystem. `groupKey` instead keys on the engine's rendered rows, + * so members that render differently never group at all — no false `∞` and no hidden real one, and + * the fix lands at every `groupByName` consumer at once instead of per chip. `isUnboundedPile`'s + * `.every()` stays as written: it is a fail-safe over a channel `groupKey` does not key on. + * + * Subscribed today by exactly FIVE render sites — EVERY counter DISPLAY surface in the client: + * `board/PermanentCard`, `card/ArtCropCard`, `card/CardPreview`'s `CardInfoPanel`, + * `controls/AttackTargetPicker`'s `StackLabel`, and `hud/DialogAttachmentCard`. FU-B (the last + * one) landed with this ledger revision; it previously enumerated and re-filtered + * `obj.counters`, so it could not express an `Unbounded` pill at all. + * + * Every surviving reader of the raw `objects[id].counters` map, measured with `git grep` and + * cross-checked with `ast-grep` (an `Object.entries`-shaped pattern alone MISSES the indexed + * reads, which is how an earlier census undercounted this list). BOTH are DELIBERATE, PERMANENT + * EXCLUSIONS — neither is a display site, and neither is a pending conversion. There is no + * remaining conversion work: + * - `modal/CardChoiceModal` (`:1712`, `:1715`, in `removableCounterCostEntries`) — NOT a + * display site and NOT a pending conversion. It enumerates which counters are legal to + * REMOVE AS A COST — CR 118.3, a player can't pay a cost without the resources to pay it + * fully, which is exactly the `count > 0` filter — so it must read the live payable map. It + * is reached for ability costs as well as spell costs, so the general cost rule governs, not + * the spell-casting payment step. It deliberately keeps + * `loyalty` (removing loyalty counters is a payable cost) where the display projection + * splits loyalty out per CR 306.5c, and a cost must be paid in real counters, so an + * `Unbounded` magnitude would be actively wrong here. + * - `chrome/DebugCardContextMenu` (`:253`, `:254`, `:258`, `:261`) — NOT a display site and NOT + * a pending conversion. It is a debug counter EDITOR: each `CounterRow` reads the current + * value of the counter its own +/- buttons are about to `ModifyCounters`, `loyalty` + * included. It needs the writable map it mutates, not the CR 306.5c-partitioned view. + * + * This hook now covers every counter render site. A NEW raw-map DISPLAY reader is a regression, + * not an omission — add it to the subscribed list above, or justify it here as a third exclusion. + */ +export function useCounterDisplay(objectId: ObjectId): ObjectCounterDisplay { + return useGameStore( + (s) => s.gameState?.derived?.counter_display?.[String(objectId)] ?? EMPTY_DISPLAY, + ); +} + +/** The pill rows, in engine order. Never sort or filter the result. */ +export const pillsOf = (display: ObjectCounterDisplay): ReadonlyArray => + display.pills ?? EMPTY_PILLS; + +/** + * The single spelling of the engine enum → render-time distinction, so the five render sites + * cannot drift. An absent `magnitude` is the serde default, `"Finite"`. + */ +export const isUnbounded = (row?: CounterRowView): boolean => row?.magnitude === "Unbounded"; diff --git a/client/src/hooks/useUnboundedCounterTypes.ts b/client/src/hooks/useUnboundedCounterTypes.ts deleted file mode 100644 index 35f79fa38f..0000000000 --- a/client/src/hooks/useUnboundedCounterTypes.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ObjectId } from "../adapter/types.ts"; -import { useGameStore } from "../stores/gameStore.ts"; - -// CR 732.2a / CR 701.34a: stable empty ref so a permanent with no unbounded counter -// (the dominant case) never re-renders on identity churn. -const EMPTY_UNBOUNDED_COUNTERS: string[] = []; - -/** - * CR 732.2a / CR 701.34a: the counter-type keys the engine marks as `∞` for this - * object (`DerivedViews::unbounded_counters`). Keys match the object's `counters` - * map (e.g. "charge"). DISPLAY-only — the object's real counter count is - * deliberately left finite (`game/engine.rs::materialize_object_growth_shortcut`), - * so a site that reads `obj.counters` alone shows a stale pre-shortcut number. - * - * Subscribed today by exactly three render sites: `board/PermanentCard`, - * `card/ArtCropCard`, and `card/CardPreview`'s `CardInfoPanel`. Two counter render - * sites remain unsubscribed, each with a measured blocker: - * - FU-A `controls/AttackTargetPicker` (`StackLabel`) — a chip stands for N grouped - * objects and the mark is per object id, so it needs an `.every()` intersection - * threaded through `groupByName`/`AttackerStack` (mirroring `isUnboundedPile`), - * not a representative lookup, or it renders a FALSE `∞`. - * - FU-B `hud/DialogAttachmentCard` — no component test file exists for it. - * Do not claim this hook covers every counter render site until both land. - */ -export function useUnboundedCounterTypes(objectId: ObjectId): string[] { - return useGameStore( - (s) => s.gameState?.derived?.unbounded_counters?.[String(objectId)] ?? EMPTY_UNBOUNDED_COUNTERS, - ); -} diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 004bf1a8f6..d759ab3937 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -16,8 +16,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -485,8 +485,10 @@ "companion": "Gefährte", "unboundedTooltip": "Unbegrenzt: {{resource}} (∞)", "unboundedScheduledTooltip": "Unbegrenzt: {{resource}} (∞) — Auflösung steht aus; eine endliche Anzahl wird gewählt", + "unboundedScheduledYouTooltip": "Unbegrenzt: {{resource}} (∞) — Auflösung steht aus; du nennst die Anzahl", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "Unbegrenzt: {{resource}} (∞) — Auflösung steht aus; dies kann unbegrenzt bleiben", + "unboundedConditionalYouTooltip": "Unbegrenzt: {{resource}} (∞) — Auflösung steht aus; dies kann unbegrenzt bleiben, und andernfalls nennst du die Anzahl", "unboundedMixedTooltip": "Unbegrenzt: {{resource}} (∞) — ein Teil dieser Gruppe hat eine ausstehende Auflösung; ein Teil bleibt unbegrenzt", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Unbegrenztes Mana (∞)", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 3f9b7bbabb..2a65bf4c3e 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -22,8 +22,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -524,8 +524,10 @@ "companion": "Companion", "unboundedTooltip": "Unbounded {{resource}} (∞)", "unboundedScheduledTooltip": "Unbounded {{resource}} (∞) — collapse pending; a finite amount will be chosen", + "unboundedScheduledYouTooltip": "Unbounded {{resource}} (∞) — collapse pending; you'll name the count", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "Unbounded {{resource}} (∞) — collapse pending; this may stay unbounded", + "unboundedConditionalYouTooltip": "Unbounded {{resource}} (∞) — collapse pending; this may stay unbounded, and you'll name the count if it doesn't", "unboundedMixedTooltip": "Unbounded {{resource}} (∞) — part of this group has a pending collapse; part remains unbounded", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Unbounded mana (∞)", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 581f6237f3..917ced0642 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -16,8 +16,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -485,8 +485,10 @@ "companion": "Compañero", "unboundedTooltip": "{{resource}} ilimitado (∞)", "unboundedScheduledTooltip": "{{resource}} ilimitado (∞) — colapso pendiente; se elegirá una cantidad finita", + "unboundedScheduledYouTooltip": "{{resource}} ilimitado (∞) — colapso pendiente; tú indicarás la cantidad", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "{{resource}} ilimitado (∞) — colapso pendiente; esto puede seguir siendo ilimitado", + "unboundedConditionalYouTooltip": "{{resource}} ilimitado (∞) — colapso pendiente; esto puede seguir siendo ilimitado y, si no, tú indicarás la cantidad", "unboundedMixedTooltip": "{{resource}} ilimitado (∞) — parte de este grupo tiene un colapso pendiente; parte sigue siendo ilimitada", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Maná ilimitado (∞)", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 272267a15d..7be0fc91f6 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -16,8 +16,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -485,8 +485,10 @@ "companion": "Compagnon", "unboundedTooltip": "{{resource}} illimité (∞)", "unboundedScheduledTooltip": "{{resource}} illimité (∞) — réduction en attente ; un nombre fini sera choisi", + "unboundedScheduledYouTooltip": "{{resource}} illimité (∞) — réduction en attente ; vous indiquerez le nombre", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "{{resource}} illimité (∞) — réduction en attente ; cela peut rester illimité", + "unboundedConditionalYouTooltip": "{{resource}} illimité (∞) — réduction en attente ; cela peut rester illimité, et sinon vous indiquerez le nombre", "unboundedMixedTooltip": "{{resource}} illimité (∞) — une partie de ce groupe a une réduction en attente ; une partie reste illimitée", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Mana illimité (∞)", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 58bb4a5332..77fc9b2871 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -16,8 +16,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -485,8 +485,10 @@ "companion": "Compagno", "unboundedTooltip": "{{resource}} illimitato (∞)", "unboundedScheduledTooltip": "{{resource}} illimitato (∞) — collasso in sospeso; verrà scelta una quantità finita", + "unboundedScheduledYouTooltip": "{{resource}} illimitato (∞) — collasso in sospeso; indicherai tu la quantità", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "{{resource}} illimitato (∞) — collasso in sospeso; questo può restare illimitato", + "unboundedConditionalYouTooltip": "{{resource}} illimitato (∞) — collasso in sospeso; questo può restare illimitato e, in caso contrario, indicherai tu la quantità", "unboundedMixedTooltip": "{{resource}} illimitato (∞) — parte di questo gruppo ha un collasso in sospeso; parte resta illimitata", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Mana illimitato (∞)", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 0d6bd6962e..be3308e13d 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -16,8 +16,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -485,8 +485,10 @@ "companion": "Towarzysz", "unboundedTooltip": "Nieograniczone: {{resource}} (∞)", "unboundedScheduledTooltip": "Nieograniczone: {{resource}} (∞) — zwinięcie oczekuje; zostanie wybrana skończona liczba", + "unboundedScheduledYouTooltip": "Nieograniczone: {{resource}} (∞) — zwinięcie oczekuje; podasz liczbę", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "Nieograniczone: {{resource}} (∞) — zwinięcie oczekuje; może pozostać nieograniczone", + "unboundedConditionalYouTooltip": "Nieograniczone: {{resource}} (∞) — zwinięcie oczekuje; może pozostać nieograniczone, a jeśli nie — podasz liczbę", "unboundedMixedTooltip": "Nieograniczone: {{resource}} (∞) — część tej grupy ma oczekujące zwinięcie; część pozostaje nieograniczona", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Nieograniczona mana (∞)", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 9d83f2fc5e..17dd8a5389 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -16,8 +16,8 @@ "shorten": "Break out", "decline": "Decline the shortcut", "untilLethal": "Repeat until the game ends.", - "fixedCount_one": "Repeat once.", - "fixedCount_other": "Repeat {{count}} times.", + "fixedCount_one": "Repeat at most once.", + "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "winKind": { @@ -485,8 +485,10 @@ "companion": "Companheiro", "unboundedTooltip": "{{resource}} ilimitado (∞)", "unboundedScheduledTooltip": "{{resource}} ilimitado (∞) — colapso pendente; será escolhida uma quantidade finita", + "unboundedScheduledYouTooltip": "{{resource}} ilimitado (∞) — colapso pendente; você indicará a quantidade", "unboundedScheduledGlyph": "∞→N", "unboundedConditionalTooltip": "{{resource}} ilimitado (∞) — colapso pendente; isto pode continuar ilimitado", + "unboundedConditionalYouTooltip": "{{resource}} ilimitado (∞) — colapso pendente; isto pode continuar ilimitado e, caso contrário, você indicará a quantidade", "unboundedMixedTooltip": "{{resource}} ilimitado (∞) — parte deste grupo tem um colapso pendente; parte continua ilimitada", "unboundedConditionalGlyph": "∞→?", "unboundedManaPoolMarker": "Mana ilimitada (∞)", diff --git a/client/src/test/fixtures/unbounded-counter-wire.json b/client/src/test/fixtures/unbounded-counter-wire.json index 0c4cff5916..153c39d0ee 100644 --- a/client/src/test/fixtures/unbounded-counter-wire.json +++ b/client/src/test/fixtures/unbounded-counter-wire.json @@ -1,15 +1,24 @@ { - "unbounded_counters": { - "405": [ - "charge" - ] + "counter_display": { + "405": { + "pills": [ + { + "count": 4, + "counter": "charge", + "magnitude": "Unbounded" + } + ] + } }, "unbounded_families": [ { "family": "counters", "player": 0, "state": { - "data": "Committed", + "data": { + "certainty": "Committed", + "prompted": 0 + }, "type": "Scheduled" } } diff --git a/client/src/test/fixtures/unbounded-token-wire.json b/client/src/test/fixtures/unbounded-token-wire.json index 53b9415989..59a548e64c 100644 --- a/client/src/test/fixtures/unbounded-token-wire.json +++ b/client/src/test/fixtures/unbounded-token-wire.json @@ -4,7 +4,10 @@ "family": "tokens", "player": 0, "state": { - "data": "Conditional", + "data": { + "certainty": "Conditional", + "prompted": 0 + }, "type": "Scheduled" } } diff --git a/client/src/utils/combat.ts b/client/src/utils/combat.ts index 88b55cb61b..c28058e022 100644 --- a/client/src/utils/combat.ts +++ b/client/src/utils/combat.ts @@ -194,7 +194,9 @@ export function groupAttackers( // `∞` like the battlefield (mirrors buildPlayerBattlefieldView in gameStateView.ts). const unboundedPileIds = new Set(state.derived?.unbounded_pile ?? []); - return groupByName(objects, ringBearerIds, unboundedPileIds) + // CR 122.1: the engine's counter-display projection is part of the group IDENTITY, so attacker + // stacks split exactly where the battlefield does (mirrors buildPlayerBattlefieldView). + return groupByName(objects, ringBearerIds, unboundedPileIds, state.derived?.counter_display) .flatMap((group) => subdivideByTargets( [...group.ids], diff --git a/client/src/viewmodel/__tests__/battlefieldGrouping.test.ts b/client/src/viewmodel/__tests__/battlefieldGrouping.test.ts index 0661ebc292..653f2ff8ed 100644 --- a/client/src/viewmodel/__tests__/battlefieldGrouping.test.ts +++ b/client/src/viewmodel/__tests__/battlefieldGrouping.test.ts @@ -222,7 +222,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Mountain" }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups[0]).toMatchObject({ name: "Forest", ids: [1, 2], count: 2 }); @@ -236,7 +236,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Forest", tapped: false }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); const untapped = groups.find((g) => !g.representative.tapped); @@ -251,8 +251,14 @@ describe("groupByName", () => { makeGameObject({ id: 2, name: "Grizzly Bears", counters: { Plus1Plus1: 1 } }), makeGameObject({ id: 3, name: "Grizzly Bears", attachments: [99] }), ]; + // The group identity reads the ENGINE's rows, so the fixture supplies them; `obj.counters` is + // kept in sync only so the object stays a plausible engine snapshot. + const counterDisplay = { + "1": { pills: [{ counter: "Plus1Plus1", count: 1 }] }, + "2": { pills: [{ counter: "Plus1Plus1", count: 1 }] }, + }; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, counterDisplay); // Two copies with identical counters stack; the one with an attachment is solo expect(groups).toHaveLength(2); @@ -267,7 +273,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Orc Army" }), ]; - const groups = groupByName(objects, new Set([2])); + const groups = groupByName(objects, new Set([2]), undefined, undefined); // The ring-bearer (id 2) never gets hidden behind a non-bearer // representative in a collapsed group — it always has its own entry so @@ -283,8 +289,13 @@ describe("groupByName", () => { makeGameObject({ id: 2, name: "Grizzly Bears", counters: { Plus1Plus1: 2 } }), makeGameObject({ id: 3, name: "Grizzly Bears", counters: { Plus1Plus1: 1 } }), ]; + const counterDisplay = { + "1": { pills: [{ counter: "Plus1Plus1", count: 1 }] }, + "2": { pills: [{ counter: "Plus1Plus1", count: 2 }] }, + "3": { pills: [{ counter: "Plus1Plus1", count: 1 }] }, + }; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, counterDisplay); expect(groups).toHaveLength(2); expect(groups.find((g) => g.count === 2)?.ids).toEqual([1, 3]); @@ -298,7 +309,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Grizzly Bears", power: 2, toughness: 2 }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups.find((g) => g.count === 2)?.ids).toEqual([1, 3]); @@ -312,7 +323,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Grizzly Bears", power: 2, toughness: 2 }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups.find((g) => g.count === 2)?.ids).toEqual([1, 3]); @@ -326,7 +337,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Llanowar Elves", keywords: [] }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups.find((g) => g.count === 2)?.ids).toEqual([1, 3]); @@ -342,7 +353,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Spectral Sailor", keywords: [ward2] }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups.find((g) => g.count === 2)?.ids).toEqual([1, 3]); @@ -356,7 +367,7 @@ describe("groupByName", () => { makeGameObject({ id: 3, name: "Grizzly Bears", color: ["Green"] }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups.find((g) => g.count === 2)?.ids).toEqual([1, 3]); @@ -369,7 +380,7 @@ describe("groupByName", () => { makeGameObject({ id: 9, name: "Mountain" }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups[0].name).toBe("Forest"); expect(groups[0].representative.id).toBe(5); @@ -417,7 +428,7 @@ describe("groupByName", () => { color: ["Black", "Green"], }); - const groups = groupByName([attackPest, diesPest]); + const groups = groupByName([attackPest, diesPest], undefined, undefined, undefined); expect(groups).toHaveLength(2); expect(groups.map((g) => g.count).sort()).toEqual([1, 1]); @@ -465,7 +476,7 @@ describe("groupByName", () => { }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); }); @@ -515,7 +526,7 @@ describe("groupByName", () => { }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); }); @@ -534,7 +545,7 @@ describe("groupByName", () => { }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(2); }); @@ -559,7 +570,7 @@ describe("groupByName", () => { }), ]; - const groups = groupByName(objects); + const groups = groupByName(objects, undefined, undefined, undefined); expect(groups).toHaveLength(1); expect(groups[0]).toMatchObject({ @@ -593,7 +604,7 @@ describe("groupByName", () => { const pile = new Set([1, 2]); // Both tapped Saprolings are pile members → the group is ∞. - const infinite = groupByName(tappedFodder, undefined, pile); + const infinite = groupByName(tappedFodder, undefined, pile, undefined); expect(infinite).toHaveLength(1); expect(infinite[0].isUnboundedPile).toBe(true); @@ -607,12 +618,56 @@ describe("groupByName", () => { toughness: 1, card_types: { supertypes: [], core_types: ["Creature"], subtypes: ["Saproling"] }, }); - const mixed = groupByName([...tappedFodder, untapped], undefined, pile); + const mixed = groupByName([...tappedFodder, untapped], undefined, pile, undefined); const untappedGroup = mixed.find((g) => g.ids.includes(3)); expect(untappedGroup?.isUnboundedPile).toBe(false); // Empty pile set → no group is ∞ (the dominant no-loop case; also the default). - expect(groupByName(tappedFodder)[0].isUnboundedPile).toBe(false); - expect(groupByName(tappedFodder, undefined, new Set())[0].isUnboundedPile).toBe(false); + expect(groupByName(tappedFodder, undefined, undefined, undefined)[0].isUnboundedPile).toBe(false); + expect(groupByName(tappedFodder, undefined, new Set(), undefined)[0].isUnboundedPile).toBe(false); + }); + + // CR 122.1 + CR 732.2a: the group identity keys on the engine's RENDERED counter rows, so a + // collapsed group's representative can never speak for a member that renders differently. This + // is the live `GroupedPermanent` representative bug — the collapsed branch subscribes the + // counter projection for `ids[0]` alone. + it("splits a group whose members disagree on their counter rows, and only then", () => { + const twins = () => [ + makeGameObject({ id: 1, name: "Pentad Prism", counters: { charge: 4 } }), + makeGameObject({ id: 2, name: "Pentad Prism", counters: { charge: 4 } }), + ]; + + // NEGATIVE — THE FIX. Byte-identical counter maps; only id 1 is `∞`-annotated. `magnitude` is + // absent on id 2's row exactly as the engine omits the serde default. + const divergent = groupByName(twins(), undefined, undefined, { + "1": { pills: [{ counter: "charge", count: 4, magnitude: "Unbounded" }] }, + "2": { pills: [{ counter: "charge", count: 4 }] }, + }); + expect(divergent).toHaveLength(2); + expect(divergent.find((g) => g.ids.includes(1))?.ids).toEqual([1]); + + // POSITIVE — THE REACH-GUARD. The SAME two objects with the SAME entry collapse into one + // group, which is what proves the split above is caused by the ∞ divergence and by nothing + // else (a groupKey that split on object id would pass the negative arm vacuously). + const identical = groupByName(twins(), undefined, undefined, { + "1": { pills: [{ counter: "charge", count: 4, magnitude: "Unbounded" }] }, + "2": { pills: [{ counter: "charge", count: 4, magnitude: "Unbounded" }] }, + }); + expect(identical).toHaveLength(1); + expect(identical[0].ids).toEqual([1, 2]); + + // FREE ARM 1 — objects with no entry at all still collapse, i.e. the new fragment does not + // accidentally key on identity. + expect(groupByName(twins(), undefined, undefined, {})).toHaveLength(1); + + // FREE ARM 2 — the DE-split direction (behavior change #5). The engine drops zero-count + // finite rows (CR 122.1), so a `{charge: 0}` permanent and a counterless one publish the same + // (empty) row set and now render identically. Reading `obj.counters` here instead of the + // projection splits them, as the head revision did. + const zeroVsAbsent = [ + makeGameObject({ id: 1, name: "Pentad Prism", counters: { charge: 0 } }), + makeGameObject({ id: 2, name: "Pentad Prism", counters: {} }), + ]; + expect(groupByName(zeroVsAbsent, undefined, undefined, {})).toHaveLength(1); }); }); diff --git a/client/src/viewmodel/__tests__/cardProps.test.ts b/client/src/viewmodel/__tests__/cardProps.test.ts index 07256a8176..36a6f55e21 100644 --- a/client/src/viewmodel/__tests__/cardProps.test.ts +++ b/client/src/viewmodel/__tests__/cardProps.test.ts @@ -128,16 +128,6 @@ describe("toCardProps", () => { expect(props.toughness).toBe(4); }); - it("extracts counters as typed array", () => { - const obj = makeGameObject({ counters: { P1P1: 2, loyalty: 3 } }); - const props = toCardProps(obj); - - expect(props.counters).toEqual([ - { type: "P1P1", count: 2 }, - { type: "loyalty", count: 3 }, - ]); - }); - it("detects creature and land types", () => { const creature = makeGameObject({ card_types: { supertypes: [], core_types: ["Creature"], subtypes: ["Elf"] }, @@ -189,8 +179,10 @@ describe("toRoman", () => { // LOW-4 (CR 732.2a / CR 701.34a): while an accepted counter-growth loop pumps a counter, the // badge renders `∞` — the tooltip summary must say "unbounded" and NOT leak the still-finite -// count. `isUnbounded` is the display-only flag threaded from the engine's `unbounded_counters` -// membership set (never computed in the frontend). +// count. `isUnbounded` is the display-only distinction read off the engine's `counter_display` +// projection (never computed in the frontend): each `CounterRowView` names an (object, counter) +// pair, carries the live count — which is `0` when the loop pumps a counter the object does not +// carry — and carries a typed `magnitude`, absent on the wire for the dominant `Finite` case. describe("formatCounterTooltip — unbounded summary", () => { it("says ∞ and hides the finite count when unbounded (fallback, no translator)", () => { const summary = formatCounterTooltip("charge", 4, undefined, true); diff --git a/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts index dd3b687980..ce5c73239b 100644 --- a/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts +++ b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts @@ -28,7 +28,7 @@ import type { UnboundedFamily, } from "../../adapter/types"; import { familyOf, UNBOUNDED_FAMILY_FOR_TEST } from "../../components/hud/HudBadges"; -import { useUnboundedCounterTypes } from "../../hooks/useUnboundedCounterTypes"; +import { pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay"; import { buildGameObject } from "../../test/factories/gameObjectFactory"; import { buildGameState } from "../../test/factories/gameStateFactory"; import counterWire from "../../test/fixtures/unbounded-counter-wire.json"; @@ -54,10 +54,18 @@ describe("unbounded ∞ wire seam (engine-emitted goldens)", () => { // (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"] }); + // The value is a PRE-PARTITIONED row set carrying the engine's live count, not a bare type + // list — and `magnitude` is only written for the exceptional `Unbounded` case. + expect(counterWire.counter_display).toEqual({ + "405": { pills: [{ counter: "charge", count: 4, magnitude: "Unbounded" }] }, + }); + // (2b) the discriminator against a `skip_serializing_if` INVERSION. Without it, an inversion + // that made every row read `Finite` would leave the golden above looking plausible — the TS + // mirror types `magnitude` as optional, so `tsc` cannot see it either. + expect(counterWire.counter_display["405"].pills[0].magnitude).toBe("Unbounded"); // (3) omit-when-empty, engine-attested in BOTH directions. expect("unbounded_pile" in counterWire).toBe(false); - expect("unbounded_counters" in tokenWire).toBe(false); + expect("counter_display" in tokenWire).toBe(false); // (4) the ROW no longer carries a schedule at all — the flag was deleted. Pinning the exact // row shape is what catches a partial revert that leaves the field on one side. expect(tokenWire.unbounded_resources).toEqual([{ axis: "TokensCreated", player: 0 }]); @@ -73,11 +81,31 @@ describe("unbounded ∞ wire seam (engine-emitted goldens)", () => { // - declined wire: the post-decline frame ⇒ Unscheduled, axis still ∞, promise withdrawn. // Certainty is the discriminator here: the first two are both "scheduled", and a projection // that collapsed them into one answer reds this row. + // + // EXHAUSTIVE OBJECT EQUALITY, not a property read, and that choice is the discriminator for + // `prompted`: `toEqual` on the whole row reds if the engine silently stops emitting the seat, + // whereas `expect(row.state.data.prompted).toBe(0)` would read `undefined` off a dropped field + // and... still pass on a golden regenerated without it. The seat is a real wire field, so it + // is pinned like one. + // + // HONEST BOUND: both goldens carry `prompted: 0`, and 0 is also the attributed seat, because + // each golden's single axis attributes to its own controller. So this file pins the ENCODING + // of the seat, never the divergence between the prompted seat and the badge's seat — that is + // `derived_views::tests::two_controllers_draining_one_victim_do_not_cross_schedule` arms B/C + // engine-side and `UnboundedBadge.test.tsx`'s U8 client-side. expect(tokenWire.unbounded_families).toEqual([ - { player: 0, family: "tokens", state: { type: "Scheduled", data: "Conditional" } }, + { + player: 0, + family: "tokens", + state: { type: "Scheduled", data: { certainty: "Conditional", prompted: 0 } }, + }, ]); expect(counterWire.unbounded_families).toEqual([ - { player: 0, family: "counters", state: { type: "Scheduled", data: "Committed" } }, + { + player: 0, + family: "counters", + state: { type: "Scheduled", data: { certainty: "Committed", prompted: 0 } }, + }, ]); expect(declinedWire.unbounded_families).toEqual([ { player: 0, family: "counters", state: { type: "Unscheduled" } }, @@ -135,7 +163,7 @@ describe("unbounded ∞ wire seam (engine-emitted goldens)", () => { }), ]; - const groups = groupByName(objects, new Set(), unboundedPileIds); + const groups = groupByName(objects, new Set(), unboundedPileIds, undefined); const groupOf = (id: ObjectId) => { const group = groups.find((g) => g.ids.includes(id)); expect(group, `no group contains ${id}`).toBeDefined(); @@ -171,13 +199,37 @@ describe("unbounded ∞ wire seam (engine-emitted goldens)", () => { ); }); - it("feeds the real useUnboundedCounterTypes hook from the engine wire", () => { + it("feeds the real useCounterDisplay hook from the engine wire", () => { + setGameStoreForTest({ + gameState: buildGameState({ derived: counterWire as unknown as DerivedViews }), + }); + // (10) paired POSITIVE through the real zustand selector — the engine's row reaches the hook + // verbatim, not re-derived from the object (which is absent from this state). + expect(renderHook(() => useCounterDisplay(405)).result.current).toEqual({ + pills: [{ counter: "charge", count: 4, magnitude: "Unbounded" }], + }); + // (11) paired NEGATIVE: 404 is on the same battlefield and has no projection entry. + expect(pillsOf(renderHook(() => useCounterDisplay(404)).result.current)).toEqual([]); + }); + + // The zustand v5 hazard the hook's shape exists to avoid: v5 has no shallow default, so the + // selector result IS React's `getSnapshot` return, compared with `Object.is`. An allocating + // selector returns a fresh ref on every store read and trips the getSnapshot cache. `tsc` + // cannot see it; this asserts the referential stability directly. + it("returns a referentially STABLE value across re-renders (zustand v5 getSnapshot)", () => { 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([]); + const marked = renderHook(() => useCounterDisplay(405)); + const firstMarked = marked.result.current; + marked.rerender(); + expect(marked.result.current).toBe(firstMarked); + + // The dominant no-row case must be stable too — that is what the module constants are for. + const bare = renderHook(() => useCounterDisplay(404)); + const firstBare = bare.result.current; + bare.rerender(); + expect(bare.result.current).toBe(firstBare); + expect(pillsOf(bare.result.current)).toBe(pillsOf(firstBare)); }); }); diff --git a/client/src/viewmodel/battlefieldProps.ts b/client/src/viewmodel/battlefieldProps.ts index c9bbc7b155..ca43752a65 100644 --- a/client/src/viewmodel/battlefieldProps.ts +++ b/client/src/viewmodel/battlefieldProps.ts @@ -1,4 +1,11 @@ -import type { AttackerInfo, CombatState, GameObject, ObjectId, PlayerId } from "../adapter/types"; +import type { + AttackerInfo, + CombatState, + GameObject, + ObjectCounterDisplay, + ObjectId, + PlayerId, +} from "../adapter/types"; import { publicName, toCardProps } from "./cardProps"; import type { CardViewProps } from "./cardProps"; @@ -9,18 +16,25 @@ function canGroup(obj: GameObject, ringBearerIds: ReadonlySet): boolea return obj.attachments.length === 0 && !ringBearerIds.has(obj.id); } -function groupKey(obj: GameObject): string { +function groupKey( + obj: GameObject, + counterDisplay: Record | undefined, +): string { const kw = obj.keywords.map((k) => typeof k === "string" ? k : JSON.stringify(k)).sort().join(","); const colors = [...obj.color].sort().join(""); - // counters is a known-shape Partial>. Build the - // key from sorted entries rather than JSON.stringify — cheaper (no serialize - // allocation per permanent on every board rebuild) and order-independent, so - // two identical permanents always land in the same group regardless of the - // order their counters were applied (the old stringify could split them by - // insertion order; this matches the sorted keyword key above). - const counters = Object.entries(obj.counters ?? {}) - .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) - .map(([type, n]) => `${type}:${n}`) + // CR 122.1 + CR 732.2a: the group identity for counters is the engine's RENDERED rows, not the + // raw map — two permanents that render different pills must not collapse into one representative, + // and an `∞`-marked member must never hide behind an unmarked one. The engine already orders the + // rows deterministically (`∞` first, then CounterType order), so this is a straight join, never a + // sort. Zero-count entries are absent from the engine's rows (CR 122.1), so two permanents + // differing only by a `{charge: 0}` entry now group TOGETHER; they render identically, which is + // what this key is for. + const display = counterDisplay?.[String(obj.id)]; + const counters = [ + ...(display?.pills ?? []), + ...(display?.loyalty ? [display.loyalty] : []), + ] + .map((r) => `${r.counter}:${r.count}:${r.magnitude ?? "Finite"}`) .join(","); // Tokens that share a display name (e.g. SOS vs BLC Pest) differ by rules text // and/or preset art — include both so visually distinct tokens never stack. @@ -93,10 +107,18 @@ export function partitionByType(objects: GameObject[]): BattlefieldPartition { const NO_RING_BEARERS: ReadonlySet = new Set(); const NO_UNBOUNDED_PILE: ReadonlySet = new Set(); +/** + * `counterDisplay` is POSITIONALLY REQUIRED even though it accepts `undefined`: `ringBearerIds` + * and `unboundedPileIds` are *enrichment* inputs whose omission only degrades a badge, while + * `counterDisplay` is a *correctness* input to the group identity itself — a default would + * silently produce wrong grouping at any site that forgot it. Defaults are for enrichment; + * correctness inputs get the compiler. + */ export function groupByName( objects: GameObject[], ringBearerIds: ReadonlySet = NO_RING_BEARERS, unboundedPileIds: ReadonlySet = NO_UNBOUNDED_PILE, + counterDisplay: Record | undefined, ): GroupedPermanent[] { const groups = new Map(); @@ -107,7 +129,7 @@ export function groupByName( continue; } - const key = groupKey(obj); + const key = groupKey(obj, counterDisplay); const existing = groups.get(key); if (existing) { existing.push(obj); diff --git a/client/src/viewmodel/cardProps.ts b/client/src/viewmodel/cardProps.ts index 4ce69f1a8d..3f6c73c97a 100644 --- a/client/src/viewmodel/cardProps.ts +++ b/client/src/viewmodel/cardProps.ts @@ -26,7 +26,6 @@ export interface CardViewProps { isPowerDebuffed: boolean; isToughnessBuffed: boolean; isToughnessDebuffed: boolean; - counters: Array<{ type: string; count: number }>; isCreature: boolean; isLand: boolean; attachedTo: AttachTarget | null; @@ -74,9 +73,6 @@ export function toCardProps(obj: GameObject): CardViewProps { isPowerDebuffed, isToughnessBuffed, isToughnessDebuffed, - counters: Object.entries(obj.counters) - .filter((entry): entry is [string, number] => entry[1] != null) - .map(([type, count]) => ({ type, count })), isCreature: obj.card_types.core_types.includes("Creature"), isLand: obj.card_types.core_types.includes("Land"), attachedTo: obj.attached_to, diff --git a/client/src/viewmodel/gameStateView.ts b/client/src/viewmodel/gameStateView.ts index c8f3a9037d..78ee8859bb 100644 --- a/client/src/viewmodel/gameStateView.ts +++ b/client/src/viewmodel/gameStateView.ts @@ -2,6 +2,7 @@ import type { GameAction, GameObject, GameState, + ObjectCounterDisplay, ObjectId, PlayerId, WaitingFor, @@ -755,10 +756,14 @@ export function buildPlayerBattlefieldView( // CR 732.2a: engine-authored ∞-pile membership (accepted object-growth loop). // Read exactly like ring_bearer — the adapter attaches `derived` onto gameState. const unboundedPileIds = new Set(gameState.derived?.unbounded_pile ?? []); + // CR 122.1: the engine's complete counter-display projection is part of the group IDENTITY — + // two permanents that render different counter rows must not share a representative. Read + // exactly like `unbounded_pile` above. return buildPlayerBattlefieldViewFromObjects( playerObjects, ringBearerIds, unboundedPileIds, + gameState.derived?.counter_display, ); } @@ -766,6 +771,7 @@ export function buildPlayerBattlefieldViewFromObjects( playerObjects: GameObject[], ringBearerIds: ReadonlySet = new Set(), unboundedPileIds: ReadonlySet = new Set(), + counterDisplay: Record | undefined, ): PlayerBattlefieldView { const partition = partitionByType(playerObjects); const objectMap = new Map(playerObjects.map((object) => [object.id, object])); @@ -775,11 +781,11 @@ export function buildPlayerBattlefieldViewFromObjects( .filter(Boolean) as GameObject[]; return { - creatures: groupByName(resolveObjects(partition.creatures), ringBearerIds, unboundedPileIds), - lands: groupByName(resolveObjects(partition.lands), ringBearerIds, unboundedPileIds), - support: groupByName(resolveObjects(partition.support), ringBearerIds, unboundedPileIds), - planeswalkers: groupByName(resolveObjects(partition.planeswalkers), ringBearerIds, unboundedPileIds), - other: groupByName(resolveObjects(partition.other), ringBearerIds, unboundedPileIds), + creatures: groupByName(resolveObjects(partition.creatures), ringBearerIds, unboundedPileIds, counterDisplay), + lands: groupByName(resolveObjects(partition.lands), ringBearerIds, unboundedPileIds, counterDisplay), + support: groupByName(resolveObjects(partition.support), ringBearerIds, unboundedPileIds, counterDisplay), + planeswalkers: groupByName(resolveObjects(partition.planeswalkers), ringBearerIds, unboundedPileIds, counterDisplay), + other: groupByName(resolveObjects(partition.other), ringBearerIds, unboundedPileIds, counterDisplay), }; } diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 86907cff62..68a0c66fe6 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -2959,9 +2959,13 @@ enum CounterGrowthDisposition { /// of the counter-growth cover (charge / burden / oil / quest)? This `match` IS the /// SINGLE-SOURCE per-`CounterType` classification table, WILDCARD-FREE by /// construction, so a new `CounterType` variant will not compile until it is -/// explicitly classified here. Shared by BOTH `classify_generic_counter_growth` (the -/// ω-cover direction gate) and `grown_generic_counter_targets` (the display -/// re-derivation) so the two can never drift out of lockstep. Kept in lockstep with +/// explicitly classified here. Scoped to the ω-COVER DIRECTION GATE alone +/// (`classify_generic_counter_growth`) — it is NOT the display partition. Sharing one +/// partition between the cover and the ∞ display channel WAS the bug: it made every +/// non-`Generic` beneficial counter loop (+1/+1, loyalty, defense) collapse correctly +/// while rendering no `∞` pill at all. The display and batched-collapse channels use +/// `counter_is_beneficial_materializable` instead, and the two partitions are now +/// deliberately different rather than accidentally shared. Kept in lockstep with /// `CounterType::is_monotone_loop_resource`, which governs the projection: monotone /// P/T / loyalty / defense counters are `project_out_resources`'d away, the /// non-`Generic` preserved counters gate SBAs/durations and so must compare @@ -3035,45 +3039,6 @@ fn classify_generic_counter_growth( } } -/// CR 122.1 + CR 701.34a + CR 732.2a: the per-object `(ObjectId, CounterType)` pairs -/// whose PRESERVED `Generic` counters STRICTLY GREW across one cycle (`current` vs -/// `prior`) — the concrete DISPLAY targets of an accepted counter-growth loop -/// (proliferate charge on Pentad Prism, burden on The One Ring). The offer -/// certificate's unbounded axis is object-AGNOSTIC (`Counter(Other, Other)`), so the -/// specific object id / counter type is NOT recoverable from the axis; this -/// re-derives them by diffing each SHARED object's growable counters — the display -/// analog of `classify_generic_counter_growth`, sharing its SAME wildcard-free -/// `generic_counter_is_growable` partition (single-source, so they can't drift). -/// -/// Iterates the CURRENT side only: strict growth requires `a > b >= 0`, so a grown -/// counter is necessarily present in `current`'s map — this both captures every -/// grown pair (no false negatives) and is duplicate-free (unlike a two-sided key -/// chain). An object absent from `prior` is caught by the object-set cover, not this -/// axis, so only SHARED objects contribute. DISPLAY-ONLY: the caller renders `∞` -/// from these pairs without mutating the real counter count (CR 701.34a still adds a -/// real counter each cycle; the `∞` is a render of the certified-unbounded loop). -pub(crate) fn grown_generic_counter_targets( - prior: &GameState, - current: &GameState, -) -> Vec<(ObjectId, CounterType)> { - let mut targets = Vec::new(); - for (id, co) in current.objects.iter() { - let Some(po) = prior.objects.get(id) else { - continue; - }; - for (ct, &a) in co.counters.iter() { - if !generic_counter_is_growable(ct) { - continue; - } - let b = po.counters.get(ct).copied().unwrap_or(0); - if a > b { - targets.push((*id, ct.clone())); - } - } - } - targets -} - /// CR 122.1 + CR 732.2a: the wildcard-free partition of `CounterType`s whose per-cycle /// growth is a BENEFICIAL persistent artifact materializable N×δ at the CR 500.5 boundary /// (the batched-collapse path). SEPARATE from `generic_counter_is_growable` (the cover @@ -3110,13 +3075,15 @@ pub(crate) fn counter_is_beneficial_materializable(ct: &CounterType) -> bool { } } -/// CR 122.1 + CR 732.2a: the per-object `(ObjectId, CounterType, delta)` triples whose -/// BENEFICIAL-materializable counters strictly grew across one accepted period (`current` -/// vs `prior`) — the batched-collapse δ source. The beneficial analog of -/// `grown_generic_counter_targets` (Generic-only for the DISPLAY channel); this widens to -/// +1/+1 / loyalty / defense via `counter_is_beneficial_materializable`. A CLONE, not a -/// refactor: the display/cover Generic partition must stay narrow. Iterates the CURRENT -/// side (strict growth ⇒ the grown counter is present in `current`); only SHARED objects +/// CR 122.1 + CR 732.2a: THE per-object counter derivation of an accepted period — the +/// `(ObjectId, CounterType, delta)` triples whose BENEFICIAL-materializable counters strictly +/// grew across it (`current` vs `prior`), feeding BOTH the batched-collapse δ stash AND (projected +/// to `(object, counter)`) the `∞` DISPLAY channel. ONE derivation, two consumers, so the pills +/// and the growth that lands cannot disagree; the display channel used to run its own Generic-only +/// diff, which is why beneficial non-`Generic` loops collapsed without ever rendering `∞`. +/// Partitioned by `counter_is_beneficial_materializable` (`Generic(_)` / +1/+1 / loyalty / +/// defense), deliberately WIDER than the ω-cover's `generic_counter_is_growable`. Iterates the +/// CURRENT side (strict growth ⇒ the grown counter is present in `current`); only SHARED objects /// contribute (a fresh object is caught by the object-set cover, not this axis). pub(crate) fn grown_beneficial_counter_deltas( prior: &GameState, diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 047cd13924..76472cdb1f 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -27,7 +27,7 @@ use crate::types::ability::{ use crate::types::attribution::EffectRef; use crate::types::card::TokenImageRef; use crate::types::card_type::CoreType; -use crate::types::counter::CounterType; +use crate::types::counter::{positive_counter_entries, CounterType}; use crate::types::events::GameEvent; use crate::types::format::GameFormat; use crate::types::game_state::{ @@ -241,28 +241,62 @@ impl CollapseCertainty { pub enum FamilyCollapseState { Unscheduled, Mixed, - Scheduled(CollapseCertainty), + Scheduled { + certainty: CollapseCertainty, + /// CR 732.2a: the seat that will be asked to name the "specified number of times" when + /// this collapse cashes out — the loop's CONTROLLER, emitted because it is NOT + /// recoverable from [`UnboundedFamilyView::player`] (the ATTRIBUTION seat, which for + /// `Life`/`DamageDealt`/`LibraryDelta`/`Poison` is the VICTIM, who is never asked). + /// + /// `None` means the family's scheduled axes name TWO OR MORE distinct seats — never + /// "nobody", which this variant makes unrepresentable. One glyph cannot address two + /// players, so the badge falls back to the seat-neutral voice rather than picking a + /// winner. Witnessed by `two_controllers_draining_one_victim_do_not_cross_schedule`. + /// + /// SCOPE: `game::turns` raises ONE `PayAmountChoice` for the controller's WHOLE stash, so + /// a multi-family collapse names one count across several badges. The shipped copy says + /// "you'll name the count", which is true of each family that count collapses. + #[serde(default, skip_serializing_if = "Option::is_none")] + prompted: Option, + }, } impl FamilyCollapseState { - /// Join: `Scheduled(a) ⊔ Scheduled(b) = Scheduled(weaker)`, `Unscheduled ⊔ Scheduled(_) = - /// Mixed`, `Mixed` is top. Commutative + associative + idempotent because it IS a join — - /// load-bearing: the FE fold it replaces documented a last-wins order hazard, and its open - /// question ("what would make the over-report reachable") is settled here rather than avoided: - /// `Mixed` is REPRESENTABLE, so a mixed family renders a bare `∞` instead of a wrong `∞→N`. - /// Witnessed by `mixed_family_is_not_scheduled` and + /// Join: `Scheduled ⊔ Scheduled = Scheduled(weaker certainty, met seat)`, + /// `Unscheduled ⊔ Scheduled { .. } = Mixed`, `Mixed` is top. Commutative + associative + + /// idempotent because it IS a join — load-bearing: the FE fold it replaces documented a + /// last-wins order hazard, and its open question ("what would make the over-report + /// reachable") is settled here rather than avoided: `Mixed` is REPRESENTABLE, so a mixed + /// family renders a bare `∞` instead of a wrong `∞→N`. Witnessed by + /// `mixed_family_is_not_scheduled` and /// `two_controllers_draining_one_victim_do_not_cross_schedule`. /// + /// The seat meet is the flat-lattice meet on `Option` (⊥ = `None`): equal seats + /// agree, distinct seats fall to `None`. Idempotent, commutative and associative, so the fold + /// over a family's axes is order-independent for any number of members. The two + /// `Unscheduled × Scheduled` arms drop the seat STRUCTURALLY — a `Mixed` family names none. + /// /// No CR governs this — it is a join over a display projection, not a rules behavior /// (cf. `game/filter.rs`'s `context_free_prop_matches_face` Kleene `AnyOf` arm). fn merge(self, other: Self) -> Self { match (self, other) { (Self::Mixed, _) | (_, Self::Mixed) => Self::Mixed, (Self::Unscheduled, Self::Unscheduled) => Self::Unscheduled, - (Self::Unscheduled, Self::Scheduled(_)) | (Self::Scheduled(_), Self::Unscheduled) => { - Self::Mixed - } - (Self::Scheduled(a), Self::Scheduled(b)) => Self::Scheduled(a.weaker(b)), + (Self::Unscheduled, Self::Scheduled { .. }) + | (Self::Scheduled { .. }, Self::Unscheduled) => Self::Mixed, + ( + Self::Scheduled { + certainty: a, + prompted: p, + }, + Self::Scheduled { + certainty: b, + prompted: q, + }, + ) => Self::Scheduled { + certainty: a.weaker(b), + prompted: if p == q { p } else { None }, + }, } } } @@ -300,13 +334,14 @@ impl FamilyCollapseState { /// SAME-FRAME ASYMMETRY — UNCHANGED AND LIVE. Carried forward from the `scheduled` flag this /// channel replaced, because retyping the flag as [`FamilyCollapseState`] did not answer the /// objection, and a reader still sees it on screen. Only THIS channel carries a collapse state. -/// `unbounded_pile` (card groups) and `unbounded_counters` (counter pills) are `ObjectId`-keyed and +/// `unbounded_pile` (card groups) and `counter_display` (counter pills) are `ObjectId`-keyed and /// carry no collapse projection at all, so during the accept→boundary window one loop can show /// `∞→N` on the badge and a plain `∞` on its own token group and counter pill in the SAME frame. /// Witnessed rather than asserted: /// `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_target` -/// pins `unbounded_counters[Pentad] == [charge]` — a bare `∞` pill — in the exact frame whose -/// golden family state is `Scheduled(Committed)`. +/// pins `counter_display[Pentad]` as a single `charge` row carrying [`CounterMagnitude`]'s +/// `Unbounded` — a bare `∞` pill — in the exact frame whose golden family state is +/// `Scheduled(Committed)`. /// /// THE ANSWER, not a disclosure: this is not the `Mana(_)` false-promise case. The collapse really /// IS scheduled for that axis, so the quiet surfaces under-announce; none of them promises a bound @@ -315,6 +350,22 @@ impl FamilyCollapseState { /// that join downstream is precisely the display-layer computation this channel exists to remove /// (see `CLAUDE.md`). `Mana(_)` is different in kind — its promise is false the moment it is made — /// and it is handled by exclusion upstream at `scheduled_display_axes`, not by this asymmetry. +/// +/// THE SECOND ASYMMETRY, ACROSS THE BOUNDARY RATHER THAN INSIDE THE WINDOW — disclosed, measured, +/// and deliberately kept. `∞` counter targets are registered for the whole beneficial-counter +/// partition, while a `DriveSequence` collapse names only the axes its own proposal carried. At the +/// boundary, `types::game_state::clear_collapsed_materializations` filters the registered pairs by +/// the collapsed axes and RE-INSERTS the survivors, and the counter-pill loop below is ungated on +/// `unbounded_resources` — so a registered pair whose derived axis was NOT in the driven collapse +/// keeps rendering `∞` on its own pill for one boundary after its family row is gone. The rules +/// state is untouched: that pair's axis was never collapsed, so per CR 732.2c nothing about it has +/// ended, and the axis-removal set and the `unbounded_loop_enablers` lockstep both move exactly as +/// they did before the widening. Fixing the pill by stripping unmatched pairs would trade this +/// display over-KEEP for a display over-DROP, which the subsystem's stated polarity forbids: it may +/// only ever leave an `∞` standing one boundary longer than it should, never hide a real one. +/// Pinned by `types::game_state`'s +/// `widened_counter_registration_survives_a_driven_collapse_without_moving_the_axis_set` and its +/// matched negative `a_counter_pair_on_the_driven_axis_is_dropped_at_the_boundary`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UnboundedFamilyView { pub player: PlayerId, @@ -322,6 +373,61 @@ pub struct UnboundedFamilyView { pub state: FamilyCollapseState, } +/// CR 122.1 + CR 732.2a: whether a counter row's count is a real quantity or one an accepted +/// shortcut pumps without bound. Typed rather than a bool because the row is engine-classified +/// data, not a render-time guess. `Finite` is the dominant case and is therefore the serde +/// default, so the wire stays quiet for it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CounterMagnitude { + #[default] + Finite, + Unbounded, +} + +/// `skip_serializing_if` predicate for [`CounterRowView`]'s `magnitude`, mirroring the free-fn +/// `is_false` shape every sibling display row in this module already uses. +fn is_finite(magnitude: &CounterMagnitude) -> bool { + matches!(magnitude, CounterMagnitude::Finite) +} + +/// One RENDERABLE counter row on one object: an (object, counter) pair. CR 122.1 — a counter is a +/// marker ON an object, so the pair IS the row key and no two rows can name one pair. +/// +/// `count` is the object's LIVE count; the `unwrap_or(0)` at the projection site is the +/// PRODUCER/PROJECTOR convention mirroring `analysis::resource::grown_beneficial_counter_deltas`, +/// so both ends share one definition of "absent". An `Unbounded` row with `count: 0` is real, not +/// a placeholder: the pair is derived by diffing a SIMULATED one-period frame against a clone of +/// the LIVE state (`game::engine::drive_one_period_frames`), so a pair growing `0 -> 1` across +/// that period is registered while the live object carries NONE of that counter. +/// +/// DISPLAY-only — never written back to `GameState`. +/// +/// Derive list matches its siblings [`UnboundedResourceView`] / [`UnboundedFamilyView`] exactly. +/// `Eq` is not optional: [`DerivedViews`] itself derives `Eq`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CounterRowView { + pub counter: CounterType, + pub count: u32, + #[serde(default, skip_serializing_if = "is_finite")] + pub magnitude: CounterMagnitude, +} + +/// Every counter row one object renders, PRE-PARTITIONED by where it renders — so the display +/// layer selects nothing, filters nothing, and interprets no counter type. +/// +/// CR 306.5c: a planeswalker's loyalty IS its loyalty-counter count, so a loyalty counter on an +/// object that HAS a loyalty characteristic drives the total badge, never a pill. +/// CR 606.4: a loyalty ABILITY COST is a different game fact and is never projected here. +/// A loyalty counter on an object with NO loyalty characteristic is a `pills` row — CR 306.5c +/// speaks only of planeswalkers, and hiding such a marker would be an over-DROP. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectCounterDisplay { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pills: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loyalty: Option, +} + /// The display family a pumped [`ResourceAxis`] groups into. Exhaustive by design (no wildcard) — /// a new `ResourceAxis` variant must make a deliberate grouping choice here. Payload-independent /// by construction: only the variant tag decides the family, exactly as the client's @@ -564,13 +670,20 @@ pub struct DerivedViews { /// `unbounded_families` below); the frontend renders what it is handed. /// Empty (and omitted) in the dominant case where no loop is active. /// - /// NOT a straight projection of the mark: a TOKEN-axis row is withheld when its entire - /// registered pile has left the battlefield ([`object_growth_backing`]), so this can carry - /// FEWER axes than `GameState::unbounded_resources` marks. The mark and the accepted stash - /// are both unaffected by that — it is a display decision, never a cancellation of agreed - /// growth (CR 732.2c). A withheld row therefore does NOT mean the collapse was cancelled; + /// NOT a straight projection of the mark: an object-backed row (TOKEN axis, or a COUNTER axis + /// with registered targets) is withheld on TWO conjuncts, and both must hold — + /// + /// 1. the controller has NO accepted collapse for that axis ([`accepted_collapse_axes`]), and + /// 2. the axis' entire registered board backing has left the battlefield + /// ([`object_growth_backing`] answering `Some(false)`). + /// + /// so this can carry FEWER axes than `GameState::unbounded_resources` marks. Conjunct 1 is + /// CR 732.2c: once the last player accepts, the shortcut is TAKEN, so an agreed collapse is not + /// cancelled by its board backing dying afterwards — the growth still lands at the boundary and + /// the row keeps saying so. Conjunct 2 alone is a display decision, never a cancellation of + /// agreed growth. A withheld row therefore does NOT mean the collapse was cancelled; /// `pending_unbounded_materialization` still carries it and the boundary still applies it, - /// which `combo_infinite_pile::object_growth_infinity_row_dies_with_its_last_pile_member` + /// which `combo_infinite_pile::accepted_object_growth_row_survives_losing_its_entire_pile` /// asserts at the store level. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unbounded_resources: Vec, @@ -593,19 +706,42 @@ pub struct DerivedViews { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unbounded_pile: Vec, - /// CR 732.2a / CR 701.34a: the per-object `∞` COUNTER channel — for each - /// battlefield object, the counter types whose preserved `Generic` counters an - /// accepted counter-growth loop (proliferate charge on Pentad Prism, burden on - /// The One Ring) pumps unboundedly (projected from - /// `GameState::unbounded_counter_targets`, filtered to objects still on the - /// battlefield). The counter analog of `unbounded_pile`: object-growth marks whole - /// objects, but a counter-growth loop's unbounded axis is object-agnostic, so this - /// keys the specific pumped counter so the frontend renders `∞` (not `×N`) on that - /// counter pill and nothing else. Keyed by ObjectId; DISPLAY-only (the real counter - /// count is unchanged). Public board state — no viewer filtering. Empty (and - /// omitted) when no counter-growth loop is active — the dominant case. + /// CR 122.1 + CR 732.2a: the COMPLETE per-object counter-display projection — every counter + /// row every display surface renders, for EVERY object that has one, in ANY zone. The single + /// authority for counter display: the client looks up its object's [`ObjectCounterDisplay`] + /// and renders it, joining nothing, filtering nothing, sorting nothing, and interpreting no + /// counter type. Produced by `counter_display_views`. + /// + /// TWO DISTINCT EXISTENCE GATES, and conflating them is the bug this shape exists to prevent: + /// - A FINITE row exists iff the object's own map carries that counter with a POSITIVE count + /// (`types::counter::positive_counter_entries` — CR 122.1, a zero map entry is not a + /// marker). It is NOT battlefield-gated. CR 122.2 already makes counters cease to exist on + /// a zone change, and `zones::counters_persist_on_move` is the SINGLE authority for the + /// CR 113.6b carve-out that overrides it (Skullbriar, Me the Immortal) — so a zone gate + /// here would be a second, weaker copy of that rule, and it would also delete a suspended + /// card's time counters in exile (CR 702.62b). The projection defers; it never re-derives. + /// - The `Unbounded` ANNOTATION exists iff the pair is registered in + /// `GameState::unbounded_counter_targets` AND the bearer is on the LIVE battlefield. + /// CR 110.1: a permanent is a card or token on the battlefield, and the CR 732.2a mark + /// claims a PERMANENT's counters are being pumped — off the battlefield there is no + /// permanent, so the annotation drops while a persisting finite row may survive. + /// + /// Cross-seat duplication is structurally impossible rather than deduplicated: the row key is + /// `(ObjectId, CounterType)`, so the per-seat store holding one pair twice yields one row. + /// + /// ORDER: `Unbounded` rows lead, then `CounterType`'s declaration `Ord` inside each class. + /// The lead is display salience under clipping — all five subscribed strips are fixed-size + /// overlay stacks, so a row pushed past the fold is a row the player does not see, and the `∞` + /// state is the exceptional one. The finite tie-break is `CounterType`'s `Ord` because that is + /// already the order `counter_map_serde` puts `objects[*].counters` on the wire in, so the + /// finite-only case — the dominant case — renders exactly as it did before. + /// + /// PAYLOAD: this field's population grew from `∞`-registered battlefield pairs (usually zero + /// entries) to every counter-bearing object. Measured on the production dumps this PR drives: + /// ONE entry on a 411-object board and ZERO on a 410-object one, and `HashMap::is_empty` still + /// omits the channel entirely for a counterless board. #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub unbounded_counters: HashMap>, + pub counter_display: HashMap, } /// Serialize-only wrapper: the WASM getter passes `&GameState` by reference @@ -763,16 +899,6 @@ pub struct ClientGameState { pub derived: DerivedViews, } -/// Compute all engine-authored projections over `state`. Runs in O(damage -/// entries) per call; the JIT short-circuit for non-Commander formats -/// (where `commander_damage_threshold` is `None`) keeps the cost at exactly -/// zero for the overwhelmingly common case. -/// -/// CR 903.10a: commander damage is public information tracked per commander -/// — no viewer-based redaction is applied here, and the grouping runs -/// unconditionally for every Commander-format game regardless of who is -/// viewing. Partner commanders under the same controller each get their -/// own `CommanderDamageView` entry, not a summed total. /// CR 118.3a + CR 601.2g: the cost still unpaid by `viewer`'s pinned pool units /// during their own manual mana payment for a spell. Reduces the locked spell /// cost against a pool containing ONLY the pinned units (so the residual is @@ -922,6 +1048,18 @@ fn temporary_cant_be_blocked_source( }) } +/// Compute all engine-authored projections over `state`. Runs in O(objects + `∞` +/// targets + damage entries) per call; the JIT short-circuit for non-Commander +/// formats (where `commander_damage_threshold` is `None`) still keeps the +/// commander-damage grouping at exactly zero cost. The per-object counter walk +/// (`counter_display_views`) allocates nothing for the dominant counterless +/// object. +/// +/// CR 903.10a: commander damage is public information tracked per commander +/// — no viewer-based redaction is applied here, and the grouping runs +/// unconditionally for every Commander-format game regardless of who is +/// viewing. Partner commanders under the same controller each get their +/// own `CommanderDamageView` entry, not a summed total. pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews { let mut views = DerivedViews { unique_authorized_submitter: unique_authorized_submitter(state), @@ -1207,8 +1345,12 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // i.e. it hid a badge beside a pool the player can visibly keep spending. // // NO SURFACE IS FILTERED BY THE SCHEDULE — that, and only that, is the invariant here. Which - // rows/groups/pills EXIST is decided by the `∞` stores and the LIVE battlefield alone; nothing - // below hides a surface because a collapse is scheduled. The schedule is read to ANNOTATE, not + // rows/groups/pills EXIST is decided by the `∞` stores, the object's own counters, and the + // LIVE battlefield alone; nothing below hides a surface because a collapse is scheduled. That + // now covers a COMPLETE projection, not just the `∞` subset: `counter_display` publishes every + // rendered counter row on every object, and the schedule decides the existence of none of + // them. The schedule may only ANNOTATE (`unbounded_families`), never admit or withhold a row. + // The schedule is read to ANNOTATE, not // to filter: the row loop accumulates a per-`(player, family)` `FamilyCollapseState` emitted as // a SEPARATE channel (`unbounded_families`), and NO row carries a flag. Still additive. // @@ -1227,6 +1369,17 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // 4. "…and the schedule rides on each row as a `scheduled` flag" — falsified when that flag // was deleted. A per-FAMILY badge cannot render a per-ROW flag honestly: two same-family // axes that disagree need a third answer, which is what `FamilyCollapseState::Mixed` is. + // 5. "…and `object_growth_backing` refuses for `Counter(..)`, so only the token axis can + // lose a row" — falsified when that arm stopped reading the controller-keyed store WHOLE + // and started deriving each registered pair's own axis, which is an axis-scoped + // authority and therefore may revoke a counter row too. + // 6. "…and the counter-pill loop projects only `∞`-marked pairs" — falsified when the + // channel widened to the complete per-object projection in `counter_display_views`. `∞` + // became an ANNOTATION on a row whose existence is decided by the object's own counters, + // and `Finite` row existence stopped being battlefield-gated at all. + // The gate itself reads the schedule for the first time, and it still does not FILTER by it: + // an accepted collapse can only ADD a row back that the backing check would have dropped + // (CR 732.2c — the shortcut is already taken), never remove one. // Naming WHO reads the schedule is a claim every future consumer can break; naming what the // schedule may not DO is not. The stores are not filtered either: // `unbounded_resources` keeps the mark until the boundary applies the growth. (`unbounded_loop_enablers` is held in @@ -1254,19 +1407,36 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // downstream consumer (engine or frontend) can answer this correctly; two controllers // draining one victim would collide. This reads the ENGINE'S DEFERRAL STASH, which no CR // licenses (see `FamilyCollapseState`) — it is not a projection of CR 732.2c. - let scheduled_axes = scheduled_display_axes(state, controller); + let accepted_axes = accepted_collapse_axes(state, controller); + let scheduled_axes = scheduled_display_axes(&accepted_axes); for &axis in axes { // CR 732.2a + CR 110.1: an object-growth ∞ whose ENTIRE registered display set // has left the battlefield has no live board backing left — drop the row rather // than render an ∞ beside an already-empty ∞ pile. `None` (never registered a // backing set, e.g. a mana engine) keeps the badge; see `object_growth_backing` // for why that asymmetry is typed rather than collapsed into a bool. - if object_growth_backing(state, controller, axis) == Some(false) { + // + // CR 732.2c binds the shortcut the instant the last player accepts, so an agreed + // collapse is NOT cancelled by its board backing dying — its row survives the + // departure and keeps announcing the growth that will still land. The FACT set is + // read here, deliberately not the display-filtered one: `object_growth_backing` + // returns `None` for `Mana(_)` today, so the two happen to agree — an accident + // between two functions, not an invariant either of them states. + if !accepted_axes.contains_key(&axis) + && object_growth_backing(state, controller, axis) == Some(false) + { continue; } let player = attribution_player(axis, controller); let state_for_axis = match scheduled_axes.get(&axis) { - Some(&certainty) => FamilyCollapseState::Scheduled(certainty), + Some(&certainty) => FamilyCollapseState::Scheduled { + certainty, + // The prompted seat is THIS loop's controller, captured here, in the only + // scope that still knows it: one line below, `attribution_player` may + // replace `player` with the victim, and from that point the controller is + // unrecoverable. + prompted: Some(controller), + }, None => FamilyCollapseState::Unscheduled, }; families @@ -1303,26 +1473,12 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews } } - // CR 732.2a / CR 701.34a: project the accepted counter-growth loop's per-object ∞ - // counter targets — the objects whose PRESERVED Generic counters (charge / burden) - // the certified-unbounded loop pumps each cycle — dropping any that have since left - // the battlefield (stale member). Display-only per-object channel mirroring - // `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 122.1 + CR 732.2a: the COMPLETE per-object counter-display projection. Emitted HERE, + // above the Commander short-circuit below, for the same reason the two loops above are: that + // `return` would drop this channel in every non-Commander format. // // Unconditional while a collapse is merely scheduled — see the CR 732 timing block above. - for targets in state.unbounded_counter_targets.values() { - for (id, ct) in targets { - if !state.battlefield.contains(id) { - continue; - } - views - .unbounded_counters - .entry(*id) - .or_default() - .push(ct.clone()); - } - } + views.counter_display = counter_display_views(state); if state.format_config.commander_damage_threshold.is_none() { return views; @@ -1347,6 +1503,109 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews views } +/// CR 306.5c: route one row to the loyalty TOTAL or to the pill strip. A `Loyalty` counter drives +/// the total only on an object that HAS a loyalty characteristic; on anything else CR 306.5c says +/// nothing, so hiding the marker would be an over-DROP and the row stays a pill. CR 606.4's +/// loyalty ABILITY COST is a different game fact and is never projected here. +fn push_counter_row(display: &mut ObjectCounterDisplay, has_loyalty: bool, row: CounterRowView) { + if has_loyalty && row.counter == CounterType::Loyalty { + display.loyalty = Some(row); + } else { + display.pills.push(row); + } +} + +/// CR 122.1 + CR 732.2a: build the COMPLETE per-object counter-display projection — every counter +/// row every display surface renders, for every object that has one, in any zone. +/// +/// TWO DISJOINT PASSES, so no `(ObjectId, CounterType)` key can be emitted twice and a duplicate +/// pill is structurally unrepresentable rather than removed by a step that could regress. CR 122.1 +/// — a counter is a marker ON an object, so the pair IS the row key. +/// +/// PASS 1, the `∞` ANNOTATION, driven from the registered target set: +/// +/// CR 122.1 + CR 110.1 + CR 122.2: a counter is a marker placed ON an object, a permanent is a +/// card or token ON THE BATTLEFIELD, and counters cease to exist when their bearer changes zones +/// — so an off-battlefield bearer carries no `∞` ANNOTATION. Only the annotation is gated this +/// way; a FINITE row for the same bearer may still survive the move (pass 2). +/// +/// CROSS-SEAT DEDUPE: the store is per-seat (`BTreeMap>`), so it dedupes +/// WITHIN a seat and never ACROSS seats. Two controllers whose accepted loops pump the same +/// (object, counter) pair each hold their own entry, and emitting both produced a duplicate row — +/// two identical pills sharing one React key, since every render site keys on the counter type +/// alone. The rows are byte-identical (`count` is keyed only by `(id, ct)`, with no seat input), +/// so collapsing at the source is a deduplication, not a choice of whose row wins. Flattening into +/// a `BTreeSet` keeps the wire order the single-seat case already had: sorted by +/// `(ObjectId, CounterType)`. +/// +/// The `unwrap_or(0)` is the PROJECTOR/PRODUCER convention, not a rule: it mirrors +/// `analysis::resource::grown_beneficial_counter_deltas`, so both sides share one definition of +/// "absent". WHY A `count: 0` ROW EXISTS: the pair is derived by diffing a SIMULATED one-period +/// frame against a clone of the LIVE state (`game::engine::drive_one_period_frames`), so a pair +/// growing `0 -> 1` across that period is registered while the live object carries none. Dropping +/// it would trade a display over-KEEP for an over-DROP, which this subsystem's stated polarity +/// forbids (see [`UnboundedFamilyView`]). +/// +/// PASS 2, the FINITE rows, driven from the objects themselves: +/// +/// Admission is `types::counter::positive_counter_entries` — CR 122.1, an internal map entry with +/// count zero is not a marker. There is NO zone gate: CR 122.2 already makes counters cease to +/// exist on a zone change and `zones::counters_persist_on_move` is the SINGLE authority for the +/// CR 113.6b carve-out that overrides it, so a gate here would be a second, weaker copy of that +/// rule — one that would silently delete a persisting bearer's graveyard pills and a suspended +/// card's time counters in exile (CR 702.62b). The projection defers to that authority instead of +/// re-deriving it. Collecting into a `BTreeMap` is what makes the intra-class order +/// `CounterType`'s declaration `Ord`, which is the order `counter_map_serde` already puts +/// `objects[*].counters` on the wire in. +fn counter_display_views(state: &GameState) -> HashMap { + let mut display: HashMap = HashMap::new(); + let mut annotated: HashMap> = HashMap::new(); + + let targets: BTreeSet<&(ObjectId, CounterType)> = + state.unbounded_counter_targets.values().flatten().collect(); + for (id, ct) in targets { + if !state.battlefield.contains(id) { + continue; + } + let object = state.objects.get(id); + let count = object + .and_then(|obj| obj.counters.get(ct).copied()) + .unwrap_or(0); + // A battlefield id with no `state.objects` entry cannot answer the CR 306.5c question, so + // the row goes to the pill strip — matching what this channel published before it widened. + push_counter_row( + display.entry(*id).or_default(), + object.is_some_and(|obj| obj.loyalty.is_some()), + CounterRowView { + counter: ct.clone(), + count, + magnitude: CounterMagnitude::Unbounded, + }, + ); + annotated.entry(*id).or_default().insert(ct); + } + + for (id, object) in &state.objects { + let annotated_here = annotated.get(id); + let finite: BTreeMap<&CounterType, u32> = positive_counter_entries(&object.counters) + .filter(|(counter, _)| !annotated_here.is_some_and(|set| set.contains(counter))) + .collect(); + for (counter, count) in finite { + push_counter_row( + display.entry(*id).or_default(), + object.loyalty.is_some(), + CounterRowView { + counter: counter.clone(), + count, + magnitude: CounterMagnitude::Finite, + }, + ); + } + } + + display +} + /// Derive a viewer-safe presentation from `filtered_state`, retaining only the /// decision-authority projection from the pre-filter rules state. This keeps /// rules state pure and makes repeated filtering idempotent. @@ -1481,22 +1740,58 @@ fn turn_order_views( (turn_order, viewer_turn_number) } -/// The axes `controller` has an accepted-but-unapplied collapse for, as the HUD should announce -/// them, each carrying how CERTAIN that collapse is. +/// CR 732.2c: THE FACT — the axes `controller` has an accepted, not-yet-applied collapse for. +/// +/// The KEYS are the fact (which axes an accepted collapse names). The VALUES are +/// `engine_resolution_choices::possible_hold`'s display encoding ([`CollapseCertainty`], typed as a +/// display promise by its own doc there), carried alongside because every consumer that needs the +/// fact also needs to know what may be promised about it. No display judgement is applied here — +/// that is [`scheduled_display_axes`]'s job, and the split is what keeps a RULES consumer (the row +/// loop's acceptance gate) from silently inheriting a DISPLAY exclusion. /// /// This reads the stash of growth in flight along CR 732.2c's advance to the proposal's ending /// point — a priority window per CR 732.2a, reached after the CR 500.5 boundary where the growth -/// lands. What it announces is therefore a real accepted result, not a parking spot; the reason it -/// announces CERTAINTY rather than a number is that the boundary re-checks whether the growth is +/// lands. What it reports is therefore a real accepted result, not a parking spot; the reason the +/// value is CERTAINTY rather than a number is that the boundary re-checks whether the growth is /// still observed and the controller names the count at the ending point (CR 732.2a). See -/// `FamilyCollapseState`, the `THE WINDOW'S TIMING IS CR 732.2c'S ADVANCE` block above, and +/// [`FamilyCollapseState`], the `THE WINDOW'S TIMING IS CR 732.2c'S ADVANCE` block above, and /// `types/game_state.rs`'s `scheduled_collapse_axes` doc for the reading. +fn accepted_collapse_axes( + state: &GameState, + controller: PlayerId, +) -> BTreeMap { + let mut axes: BTreeMap = BTreeMap::new(); + let Some(items) = state.pending_unbounded_materialization.get(&controller) else { + return axes; + }; + for item in items { + // Per ITEM, so each axis inherits the certainty of the kind that actually scheduled it; + // two items naming the same axis merge to the weaker answer. + let certainty = crate::game::engine_resolution_choices::materialization_certainty(item); + for axis in state.scheduled_collapse_axes(std::slice::from_ref(item)) { + axes.entry(axis) + .and_modify(|acc| *acc = acc.weaker(certainty)) + .or_insert(certainty); + } + } + axes +} + +/// THE ANNOUNCEMENT — the fact ([`accepted_collapse_axes`]) minus what the badge cannot honestly +/// promise, as the HUD should announce it, each axis carrying how CERTAIN that collapse is. +/// +/// Takes the fact BY REFERENCE rather than re-deriving it: the signature is the guarantee that no +/// caller can compute one without holding the other, so the two cannot drift into agreeing by +/// coincidence. The `Mana(_)` exclusion below is the ONLY display judgement in the pair, which is +/// what makes the FACT/ANNOUNCEMENT split meaningful at all. /// /// Named rather than inlined into its one caller because the SCOPE LIMIT below is a rule, not a /// line of the row loop, and it has already proved it drifts when written twice: an earlier cut of /// this change had a second consumer (a `scheduled_collapse` tag channel, since removed for having /// no reader) and the guard lived in that consumer alone, so mana rows shipped flagged while the -/// tag omitted them. Any future second consumer calls THIS, and inherits the limit. +/// tag omitted them. Any future second consumer calls THIS, and inherits the limit; a consumer that +/// needs the unfiltered rules answer calls [`accepted_collapse_axes`] instead, and the type it asks +/// for says which of the two it got. /// /// SCOPE LIMIT — `Mana(_)` is excluded. This is about what the badge would TELL the player, not /// about which code path ends the axis, and it is scoped to THE WINDOW THE BADGE RENDERS IN @@ -1537,26 +1832,10 @@ fn turn_order_views( /// seats, which that clear excludes.) The badge still must not promise a bound the player's /// spendable pool never had. fn scheduled_display_axes( - state: &GameState, - controller: PlayerId, + accepted: &BTreeMap, ) -> BTreeMap { - let mut axes: BTreeMap = BTreeMap::new(); - let Some(items) = state.pending_unbounded_materialization.get(&controller) else { - return axes; - }; - for item in items { - // Per ITEM, so each axis inherits the certainty of the kind that actually scheduled it; - // two items naming the same axis merge to the weaker answer. - let certainty = crate::game::engine_resolution_choices::materialization_certainty(item); - for axis in state.scheduled_collapse_axes(std::slice::from_ref(item)) { - if matches!(axis, ResourceAxis::Mana(_)) { - continue; - } - axes.entry(axis) - .and_modify(|acc| *acc = acc.weaker(certainty)) - .or_insert(certainty); - } - } + let mut axes = accepted.clone(); + axes.retain(|axis, _| !matches!(axis, ResourceAxis::Mana(_))); axes } @@ -1623,32 +1902,39 @@ fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { /// `state.battlefield.contains` test at MEMBER level; this is its SET-level closure, so all /// three read the same board in the same frame and none can be staler than another. /// -/// GRANULARITY — the rule that decides which axes may consult a backing store at all: +/// GRANULARITY — the rule that decides how an axis may consult a backing store: /// -/// > A CONTROLLER-keyed backing store can answer an AXIS-scoped question if and only if the -/// > axis is a UNIT variant. +/// > A CONTROLLER-keyed backing store can be READ AS an axis' backing if and only if the axis is +/// > a UNIT variant. A DATA variant must derive its axis from each stored ELEMENT. /// /// `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. +/// variant: `mark_unbounded_loop` unions arbitrarily many per controller (`entry.extend`), so +/// the controller-keyed store is strictly coarser than the axis and must NOT be read whole. +/// It is read per ELEMENT instead — each stored `(ObjectId, CounterType)` pair derives its own +/// axis through `types::game_state::collapsed_counter_axis`, and only the pairs matching THIS +/// axis answer for it. /// -/// 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 +/// An earlier revision of this function read `unbounded_counter_targets` WHOLE 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. +/// counter row, including the one whose backing it had never consulted. The per-element +/// derivation is what fixes that: a row is revoked only by the departure of pairs that derive +/// ITS axis, and an axis with no registered pair at all answers `None` (badge kept), never +/// `Some(false)`. Re-keying the store by `(controller, ResourceAxis)` would have asserted a +/// scope the derivation does not have; deriving per element asserts none. /// -/// 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. +/// CR 400.7 — the FAIL-OPEN direction, stated because it is a design choice and not an +/// accident: the pair is snapshotted at accept, but the axis it derives to is LIVE +/// (`collapsed_counter_axis` reads `state.objects` on every projection). A bearer that ceased to +/// exist derives `Counter(_, Other)`, which matches no registered pair for this axis, so nothing +/// answers, and the answer is `None` — the badge is KEPT. Every drift in this bridge therefore +/// leaves an `∞` standing one boundary too long; none can hide a real one. Witnessed by +/// `bridge_drift_on_cease_to_exist_fails_open`. /// /// 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 @@ -1669,16 +1955,30 @@ fn object_growth_backing( .unbounded_loop_pile .get(&controller) .map(|pile| pile.iter().any(|id| state.battlefield.contains(id))), + // CR 122.1: a counter is a marker ON AN OBJECT, so the counter axis' backing is the set + // of registered `(ObjectId, CounterType)` pairs that derive THIS axis — not the whole + // controller-keyed store (GRANULARITY, above). `?` on the lookup: a controller that + // never registered any target has no live authority to consult ⇒ `None` ⇒ badge kept. + ResourceAxis::Counter(..) => { + let targets = state.unbounded_counter_targets.get(&controller)?; + let mut any_for_axis = false; + let mut any_live = false; + for (id, ct) in targets { + if crate::types::game_state::collapsed_counter_axis(state, *id, ct) != axis { + continue; + } + any_for_axis = true; + any_live |= state.battlefield.contains(id); + } + // No pair derives this axis ⇒ nothing registered a backing FOR IT ⇒ `None`, not + // `Some(false)`. That is the CR 400.7 fail-open in one expression. + any_for_axis.then_some(any_live) + } // 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::Mana(_) | ResourceAxis::Life(_) | ResourceAxis::DamageDealt(_) | ResourceAxis::LibraryDelta(_) @@ -2246,27 +2546,38 @@ 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. + /// Counter-row revocation is AXIS-SCOPED: the departure of a registered pair may revoke the + /// axis THAT PAIR DERIVES, and no other. + /// + /// A controller-keyed backing store may be read WHOLE 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, so the store is strictly coarser than the axis + /// and is read PER ELEMENT instead — each registered `(ObjectId, CounterType)` derives its own + /// axis through `types::game_state::collapsed_counter_axis`. /// - /// 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. + /// Both wrong answers this fixture rules out are real revisions of this code. The + /// controller-keyed `Some(false)` originally shipped here 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 targets + /// are ever registered. Refusing entirely (`None` for every `Counter(..)`) is the opposite + /// error: the departed axis keeps a badge it has no backing for. /// - /// 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. + /// Two-sided on ONE assertion pair: reverting the arm to `None` reds "Generic gone"; dropping + /// the per-pair `collapsed_counter_axis` filter — i.e. exactly the original axis-blind code — + /// reds "Plus1Plus1 survives". 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. + /// + /// CONTRACT — NOT REACHABLE AT ACCEPT, which is a narrower claim than "production cannot build + /// it". At accept both writes happen in one `materialize_object_growth_shortcut` body, so a + /// registered backing with no stash is both-or-neither there. POST-boundary it IS producible: + /// `take_pending_materialization` drops the stash while `clear_collapsed_materializations` can + /// re-insert surviving targets. The rig is hand-built because the accept-time shape is the one + /// being excluded, not because the state is unreachable. #[test] - fn counter_rows_are_not_revoked_by_a_controller_keyed_backing_set() { + fn counter_row_revocation_is_axis_scoped() { use crate::analysis::resource::{CounterClass, ObjectClass, ResourceAxis}; use crate::game::zones::move_to_zone; use crate::types::counter::CounterType; @@ -2322,9 +2633,270 @@ mod tests { ); 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:?}" + !subject_rows.contains(&generic_axis), + "THE assertion (subject, half 1): the departed pair derives `generic_axis`, so THAT \ + row loses its live backing and is revoked, got {subject_rows:?}" + ); + assert!( + subject_rows.contains(&plus1_axis), + "THE assertion (subject, half 2): NO registered pair derives `plus1_axis`, so nothing \ + answers for it and the badge is kept — an axis-blind guard would drop it too, got \ + {subject_rows:?}" + ); + } + + /// CR 732.2c: once the last player accepts, the shortcut IS TAKEN — so an accepted TOKEN + /// collapse keeps its `∞` row even after its entire registered pile has left the battlefield. + /// The growth still lands at the boundary, and a row that vanished first would have the HUD + /// deny a result the table already agreed to. + /// + /// Three arms, and the third is what stops the second from being vacuous: + /// - REACH: the backing check really answers `Some(false)` on this rig, so the gate's second + /// conjunct is live and the row survival below is decided by the FIRST conjunct. + /// - SUBJECT: with a stash, the row is present. + /// - NON-VACUITY: same departure, NO stash ⇒ the row must DIE. Without it, "the row survived" + /// would also pass against a projection that never revokes anything. + /// + /// REVERT-PROBE: drop `!accepted_axes.contains_key(&axis)` from the gate ⇒ the SUBJECT arm + /// reds. (Dropping a negated restricting conjunct makes the `continue` fire MORE often, so + /// rows are dropped MORE and a PRESENCE assertion is what flips; the NON-VACUITY arm stays + /// green, since its `accepted_axes` is empty either way.) + #[test] + fn an_accepted_token_collapse_keeps_its_row_when_its_pile_dies() { + use crate::game::zones::move_to_zone; + use crate::types::events::GameEvent; + use crate::types::game_state::PersistentAxisMaterialization; + + let p0 = PlayerId(0); + let build = |accepted: bool| { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let token = create_object( + &mut state, + CardId(1), + p0, + "Saproling".to_string(), + Zone::Battlefield, + ); + state.mark_unbounded_loop(p0, &[ResourceAxis::TokensCreated]); + state.register_unbounded_loop_pile(p0, BTreeSet::from([token])); + if accepted { + state.register_pending_materialization( + p0, + PersistentAxisMaterialization::Tokens(family_test_token_profile()), + ); + } + let mut events: Vec = Vec::new(); + move_to_zone(&mut state, token, Zone::Graveyard, &mut events); + assert!( + !state.battlefield.contains(&token), + "precondition: the whole registered pile really left the battlefield" + ); + state + }; + + let has_token_row = |state: &GameState| -> bool { + derive_views(state, Some(p0)) + .unbounded_resources + .iter() + .any(|r| r.axis == ResourceAxis::TokensCreated) + }; + + // REACH: the backing authority answers `Some(false)` — the gate's second conjunct is TRUE + // on this rig, so nothing below is decided by an inert check. + let subject = build(true); + assert_eq!( + object_growth_backing(&subject, p0, ResourceAxis::TokensCreated), + Some(false), + "reach: the pile is registered and entirely gone, so the backing check says so" + ); + + // SUBJECT: an accepted collapse keeps the row anyway (CR 732.2c). + assert!( + has_token_row(&subject), + "an ACCEPTED token collapse keeps its ∞ row when its pile dies — the growth still \ + lands at the boundary" + ); + + // NON-VACUITY: the identical departure without a stash must drop the row. + assert!( + !has_token_row(&build(false)), + "with NO accepted collapse the same dead pile revokes the row — otherwise the arm \ + above measures a projection that never revokes anything" + ); + } + + /// The same CR 732.2c acceptance gate, through the OTHER `object_growth_backing` arm: an + /// accepted COUNTER collapse keeps its row when every registered target has left the + /// battlefield. + /// + /// Carries two extra pins the token twin does not need, because the counter arm derives its + /// backing per element rather than reading a store whole: + /// - the `collapsed_counter_axis` REACH pin, so every later assertion is provably about the + /// axis this rig actually registers a pair for; + /// - `object_growth_backing(..) == Some(true)` while the bearer is alive. That is the V4 + /// discriminator: an arm that was never implemented (or reverted to `None`) reds HERE, which + /// is what stops "the row survived" from being vacuous — a `None` arm keeps every row too. + #[test] + fn an_accepted_counter_collapse_keeps_its_row_when_its_targets_die() { + use crate::game::zones::move_to_zone; + use crate::types::counter::CounterType; + use crate::types::events::GameEvent; + use crate::types::game_state::{ + collapsed_counter_axis, CounterGrowth, PersistentAxisMaterialization, + }; + + let p0 = PlayerId(0); + let charge = CounterType::Generic("charge".to_string()); + let build = |accepted: bool| { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let prism = create_object( + &mut state, + CardId(1), + p0, + "Pentad Prism".to_string(), + Zone::Battlefield, + ); + let axis = collapsed_counter_axis(&state, prism, &charge); + state.mark_unbounded_loop(p0, &[axis]); + state.register_unbounded_counter_targets(p0, vec![(prism, charge.clone())]); + if accepted { + state.register_pending_materialization( + p0, + PersistentAxisMaterialization::Counters(vec![CounterGrowth { + object: prism, + counter: charge.clone(), + per_cycle_delta: 1, + }]), + ); + } + (state, prism, axis) + }; + + let has_axis_row = |state: &GameState, axis: ResourceAxis| -> bool { + derive_views(state, Some(p0)) + .unbounded_resources + .iter() + .any(|r| r.axis == axis) + }; + + // REACH (a): the registered pair really derives the marked axis. + let (mut subject, prism, axis) = build(true); + assert_eq!( + collapsed_counter_axis(&subject, prism, &charge), + axis, + "reach: the registered pair derives the axis this test is about" + ); + // REACH (b): the counter arm ANSWERS, and answers positively while the bearer is alive. + // A `None` (never-implemented) arm reds here. + assert_eq!( + object_growth_backing(&subject, p0, axis), + Some(true), + "reach: the Counter(..) arm consults the registered pairs and finds a live one" + ); + + let mut events: Vec = Vec::new(); + move_to_zone(&mut subject, prism, Zone::Graveyard, &mut events); + assert_eq!( + object_growth_backing(&subject, p0, axis), + Some(false), + "reach: with the only registered bearer gone the arm says the backing is dead — the \ + gate's second conjunct is live" + ); + + // SUBJECT: the accepted collapse keeps the row anyway. + assert!( + has_axis_row(&subject, axis), + "an ACCEPTED counter collapse keeps its ∞ row when its targets die" + ); + + // NON-VACUITY: identical departure, no stash ⇒ the row dies. + let (mut control, control_prism, control_axis) = build(false); + let mut control_events: Vec = Vec::new(); + move_to_zone( + &mut control, + control_prism, + Zone::Graveyard, + &mut control_events, + ); + assert!( + !has_axis_row(&control, control_axis), + "with NO accepted collapse the same dead target revokes the counter row" + ); + } + + /// CR 400.7: an object that ceases to exist has no characteristics to read, so + /// `collapsed_counter_axis` falls back to `ObjectClass::Other` and the registered pair stops + /// deriving the axis it was registered under. The arm must then answer `None` (badge KEPT), + /// never `Some(false)` (badge dropped) — every drift in this bridge fails OPEN. + /// + /// THE BEARER MUST BE A CREATURE, and that requirement is load-bearing rather than flavour: + /// `GameObject::new` sets `card_types: CardType::default()`, i.e. EMPTY `core_types`, which + /// already derives `ObjectClass::Other` while the object is alive. On such a bearer removing + /// the object changes nothing about the derived axis and the drift is invisible — the test + /// would pass vacuously. The `core_types` assignment below is what makes the pre-drift and + /// post-drift axes differ at all. + /// + /// BASELINE arm (the paired positive): before the drift the arm answers `Some(false)`, so the + /// revocation really was ON and the `None` below is a change, not a constant. + /// + /// REVERT-PROBE: replace `any_for_axis.then_some(any_live)` with `Some(any_live)` ⇒ the + /// post-drift assertion reds with `Some(false)`, while the BASELINE arm stays green. + #[test] + fn bridge_drift_on_cease_to_exist_fails_open() { + use crate::analysis::resource::{CounterClass, ObjectClass}; + use crate::game::game_object::GameObject; + use crate::game::zones::move_to_zone; + use crate::types::counter::CounterType; + use crate::types::events::GameEvent; + + let p0 = PlayerId(0); + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let bearer = ObjectId(10); + let mut creature = GameObject::new( + bearer, + CardId(10), + p0, + "Beast".to_string(), + Zone::Battlefield, + ); + // MANDATORY — see the doc above. Without this the bearer is already `Other` while alive. + creature.card_types.core_types = vec![CoreType::Creature]; + state.objects.insert(bearer, creature); + state.battlefield.push_back(bearer); + + let axis = ResourceAxis::Counter(CounterClass::Plus1Plus1, ObjectClass::Creature); + assert_eq!( + crate::types::game_state::collapsed_counter_axis( + &state, + bearer, + &CounterType::Plus1Plus1 + ), + axis, + "reach: a CREATURE bearer derives the Creature-classed axis — if this is `Other` the \ + rig lost its core_types and the drift below would be invisible" + ); + state.mark_unbounded_loop(p0, &[axis]); + state.register_unbounded_counter_targets(p0, vec![(bearer, CounterType::Plus1Plus1)]); + + // BASELINE: the bearer leaves the battlefield but still EXISTS, so it still derives the + // Creature-classed axis and the revocation is genuinely ON. + let mut events: Vec = Vec::new(); + move_to_zone(&mut state, bearer, Zone::Graveyard, &mut events); + assert_eq!( + object_growth_backing(&state, p0, axis), + Some(false), + "baseline: an existing-but-departed bearer still derives this axis, so the arm \ + revokes — the paired positive proving the `None` below is a CHANGE" + ); + + // DRIFT: CR 400.7 cease-to-exist. The object is gone from `state.objects` entirely. + state.objects.remove(&bearer); + assert_eq!( + object_growth_backing(&state, p0, axis), + None, + "CR 400.7 fail-open: a ceased-to-exist bearer derives Counter(_, Other), which matches \ + no registered pair for this axis, so NOTHING answers and the badge is kept. \ + `Some(false)` here would hide a real ∞" ); } @@ -4276,6 +4848,14 @@ mod tests { never consulting the stash would say Unscheduled; got {:?}", life_rows[0] ); + // ARM A's seat half: a `Mixed` family names NO seat, structurally. The seat lives inside + // `Scheduled`, so this is unrepresentable rather than merely absent — asserted anyway, + // because a future sibling field beside `state` would make it representable again. + assert!( + !matches!(life_rows[0].state, FamilyCollapseState::Scheduled { .. }), + "a Mixed family carries no prompted seat at all, got {:?}", + life_rows[0] + ); // CROSS-CHECK AGAINST THE CONTRACT'S OWN AUTHORITY, not against a second wire channel. // `pending_unbounded_materialization` is what the boundary reads to cash the collapse out, @@ -4293,6 +4873,86 @@ mod tests { vec![p1], "the accepted-collapse contract names exactly P1 for this axis, got {accepted:?}" ); + + // Arms B and C share a rig with arm A's: same three seats, same victim-attributed axis, + // and they differ only in WHO marked and WHO accepted. `life_family_state` reads the one + // badge the victim's life family produces. + let life_family_state = |state: &GameState| -> FamilyCollapseState { + let rows: Vec = derive_views(state, Some(victim)) + .unbounded_families + .into_iter() + .filter(|f| f.player == victim && f.family == UnboundedFamily::Life) + .collect(); + assert_eq!( + rows.len(), + 1, + "one badge per (seat, family) in every arm, got {rows:?}" + ); + rows[0].state + }; + + // ARM B — DISAGREEMENT. Both controllers mark AND both accept, so the victim's one life + // family is genuinely scheduled by TWO distinct seats. One glyph cannot address two + // players, so the seat meets to ⊥. `None` here means "two or more seats", never "nobody". + // MUTATION: make the seat meet last-wins (`prompted: q`) ⇒ this reds with `Some(p0)` or + // `Some(p1)` depending on iteration order — which is exactly the order-dependence the join + // laws exist to forbid. + let mut both = GameState::new(FormatConfig::commander(), 3, 42); + both.mark_unbounded_loop(p0, &[axis]); + both.mark_unbounded_loop(p1, &[axis]); + for controller in [p0, p1] { + both.register_pending_materialization( + controller, + PersistentAxisMaterialization::Life { + player: victim, + per_cycle_delta: 1, + }, + ); + } + assert_eq!( + life_family_state(&both), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: None, + }, + "two controllers with accepted collapses on one victim's life family name two seats, \ + so the badge falls back to the seat-neutral voice" + ); + + // ARM C — AGREEMENT, and the MATCHED POSITIVE for arm B. Without it an implementation that + // returned `prompted: None` unconditionally would pass B. This is also the ONE fixture + // shape where the prompted seat and the attributed seat are provably DIFFERENT players: + // the badge sits on the victim's HUD (CR 119.3 + CR 704.5a) while CR 732.2a asks the + // CONTROLLER for the count. + // MUTATION: emit `player` (the attribution seat) instead of `controller` ⇒ the `assert_ne!` + // below reds, because on this rig `player` IS the victim. + let mut agreed = GameState::new(FormatConfig::commander(), 3, 42); + agreed.mark_unbounded_loop(p1, &[axis]); + agreed.register_pending_materialization( + p1, + PersistentAxisMaterialization::Life { + player: victim, + per_cycle_delta: 1, + }, + ); + let agreed_state = life_family_state(&agreed); + assert_eq!( + agreed_state, + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(p1), + }, + "one controller, one accepted collapse ⇒ the badge names THAT controller" + ); + let FamilyCollapseState::Scheduled { prompted, .. } = agreed_state else { + panic!("arm C just asserted this is Scheduled"); + }; + assert_ne!( + prompted, + Some(victim), + "the prompted seat is the CONTROLLER, never the attributed victim — the victim is \ + never asked to name the count" + ); } /// PR-6 test 1: a REAL opponent-burn certificate's axes project into victim-HUD @@ -4467,7 +5127,13 @@ mod tests { .contains(&UnboundedFamilyView { player: PlayerId(0), family: UnboundedFamily::Tokens, - state: FamilyCollapseState::Scheduled(CollapseCertainty::Conditional), + state: FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + // The controller IS the attributed seat for an aggregate axis, so this + // fixture cannot tell the two apart — the divergent case is + // `two_controllers_draining_one_victim_do_not_cross_schedule`. + prompted: Some(PlayerId(0)), + }, }), "an accepted Tokens collapse is Scheduled(Conditional) — never Committed; got {:?}", scheduled_views.unbounded_families @@ -4582,7 +5248,10 @@ mod tests { ); assert_eq!( scheduled_rows[0].state, - FamilyCollapseState::Scheduled(CollapseCertainty::Conditional), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(p0), + }, "matched positive: with no unscheduled sibling the counters family IS scheduled, and \ a batched Counters collapse is Conditional; got {:?}", scheduled_rows[0] @@ -4740,13 +5409,35 @@ mod tests { /// /// MUTATION: make `merge` last-wins (`|_, other| other`) ⇒ commutativity reds in exactly one /// order, e.g. `Unscheduled ⊔ Mixed` vs `Mixed ⊔ Unscheduled`. + /// + /// The value set includes two `Scheduled` values differing ONLY in `prompted`, so the three + /// laws are checked over the ENLARGED set the seat meet created rather than over the pre-seat + /// one. MUTATION: drop seat idempotence (always emit `prompted: None`) ⇒ `merge(x, x) != x` + /// reds for the two seat-carrying values. MUTATION: make the seat meet last-wins + /// (`prompted: q`) ⇒ commutativity reds on the `Some(0)` × `Some(1)` pair. #[test] fn family_collapse_state_merge_is_a_join() { let all = [ FamilyCollapseState::Unscheduled, FamilyCollapseState::Mixed, - FamilyCollapseState::Scheduled(CollapseCertainty::Committed), - FamilyCollapseState::Scheduled(CollapseCertainty::Conditional), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(PlayerId(0)), + }, + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(PlayerId(0)), + }, + // Same certainty as the row above, DIFFERENT seat — the axis the seat meet added. + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(PlayerId(1)), + }, + // ⊥ of the seat lattice, reachable only as a meet result. + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: None, + }, ]; for x in all { assert_eq!(x.merge(x), x, "idempotent: {x:?}"); @@ -4767,17 +5458,45 @@ mod tests { } // The lattice's load-bearing shape, stated so a reader need not re-derive it. assert_eq!( - FamilyCollapseState::Scheduled(CollapseCertainty::Committed).merge( - FamilyCollapseState::Scheduled(CollapseCertainty::Conditional) - ), - FamilyCollapseState::Scheduled(CollapseCertainty::Conditional), - "two schedules keep the WEAKER certainty" + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(PlayerId(0)), + } + .merge(FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(PlayerId(0)), + }), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(PlayerId(0)), + }, + "two schedules keep the WEAKER certainty, and an AGREED seat survives the meet" + ); + assert_eq!( + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(PlayerId(0)), + } + .merge(FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(PlayerId(1)), + }), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: None, + }, + "two DISTINCT seats meet to ⊥ (`None` = 'two or more seats', never 'nobody') while the \ + certainty is untouched — the seat axis and the certainty axis meet independently" ); assert_eq!( - FamilyCollapseState::Scheduled(CollapseCertainty::Committed) - .merge(FamilyCollapseState::Unscheduled), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(PlayerId(0)), + } + .merge(FamilyCollapseState::Unscheduled), FamilyCollapseState::Mixed, - "a schedule beside an unscheduled sibling is Mixed, never Scheduled" + "a schedule beside an unscheduled sibling is Mixed, never Scheduled — and `Mixed` \ + names no seat, structurally" ); } @@ -5050,4 +5769,386 @@ mod tests { assert_eq!(wire["derived"]["unique_authorized_submitter"], 1); } } + + // ---- `counter_display_views`: the COMPLETE per-object counter projection ---- + + fn make_counter_bearer( + state: &mut GameState, + card: u64, + zone: Zone, + counters: &[(CounterType, u32)], + ) -> ObjectId { + let id = create_object( + state, + CardId(card), + PlayerId(0), + format!("Bearer {card}"), + zone, + ); + let obj = state + .objects + .get_mut(&id) + .expect("the bearer was just created"); + for (counter, count) in counters { + obj.counters.insert(counter.clone(), *count); + } + id + } + + /// "Counters remain on this permanent as it moves to any zone other than a player's hand or + /// library" (Skullbriar / Me, the Immortal). Rig mirrored from `zones`' own + /// `CountersPersistAcrossZones` building-block tests, so this exercises the shipping shape. + fn grant_counter_persistence(state: &mut GameState, id: ObjectId) { + state + .objects + .get_mut(&id) + .expect("the bearer exists") + .static_definitions + .push( + crate::types::ability::StaticDefinition::new( + StaticMode::CountersPersistAcrossZones { + excluded_zones: vec![Zone::Hand, Zone::Library], + }, + ) + .affected(TargetFilter::SelfRef) + .active_zones(vec![ + Zone::Battlefield, + Zone::Graveyard, + Zone::Exile, + Zone::Command, + Zone::Stack, + ]), + ); + } + + /// CR 113.6b + CR 122.2: a FINITE row is NOT battlefield-gated. `zones::counters_persist_on_move` + /// is the single authority for which counters survive a zone change; `counter_display_views` + /// defers to it and never re-derives a zone rule of its own. Arm C is what proves the survival + /// is that authority and not a zone-blind projection — without it, a projection that never + /// cleared anything would pass arm B. + #[test] + fn counter_rows_survive_a_bearer_that_keeps_its_counters_off_the_battlefield() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let keeper = make_counter_bearer( + &mut state, + 1, + Zone::Battlefield, + &[(CounterType::Plus1Plus1, 3)], + ); + grant_counter_persistence(&mut state, keeper); + let plain = make_counter_bearer( + &mut state, + 2, + Zone::Battlefield, + &[(CounterType::Plus1Plus1, 3)], + ); + + let expected = ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: CounterType::Plus1Plus1, + count: 3, + magnitude: CounterMagnitude::Finite, + }], + loyalty: None, + }; + + // ARM A — MATCHED POSITIVE: on the battlefield both bearers render the same finite pill, + // so arm C's later absence is a measured transition rather than a fixture that never had + // a row. + let before = derive_views(&state, None); + assert_eq!( + before.counter_display.get(&keeper), + Some(&expected), + "arm A: a battlefield bearer's own positive counters are finite pills" + ); + assert_eq!( + before.counter_display.get(&plain), + Some(&expected), + "arm A: the paired negative's bearer starts with the identical row" + ); + + let mut events = Vec::new(); + crate::game::zones::move_to_zone(&mut state, keeper, Zone::Graveyard, &mut events); + crate::game::zones::move_to_zone(&mut state, plain, Zone::Graveyard, &mut events); + let after = derive_views(&state, None); + + // ARM B — THE ANSWER: CR 113.6b kept the counters, so the row must keep rendering. + assert_eq!( + after.counter_display.get(&keeper), + Some(&expected), + "arm B: a bearer whose counters persist off the battlefield keeps its FINITE row — \ + gating the finite pass on battlefield membership reds exactly here" + ); + // ARM C — PAIRED NEGATIVE: the plain bearer's counters ceased to exist (CR 122.2), so it + // has no row at all. + assert!( + !after.counter_display.contains_key(&plain), + "arm C: an ordinary bearer's counters ceased to exist on the move, so the projection \ + must invent no row; got {:?}", + after.counter_display.get(&plain) + ); + } + + /// The `∞` ANNOTATION leads its object's rows and SHADOWS the finite row for the same pair — + /// exactly two rows, never three. CR 122.1: the `(object, counter)` pair is the row key, so a + /// duplicate pill is unrepresentable rather than deduplicated. + #[test] + fn unbounded_row_leads_and_shadows_the_same_pair() { + let charge = CounterType::Generic("charge".to_string()); + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let bearer = make_counter_bearer( + &mut state, + 1, + Zone::Battlefield, + &[(charge.clone(), 4), (CounterType::Plus1Plus1, 2)], + ); + state.register_unbounded_counter_targets(PlayerId(0), vec![(bearer, charge.clone())]); + + assert_eq!( + derive_views(&state, None).counter_display.get(&bearer), + Some(&ObjectCounterDisplay { + pills: vec![ + CounterRowView { + counter: charge, + count: 4, + magnitude: CounterMagnitude::Unbounded, + }, + CounterRowView { + counter: CounterType::Plus1Plus1, + count: 2, + magnitude: CounterMagnitude::Finite, + }, + ], + loyalty: None, + }), + "the registered pair renders ONCE, annotated and leading; the unregistered counter \ + follows as a finite row. A third row is a shadow regression; a flipped order is a \ + salience regression" + ); + } + + /// CR 122.1: an internal map entry with count zero is not a marker, so it is not a finite row. + /// An `Unbounded` row's existence comes from the `∞` store instead, so a zero-count one is + /// real. The two arms break under OPPOSITE mutations, so neither is satisfiable by weakening + /// the other. + #[test] + fn a_zero_count_entry_is_not_a_pill_but_a_zero_count_unbounded_pair_is() { + let charge = CounterType::Generic("charge".to_string()); + let finite_row = CounterRowView { + counter: CounterType::Plus1Plus1, + count: 1, + magnitude: CounterMagnitude::Finite, + }; + + let mut unmarked = GameState::new(FormatConfig::standard(), 2, 42); + let bearer = make_counter_bearer( + &mut unmarked, + 1, + Zone::Battlefield, + &[(charge.clone(), 0), (CounterType::Plus1Plus1, 1)], + ); + + // ARM 1 — dropping `positive_counter_entries` grows a row here. + assert_eq!( + derive_views(&unmarked, None).counter_display.get(&bearer), + Some(&ObjectCounterDisplay { + pills: vec![finite_row.clone()], + loyalty: None, + }), + "a zero-count map entry is not a marker, so it publishes no finite row" + ); + + // ARM 2 — the SAME map, the pair now registered: applying `positive_counter_entries` to + // the `∞` pass too would lose this row. + let mut marked = unmarked.clone(); + marked.register_unbounded_counter_targets(PlayerId(0), vec![(bearer, charge.clone())]); + assert_eq!( + derive_views(&marked, None).counter_display.get(&bearer), + Some(&ObjectCounterDisplay { + pills: vec![ + CounterRowView { + counter: charge, + count: 0, + magnitude: CounterMagnitude::Unbounded, + }, + finite_row, + ], + loyalty: None, + }), + "a registered pair the bearer carries none of is still a real row" + ); + } + + /// CR 306.5c speaks only of planeswalkers, so a `Loyalty` counter drives the TOTAL only on an + /// object that has a loyalty characteristic. On anything else, hiding the marker would be an + /// over-DROP — arm 2 is that hostile fixture, and it is a disclosed behavior change. + #[test] + fn loyalty_routes_to_the_total_only_when_the_object_has_one() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let walker = make_counter_bearer( + &mut state, + 1, + Zone::Battlefield, + &[(CounterType::Loyalty, 4)], + ); + state + .objects + .get_mut(&walker) + .expect("the walker exists") + .loyalty = Some(4); + let creature = make_counter_bearer( + &mut state, + 2, + Zone::Battlefield, + &[(CounterType::Loyalty, 1)], + ); + assert!( + state.objects[&creature].loyalty.is_none(), + "reach-guard: arm 2's object must have NO loyalty characteristic, or it is arm 1 again" + ); + + let views = derive_views(&state, None); + assert_eq!( + views.counter_display.get(&walker), + Some(&ObjectCounterDisplay { + pills: vec![], + loyalty: Some(CounterRowView { + counter: CounterType::Loyalty, + count: 4, + magnitude: CounterMagnitude::Finite, + }), + }), + "arm 1: a planeswalker's loyalty counters drive the TOTAL, never a stray pill" + ); + assert_eq!( + views.counter_display.get(&creature), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: CounterType::Loyalty, + count: 1, + magnitude: CounterMagnitude::Finite, + }], + loyalty: None, + }), + "arm 2: partitioning on the counter TYPE alone would hide this marker entirely" + ); + } + + /// The cross-language discriminator `tsc` cannot see: the TS mirror types `magnitude` as + /// optional, so an inverted `skip_serializing_if` would make every client row read `Finite` + /// with no type error anywhere. + #[test] + fn finite_magnitude_is_omitted_on_the_wire_and_unbounded_is_not() { + let finite = CounterRowView { + counter: CounterType::Plus1Plus1, + count: 2, + magnitude: CounterMagnitude::Finite, + }; + let unbounded = CounterRowView { + counter: CounterType::Plus1Plus1, + count: 2, + magnitude: CounterMagnitude::Unbounded, + }; + + let finite_wire = serde_json::to_value(&finite).expect("the finite row serializes"); + assert!( + finite_wire.get("magnitude").is_none(), + "the dominant case stays off the wire, got {finite_wire}" + ); + let unbounded_wire = serde_json::to_value(&unbounded).expect("the ∞ row serializes"); + assert_eq!( + unbounded_wire.get("magnitude"), + Some(&serde_json::json!("Unbounded")), + "the exceptional case is always written, got {unbounded_wire}" + ); + + assert_eq!( + serde_json::from_value::(finite_wire).expect("finite round-trip"), + finite, + "an absent `magnitude` deserializes back to the serde default" + ); + assert_eq!( + serde_json::from_value::(unbounded_wire).expect("∞ round-trip"), + unbounded, + "the ∞ annotation survives a round-trip" + ); + } + + /// WHY THIS TEST EXISTS. `counter_display` projects EVERY object, including objects `hide_card` + /// redacts — six call sites in `filter_state_for_viewer`, of which face-down exile (CR 406.3) + /// can hold a counter-bearing object per `zones::counters_persist_on_move`. That widening + /// publishes nothing new ONLY because `GameObject`'s `counters` is serialized unconditionally + /// and `hide_card` does not clear it, so the projection is a pure function of data already on + /// the same wire. Nothing in either function states that dependency. Clear `counters` in + /// `hide_card`, or gate its serialization on anything that can omit a POPULATED map, and the + /// projection silently becomes a real information leak — this test is what turns that into a + /// red build. + /// + /// A fidelity note, not a leak: `hide_card` DOES clear `loyalty`, so on a redacted object a + /// `Loyalty` counter routes to `pills` rather than to the total. That is still zero new + /// information — arm 4 is exactly the assertion that a client reading the same filtered + /// envelope computes the identical partition. + #[test] + fn counter_display_publishes_nothing_a_viewer_cannot_already_read() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let hidden = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Exiled Bearer".to_string(), + Zone::Exile, + ); + { + let obj = state.objects.get_mut(&hidden).expect("the bearer exists"); + obj.face_down = true; + obj.counters.insert(CounterType::Plus1Plus1, 2); + } + let viewer = PlayerId(1); + let filtered = crate::game::visibility::filter_state_for_viewer(&state, viewer); + + // ARM 1 — REACH-GUARD (matched positive). Without it, arms 2-4 could pass on an + // unredacted object and prove nothing. Compared against the original name rather than the + // private redaction constant. + assert_ne!( + filtered.objects[&hidden].name, state.objects[&hidden].name, + "reach-guard: `hide_card` must really have redacted this face-down exiled card in \ + this frame, or this test is measuring an unredacted object" + ); + + // ARM 2 — THE PIN: `hide_card` does not clear counters. + assert_eq!( + filtered.objects[&hidden].counters, state.objects[&hidden].counters, + "`hide_card` must leave the counter map alone; clearing it there turns the widened \ + projection into a leak" + ); + + // ARM 3 — THE PIN: the counter map really reaches the wire beside the projection. Any + // `skip_serializing_if` predicate that can omit a POPULATED map reds here. + let wire = serde_json::to_value(&filtered.objects[&hidden]) + .expect("the filtered object serializes"); + assert_eq!( + wire["counters"]["P1P1"], + serde_json::json!(2), + "the redacted object still carries its counters on the same wire the projection \ + rides, got {wire}" + ); + + // ARM 4 — THE CLAIM: the projection is recomputable from that same envelope, so it + // publishes nothing new. + assert_eq!( + derive_filtered_views(&state, &filtered, Some(viewer)) + .counter_display + .get(&hidden), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: CounterType::Plus1Plus1, + count: 2, + magnitude: CounterMagnitude::Finite, + }], + loyalty: None, + }), + "the rows are exactly what a client rebuilds from the filtered object's counters, its \ + loyalty, the battlefield set and the `∞` store — zero new information" + ); + } } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 1de00a75b5..cc35351896 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4541,8 +4541,9 @@ struct PeriodFodder { /// CR 732.2a / CR 111.1: seed a `Priority{controller}` window and drive ONE iteration of /// `last_loop_action_sequence` on THROWAWAY clones, returning the `(before, after)` frames. /// The shared seed+drive kernel of the accept-time re-derivations — `current_period_fodder` -/// (object-growth ∞ pile) and `current_period_counter_targets` (counter-growth ∞ targets) -/// both diff these two frames. `None` when the sequence is empty. Mirrors the detection +/// (object-growth ∞ pile), `current_period_counter_growth` (beneficial counter δ, feeding both +/// the batched stash and the ∞ counter pills) and `current_period_life_growth` (life δ) all diff +/// these two frames. `None` when the sequence is empty. Mirrors the detection /// drive exactly: same `SimulationProbeGuard` re-entrancy guard (HELD across the drive so /// the injector's internal `apply_action` never recurses into the shortcut hooks), same /// `drive_loop_sequence_iteration`. @@ -4602,33 +4603,19 @@ fn current_period_fodder(state: &GameState) -> Option { Some(PeriodFodder { class, taps_fodder }) } -/// CR 732.2a / CR 701.34a (proliferate): re-derive the per-object `(ObjectId, CounterType)` -/// targets whose PRESERVED `Generic` counters strictly grew across one accepted -/// counter-growth period — the DISPLAY-only `∞` counter channel. The offer certificate's -/// unbounded axis is object-AGNOSTIC (`Counter(Other, Other)`), so the concrete object id / -/// counter type is NOT recoverable from the axis; re-derive it the same way -/// `current_period_fodder` derives the fodder class — drive ONE period on a clone (shared -/// `drive_one_period_frames`) and diff `Generic` counters (`grown_generic_counter_targets`). -/// Empty when the sequence is empty or the period grows no `Generic` counter (a mana / token -/// / object-growth loop). General over the class (proliferate charge / One-Ring burden), -/// never one card. DISPLAY-ONLY: the caller marks the pill to render `∞` without mutating the -/// real counter count. -fn current_period_counter_targets( - state: &GameState, -) -> Vec<(ObjectId, crate::types::counter::CounterType)> { - let Some((before, after)) = drive_one_period_frames(state) else { - return Vec::new(); - }; - crate::analysis::resource::grown_generic_counter_targets(&before, &after) -} - -/// CR 122.1 + CR 732.2a: re-derive the per-object BENEFICIAL counter growth (with per-cycle -/// δ) of the accepted period by driving ONE iteration on a clone (`drive_one_period_frames`) -/// and diffing beneficial-materializable counters (`grown_beneficial_counter_deltas`). The -/// batched-collapse δ source for the whole beneficial class (+1/+1 / loyalty / defense / -/// charge) — the widened analog of `current_period_counter_targets` (DISPLAY, Generic-only). -/// Empty when the sequence is empty or the period grows no beneficial counter (a mana / token -/// / life loop). Only reached in the UNOBSERVED batched route (the firewall gates it). +/// CR 122.1 + CR 732.2a: THE SINGLE per-object counter derivation of an accepted period — drive +/// ONE iteration on a clone (`drive_one_period_frames`) and diff beneficial-materializable +/// counters (`grown_beneficial_counter_deltas`), yielding per-cycle δ for the whole beneficial +/// class (+1/+1 / loyalty / defense / charge). Feeds BOTH consumers: the batched-collapse δ stash, +/// and (projected to `(object, counter)`) the `∞` DISPLAY counter channel. The display half used +/// to run a SECOND, `Generic`-only diff of its own, so a +1/+1 or loyalty loop collapsed correctly +/// and never rendered an `∞` pill; one derivation is what makes that class of disagreement +/// unrepresentable. The offer certificate's unbounded axis is object-AGNOSTIC, so the concrete +/// object id / counter type is NOT recoverable from the axis and must be re-derived here, the same +/// way `current_period_fodder` re-derives the fodder class. Empty when the sequence is empty or +/// the period grows no beneficial counter (a mana / token / life loop). The batched-collapse +/// consumer is only reached in the UNOBSERVED route (the firewall gates it); the display +/// registration is unconditional on both routes. fn current_period_counter_growth( state: &GameState, ) -> Vec { @@ -4917,11 +4904,34 @@ fn try_offer_object_growth_shortcut( // CR 732.2a: an UNBOUNDED object-growth offer is not repeated a CR 704-limited number of // times — it is materialized once as an unbounded axis — so it states no narrowed count // bound and keeps the global safety limit. + // + // CR 732.2a + CR 732.2c: the count this offer STATES is the count the table binds. There is + // no declare-time picker (the frontend echoes `iteration_count` verbatim), and once the last + // player accepts, "the shortcut is taken" at that count, which then caps the CR 500.5 + // collapse prompt. An offer that narrows no bound must therefore STATE the global limit it + // publishes as its ceiling; stating less silently caps the controller's collapse choice. + // This mirrors `certified_bounded_cycle_offer`, which already states `Fixed(max_iterations)`. + // + // CR 704.5a / CR 704.5c: the `UntilLethal` arm is UNREACHABLE FROM THIS PRODUCER — `delta` is + // a two-`snapshot` diff, and `ResourceVector::snapshot` writes neither `damage_dealt` nor + // `extra_turns` and never keys a poison `counters` entry by `ObjectClass::Player`, while + // `has_no_loss_axis` just above forces `life >= 0`, `library_delta >= 0`, `poison <= 0` on + // every seat; together those negate every non-`Advantage` branch of `classify_win_kind`. The + // arm is kept anyway so `shortcut_iteration_count` stays the SINGLE authority for that + // classification, and so this wildcard-free match build-breaks on a future third + // `IterationCount` variant — the guard `handle_declare_shortcut` states for its own cap. The + // unreachability is editorial, not structural: this function already event-feeds + // `tokens_created` into the same delta, so feeding `damage_dealt` would make the arm live. + use crate::analysis::decision_template::IterationCount; + let iteration_count = match shortcut_iteration_count(certificate.win_kind) { + IterationCount::UntilLethal => IterationCount::UntilLethal, + IterationCount::Fixed(_) => IterationCount::Fixed(MAX_SHORTCUT_CYCLES), + }; let schema = build_shortcut_schema( // CR 732.2a: an unresolvable pin WITHDRAWS the offer rather than publishing an // undeclarable point — see `pinned_decisions_to_points`. pinned_decisions_to_points(&schema_template.decisions, state, caster)?, - shortcut_iteration_count(certificate.win_kind), + iteration_count, MAX_SHORTCUT_CYCLES, ); Some((certificate, schema)) @@ -5010,18 +5020,7 @@ fn materialize_object_growth_shortcut( } else { None }; - // CR 732.2a / CR 701.34a: snapshot the per-object ∞ COUNTER targets for DISPLAY - // (DerivedViews::unbounded_counters). Distinct from the object-growth ∞ pile above: a - // counter-growth loop's certified unbounded axis is object-agnostic (Counter(Other, - // Other)), so re-derive the concrete (object, counter) pairs by driving one period on a - // clone and diffing Generic counters — WHILE the recast sequence is still intact (the - // `.clear()` below wipes it). DISPLAY-ONLY: the object's real counter count is NOT mutated - // (CR 701.34a already added the real counter on each live cycle; this only marks the pill - // to render ∞). A mana / token / object-growth loop grows no Generic counter ⇒ empty ⇒ - // no-op writer. Runs in BOTH routes (display is unconditional). - let counter_targets = current_period_counter_targets(state); - state.register_unbounded_counter_targets(proposal.proposer, counter_targets); - // ROUTE the STASH element only (the DISPLAY above is unconditional). `proposal.unbounded` IS + // ROUTE the STASH element only (the DISPLAY below is unconditional). `proposal.unbounded` IS // the ∞-mark set `mark_unbounded_loop` wrote. Capture-before-clear: `last_loop_action_sequence` // and the δ derivations all read BEFORE the `.clear()` tail below. // @@ -5034,6 +5033,23 @@ fn materialize_object_growth_shortcut( // carries an unrelated life/counter observer (plan §5 Note; the observedness firewall is // AXIS-SPECIFIC so an incidental board observer never mis-routes a disjoint-axis loop). let growths = current_period_counter_growth(state); + // CR 732.2a / CR 122.1: the ∞ counter DISPLAY targets are the SAME per-object growth the + // batched stash carries — ONE derivation, projected. Registering from `growths` (rather than a + // second, `Generic`-only diff) is what makes `clear_collapsed_materializations`' + // `collapsed_pairs` a superset of the registered set on the batched route, so the boundary + // clear is unchanged; on the `DriveSequence` route a pair whose derived axis was not collapsed + // survives, which is the disclosed display over-keep on `UnboundedFamilyView`. DISPLAY-ONLY: + // the object's real counter count is NOT mutated (CR 701.34a already added the real counter on + // each live cycle; this only marks the pill to render ∞). Derived WHILE the recast sequence is + // still intact (the `.clear()` tail below wipes it). Unconditional on both routes; a mana / + // token / object-growth loop grows no beneficial counter ⇒ empty ⇒ no-op writer. + state.register_unbounded_counter_targets( + proposal.proposer, + growths + .iter() + .map(|g| (g.object, g.counter.clone())) + .collect(), + ); let life = current_period_life_growth(state); let counter_observed = !growths.is_empty() && crate::analysis::resource::counter_growth_is_observed(state); @@ -16160,10 +16176,104 @@ mod stage2_injector_tests { // Search-observer dispatch: `:11828 ⇒ :11821`, −7. Removing the retired // `WaitingForWithParkedObservers` match arm is the only hunk above this // producer; it changes trigger-drain timing but does not add a prompt. + // + // ∞ AXIS-SCOPED REVOCATION ROUND (re-application of the ∞ badge/axis change onto + // `b5b8f4ecf`): `:11821 ⇒ :11814`, `-7`. This entry is written the way the + // doctrine at the head of this log demands and the way the two rounds above did + // NOT get for free: the coordinate was LOCATED BY CONTENT FIRST and the arithmetic + // was computed afterwards as a CHECK. Two incoming numbers were available and both + // were stale — this file's own `:11828` (pre-edit) and the original branch's + // `:11700` (pre-rebase) — which is exactly the situation in which inheriting a + // number is wrong. That judgement was vindicated a second time on the rebase onto + // `b5b8f4ecf`: the search-observer entry directly above ALSO landed its producer + // on `:11821`, from an unrelated hunk, so the 3-way merge saw both sides write the + // same coordinate and silently accepted it as AGREEMENT — when in fact the two + // shifts are independent and must COMPOSE: `11828 -7 (search-observer) -7 (here)` + // = `11814`. A conflict-free auto-merge of this pin would have been wrong by 7. + // Four hunks in this file, ALL above the producer, sum to this entry's own `-7`: + // the `drive_one_period_frames` caller-list doc going from two lines to three + // (`+1`), the deletion of `current_period_counter_targets` plus the rewrite of + // `current_period_counter_growth`'s doc into the single-derivation statement + // (27 lines to 13, `-14`), the deletion of the second display-registration block + // in `materialize_object_growth_shortcut` (12 lines to 1, `-11`), and its + // re-insertion below `let growths = …` as one derivation projected to two + // consumers (`+17`). Predicted `11821-7` equals the observed coordinate exactly. + // + // Identity re-established on three axes rather than assumed: the line at `:11814` + // is sha256-identical (WITH its trailing newline) to every earlier coordinate this + // row has carried — + // `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63`, the prefix + // carried since `a6d1a0e62`; that hash is UNIQUE in the file under a whole-file + // scan, so the coordinate is unambiguous; and it is still inside + // `begin_pending_trigger_target_selection`, which itself moved by the same `-7` + // and therefore did not change functions. + // + // HASHING CONVENTION, recorded because this branch produced the near-miss once: + // the line is hashed WITH its trailing newline. Piping through `tr -d '\n'` first + // yields `a6d7f2f9d1e15de538f5c2c5803f28e76e86ccd60898c7602a089345a25cb032` — a + // DIFFERENT digest for the SAME line. Both are written out in full here so a + // future reader who reproduces the wrong one identifies the convention instead of + // re-litigating the coordinate. + // + // SET PRESERVATION: unchanged. The other four entries live in `game/effects/` and + // `scoped_library_search.rs`; this change touches neither, and its own new tests + // live in `types/game_state.rs`, `derived_views.rs` and `tests/integration/`, so + // no line matching the needle is added to this file at all — total still 37, + // partition still 5/7/25. + // + // COLLISION NOTE: a separate in-flight CR 500.5 `max` bugfix also edits this file + // above this producer. Whichever lands second MUST re-derive by content; it cannot + // reuse this number, and neither entry's arithmetic is authority for the other's. + // + // CR 500.5 `max` BUGFIX (WB-7048): `:11814 ⇒ :11837`, `+23`. This IS the in-flight + // bugfix the COLLISION NOTE directly above anticipated, and it is the one landing + // SECOND — so the coordinate was re-derived BY CONTENT exactly as that note + // requires, and the arithmetic was computed afterwards as a CHECK, never as the + // source. The number above was NOT reused. The insertion is a single expression in + // `try_offer_object_growth_shortcut` — the unbounded object-growth producer now + // STATES the ceiling it publishes (`Fixed(MAX_SHORTCUT_CYCLES)`) instead of seeding + // `Fixed(1)`, since CR 732.2c makes the accepted count binding and that count caps + // the CR 500.5 collapse prompt. Its 33 lines replace 10 (23 of the 33 are comment), + // netting `+23`; the shift is LOCAL, originating in this diff, not rebase-induced. + // That insertion sits ABOVE this producer and BELOW nothing else pinned by this + // row. Predicted `11814+23` equals the observed coordinate exactly. + // + // Identity re-established, hashing convention per the entry above (hashed WITH the + // trailing newline — not restated here): the line at `:11837` is sha256-identical + // to every earlier coordinate this row has carried, that digest is still UNIQUE + // under a whole-file scan, and it is still inside + // `begin_pending_trigger_target_selection`. + // + // SET PRESERVATION: unchanged. The other four entries live in `game/effects/mod.rs` + // and `game/effects/scoped_library_search.rs`, neither of which this change touches, + // and the inserted expression adds no line matching the needle — total still 37, + // partition still 5/7/25. + // // The Ward continuation port independently inserts +13 lines above the same // producer, while this branch's durable-knowledge hooks add another 24, so the // combined tree is `:11828 - 7 + 13 + 24 = :11858`. - "game/engine.rs:11858".to_string(), + // + // MERGE OF `upstream/main` 117b430c2 INTO THIS BRANCH: `:11837` / `:11858` => `:11874`. + // This is the case the COLLISION NOTE above was written for, and it arrived as a real + // conflict rather than a silent auto-merge. BOTH incoming numbers were stale, each + // correct only for its own side: this branch's `:11837` counts the search-observer `-7`, + // the axis-scoped `-7` and the CR 500.5 `+23`, but not upstream's insertions; upstream's + // `:11858` counts the Ward continuation `+13` and the durable-knowledge hooks `+24`, but + // not this branch's. The shifts are INDEPENDENT and COMPOSE, so accepting either side + // verbatim would have been wrong by `37` or `16` respectively. + // + // Resolved BY CONTENT FIRST, arithmetic afterwards as a CHECK, per the doctrine at the + // head of this log. The line whose sha256 (WITH trailing newline) is + // `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` sits at `:11874` + // in the merged tree; that digest matches exactly ONE line under a whole-file scan, and + // the literal text is likewise unique, so the coordinate is unambiguous. The check: + // `11828 -7 (search-observer) -7 (axis-scoped) +23 (CR 500.5) +13 (Ward) +24 (durable + // knowledge) = 11874`, which equals the located coordinate exactly. The conflict markers + // sat BELOW the producer, so resolving them could not have shifted it. + // + // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and + // neither does this branch — total still 37, partition still 5/7/25. + "game/engine.rs:11874".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 5405522f18..26fc394b92 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -2737,6 +2737,13 @@ pub(super) fn handle_resolution_choice( // 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. + // That cover now spans BOTH object-backed families — the token axis reads the + // ∞ pile, and the counter axes read the registered `(object, counter)` pairs + // that derive each axis — and it applies ONLY while the collapse is still + // UNACCEPTED. Once a stash exists for the axis, CR 732.2c has already taken the + // shortcut, so the projection's acceptance gate keeps the row even with its + // whole backing gone: the growth still lands here, and a row that vanished + // before it landed would be the display lying about an agreed result. 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/interaction.rs b/crates/engine/src/game/interaction.rs index d3dff9378f..cb4edc9d81 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -2469,9 +2469,13 @@ fn loop_shortcut_projection( // restored dump into an engine panic. // // LATENT, NOT LIVE (measured at this head): no in-tree producer can reach this - // arm with `0`. `build_shortcut_schema` (`game/engine.rs`) has exactly two call - // sites and both pass `MAX_SHORTCUT_CYCLES`; the per-viewer projection in - // `game/visibility.rs` only re-projects an existing schema's value; and + // arm with `0`. `build_shortcut_schema` (`game/engine.rs`) has THREE call sites: + // `interactive_loop_bridge` and `try_offer_object_growth_shortcut` pass + // `MAX_SHORTCUT_CYCLES`, while `certified_bounded_cycle_offer` passes a NARROWED + // `max_iterations` — which cannot be `0` either, because that producer refuses + // outright unless `(1..MAX_SHORTCUT_CYCLES).contains(&max_iterations)`. The + // per-viewer projection in `game/visibility.rs` only re-projects an existing + // schema's value; and // `ShortcutDecisionSchema::default().max_iterations == default_max_iterations() // == MAX_SHORTCUT_CYCLES` (`analysis/decision_template.rs`), which is also the // `#[serde(default)]` for a pre-bound save. The only way `0` diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 978894886c..a48b1ae322 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3564,11 +3564,13 @@ 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, 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. +/// counter target to its axis, with exactly four production call sites: `scheduled_collapse_axes` +/// and `clear_collapsed_materializations`' surviving-target guard (twice) in this file, plus +/// `game::derived_views::object_growth_backing`, which derives each registered pair's own axis to +/// decide whether THIS axis still has live board backing. The counter-DISPLAY projection in +/// `game::derived_views` is NOT one of them — `counter_display_views` unions the +/// BATTLEFIELD-SURVIVING entries of `unbounded_counter_targets` (as the ∞ ANNOTATION) with every +/// object's own positive counters (as `Finite` rows, in every zone), and maps neither 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 @@ -3592,15 +3594,19 @@ 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 — with the pill projection no longer mapping to an axis, both +/// REACHABILITY BY CONSUMER — with the pill projection still not mapping to an axis, all THREE /// 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, AND by `derive_views` (through `scheduled_display_axes`) to flag each `∞` row. +/// removals, AND by `derive_views` (through `accepted_collapse_axes`) to flag each `∞` row. /// Exactly the fail-open described above: a removal that finds nothing, and an axis that /// flags no row. An unflagged row renders plain `∞` rather than `∞→N`, so the polarity is /// unchanged — nothing is hidden, one affordance is merely not offered. /// • `clear_collapsed_materializations`' own surviving-target guard. +/// • `game::derived_views::object_growth_backing`'s `Counter(..)` arm — same fail-open: a +/// drifted bearer derives `Counter(_, Other)`, which matches no registered pair for the axis +/// being asked about, so nothing answers, the arm returns `None`, and the badge is KEPT. +/// Never `Some(false)`, which is the only answer that could drop a row. /// /// That fail-open polarity is the SAME one this phase mandates everywhere else (an axis /// with no registration renders ∞): it can only ever leave an ∞ standing one boundary longer @@ -14498,17 +14504,28 @@ declare_game_state! { /// CR 732.2a / CR 701.34a display state: for the winning controller of an /// accepted COUNTER-growth loop shortcut (proliferate charge on Pentad Prism, - /// burden on The One Ring), the `(ObjectId, CounterType)` pairs whose preserved - /// `Generic` counters the certified-unbounded loop pumps each cycle. The - /// counter analog of `unbounded_loop_pile`: object-growth marks a per-OBJECT - /// pile, but the counter-growth cover's unbounded axis is object-agnostic - /// (`ResourceAxis::Counter(Other, Other)`), so this per-object channel is what - /// lets the frontend render `∞` on the specific pumped counter pill instead of - /// the literal count. Re-derived once at loop materialization (by driving one - /// period on a clone and diffing `Generic` counters) and projected to - /// `DerivedViews::unbounded_counters`. Written ONLY by - /// `register_unbounded_counter_targets`; cleared (in lockstep with - /// `unbounded_resources` / `unbounded_loop_pile`) by `clear_unbounded_loop`. + /// burden on The One Ring, a +1/+1 or loyalty pump loop), the + /// `(ObjectId, CounterType)` pairs whose preserved BENEFICIAL counters the + /// certified-unbounded loop pumps each cycle — the wildcard-free partition + /// `analysis::resource::counter_is_beneficial_materializable` names + /// (`Generic(_)`, `Plus1Plus1`, `Loyalty`, `Defense`). The counter analog of + /// `unbounded_loop_pile`: object-growth marks a per-OBJECT pile, but the + /// counter-growth cover's unbounded axis MAY be object-agnostic + /// (`ResourceAxis::Counter(Other, Other)`) — one accepted proposal can carry both + /// that display axis and an object-classed one, which is why the universal was + /// weakened here — so this per-object channel is what lets the frontend render + /// `∞` on the specific pumped counter pill instead of the literal count. + /// Re-derived once at loop materialization by + /// `game::engine::current_period_counter_growth` (drive one period on a clone, + /// diff beneficial counters) — the SAME single derivation the batched-collapse δ + /// stash carries, projected to `(object, counter)` — and projected again, as the ∞ + /// ANNOTATION half only, into `DerivedViews::counter_display`, whose rows also exist + /// for objects this store never names. Written ONLY by + /// `register_unbounded_counter_targets`. Cleared by TWO authorities: + /// `clear_unbounded_loop` (whole-map, in lockstep with `unbounded_resources` / + /// `unbounded_loop_pile`) and `clear_collapsed_materializations`, which filters + /// the pairs by the collapsed axes and either removes the entry or re-inserts the + /// survivors. /// /// DISPLAY-ONLY: the object's real counter count is NEVER mutated by this field — /// CR 701.34a proliferate still adds a real counter each cycle; the `∞` is a @@ -20559,17 +20576,20 @@ 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 TWO production callers: /// `clear_collapsed_materializations`, which REMOVES these axes once the growth was applied, - /// and `game::derived_views::scheduled_display_axes`, which flags each `∞` row's `scheduled` - /// field. + /// and `game::derived_views`, which projects it onto the `(player, family)` collapse-state + /// channel (`UnboundedFamilyView::state`). /// - /// NOT a display FILTER. `derive_views` reads this to ANNOTATE a row, never to decide whether - /// the row exists. Which surfaces exist is gated on their own stores and on live battlefield - /// membership, never on this set. (This doc has been stale twice — it once said "ONE - /// production caller" and "deliberately not read by `game::derived_views`"; then it named a - /// since-removed tag loop as the caller. Each time the mirror sentence in `derived_views` was - /// updated and this one — the doc a future caller reads first — was not. Hence it now names - /// the FUNCTION, not the consumer, so adding a consumer inside `derived_views` cannot make it - /// stale again.) + /// NOT a display FILTER. `derive_views` reads this to ANNOTATE a row, and — since CR 732.2c + /// binds an accepted shortcut the instant the last player accepts — to KEEP a row whose board + /// backing has died; never to decide that a row should not exist. Which surfaces exist is + /// gated on their own stores and on live battlefield membership, never on this set alone. + /// (This doc has been stale THREE times — it once said "ONE production caller" and + /// "deliberately not read by `game::derived_views`"; then it named a since-removed tag loop as + /// the caller; then it named both a function that has since been split in two and a row + /// `scheduled` field that no longer exists. Each time the mirror sentence in `derived_views` + /// was updated and this one — the doc a future caller reads first — was not. Hence it now + /// names the CHANNEL and the rule, not a function and not a field: a channel name survives a + /// rename, and a function name has now demonstrably not survived three.) /// /// CR 732.2a + CR 732.2c — WHAT THIS STASH IS, STATED AS THE RULE RATHER THAN AS A CONCESSION. /// This doc previously called the stash an engine deviation "that no CR licenses". That was @@ -25292,6 +25312,224 @@ mod tests { ); } + /// Shared rig for the two widened-registration boundary tests below. ONE builder, two + /// `#[test]`s: a single four-arm test masks its own later arms, because a mutant that reds an + /// early arm aborts before the rest run — which is exactly how the polarity of one of these + /// probes was got wrong once already. + /// + /// Returns `(state, driven_axis, widened_axis)`. + /// + /// EVERY LINE HERE IS LOAD-BEARING: + /// - CR 122.1: `collapsed_counter_axis` takes `CounterClass` from the COUNTER and + /// `ObjectClass` from the BEARER. On a CREATURE bearer `Generic("charge")` derives + /// `Counter(Other, CREATURE)` — NOT `Counter(Other, Other)`. Getting that wrong makes the + /// matched negative assert about an axis nothing derives. + /// - `GameObject::new` sets `card_types: CardType::default()` (EMPTY `core_types`), so the + /// bearer's creature-ness must be assigned explicitly. A name string does nothing; without + /// the assignment every axis below is `ObjectClass::Other` and the REACH arm is false. + /// - `mark_unbounded_loop` writes `unbounded_resources` ONLY. + /// `register_unbounded_loop_enablers` is the sole write authority for + /// `unbounded_loop_enablers` and no-ops on an empty set, so without the explicit call the + /// enabler-absence assertion would measure a map NOTHING in the rig can populate — a + /// zero-census with no positive control. + fn widened_counter_rig(ct: CounterType) -> (GameState, ResourceAxis, ResourceAxis) { + use crate::analysis::resource::{CounterClass, ObjectClass}; + + let driven_axis = ResourceAxis::Counter(CounterClass::Other, ObjectClass::Creature); + let widened_axis = ResourceAxis::Counter(CounterClass::Plus1Plus1, ObjectClass::Creature); + + let mut state = GameState::new_two_player(7); + let mut bearer = GameObject::new( + ObjectId(10), + CardId(10), + PlayerId(0), + "Beast".to_string(), + Zone::Battlefield, + ); + bearer.card_types.core_types = vec![CoreType::Creature]; + state.objects.insert(ObjectId(10), bearer); + state.battlefield.push_back(ObjectId(10)); + + state.mark_unbounded_loop(PlayerId(0), &[driven_axis]); + state.register_unbounded_loop_enablers(PlayerId(0), BTreeSet::from([ObjectId(10)])); + state.register_unbounded_counter_targets(PlayerId(0), vec![(ObjectId(10), ct)]); + (state, driven_axis, widened_axis) + } + + /// The stash a `DriveSequence` accept leaves, naming exactly the driven axis. + fn widened_counter_driven_stash( + driven_axis: ResourceAxis, + ) -> Vec { + vec![PersistentAxisMaterialization::DriveSequence { + sequence: vec![], + collapsed_axes: vec![driven_axis], + }] + } + + /// CR 732.2a: widening the `∞` counter registration from `Generic`-only to the whole + /// beneficial partition can register a pair whose derived axis the accepted collapse never + /// names. This pins what that does at the boundary, on BOTH halves of + /// `clear_collapsed_materializations`: + /// + /// - arm 1 REACH — the two axes really differ, and BOTH derivations are asserted, so a rig + /// whose bearer silently lost its creature type fails here instead of passing vacuously. + /// - arm 2 SUBJECT (display) — the widened pair SURVIVES the driven collapse, and the `∞` + /// counter pill really is still projected for it. + /// - arm 3 THE ANSWER (rules state) — a surviving DISPLAY pair does NOT suppress the axis + /// removal and does NOT hold the `unbounded_loop_enablers` lockstep open. Both absences are + /// preceded, in the same frame, by a PRESENCE assertion on the same key, so each measures a + /// REMOVAL rather than a map nothing populated. + /// + /// Its matched negative is `a_counter_pair_on_the_driven_axis_is_dropped_at_the_boundary`, + /// which shares this rig builder and asserts the complementary outcome on the same map. + /// + /// REVERT-PROBES, each with the arm it flips and the direction (all GREEN → RED): + /// - delete `&& !driven_axes.contains(&collapsed_counter_axis(..))` from the surviving-target + /// filter ⇒ flips the MATCHED NEGATIVE's arm 4, not this test: the filter is `P && Q`, so + /// dropping `Q` is strictly MORE permissive, more pairs survive, and only a REMOVAL + /// assertion can red. + /// - replace `axes_to_remove.retain(|ax| !backed.contains(ax))` with + /// `if !surviving_targets.is_empty() { axes_to_remove.clear(); }` — a surviving display pair + /// holding the whole rules-state removal open ⇒ arm 3 reds (both halves), arm 2 stays green. + /// - replace the survivors if/else with an unconditional + /// `self.unbounded_counter_targets.remove(&controller);` ⇒ arm 2 reds, arm 3 stays green. + /// + /// HONEST BOUND: this drives `clear_collapsed_materializations` directly with a hand-built + /// stash, so it is a CONTRACT test of the boundary algebra, not a live-game repro. The + /// mixed-stash shape in which a surviving pair CAN suppress an axis removal (two accepts by + /// one controller before one boundary, taking different routes) pre-exists this widening for + /// `Generic` pairs; the widening enlarges its domain to the beneficial partition and is + /// recorded as a follow-up, not fixed here. + #[test] + fn widened_counter_registration_survives_a_driven_collapse_without_moving_the_axis_set() { + let (mut state, driven_axis, widened_axis) = widened_counter_rig(CounterType::Plus1Plus1); + let bearer = ObjectId(10); + + // ARM 1 — REACH. Both derivations asserted, and their difference. + assert_eq!( + collapsed_counter_axis(&state, bearer, &CounterType::Plus1Plus1), + widened_axis, + "reach: on a CREATURE bearer a +1/+1 counter derives Counter(Plus1Plus1, Creature)" + ); + assert_eq!( + collapsed_counter_axis(&state, bearer, &CounterType::Generic("charge".to_string())), + driven_axis, + "reach: on the SAME creature bearer a Generic counter derives Counter(Other, Creature) \ + — the ObjectClass comes from the BEARER, so this is NOT Counter(Other, Other)" + ); + assert_ne!( + widened_axis, driven_axis, + "reach: the widened pair's axis is not the one the collapse drives — without this the \ + whole fixture is about one axis" + ); + + // PRE-CLEAR PRESENCE — the positive controls. Each absence asserted after the clear is a + // REMOVAL because the same key is proven present here, in the same frame. + assert_eq!( + state.unbounded_counter_targets.get(&PlayerId(0)), + Some(&BTreeSet::from([(bearer, CounterType::Plus1Plus1)])), + "pre-clear: the widened pair is registered" + ); + assert_eq!( + state.unbounded_resources.get(&PlayerId(0)), + Some(&BTreeSet::from([driven_axis])), + "pre-clear: the marked axis set is exactly the driven axis" + ); + assert!( + state.unbounded_loop_enablers.contains_key(&PlayerId(0)), + "pre-clear: the enabler map is POPULATED — without this the arm-3 absence below \ + measures a map nothing in the rig can write" + ); + + state.clear_collapsed_materializations( + PlayerId(0), + &widened_counter_driven_stash(driven_axis), + ); + + // ARM 2 — SUBJECT (display). The widened pair survives, and its pill is still projected. + assert_eq!( + state.unbounded_counter_targets.get(&PlayerId(0)), + Some(&BTreeSet::from([(bearer, CounterType::Plus1Plus1)])), + "ARM2 widened pair survives: its derived axis was not collapsed, so per CR 732.2c \ + nothing about it has ended" + ); + let pills = + crate::game::derived_views::derive_views(&state, Some(PlayerId(0))).counter_display; + assert_eq!( + pills.get(&bearer), + Some(&crate::game::derived_views::ObjectCounterDisplay { + pills: vec![crate::game::derived_views::CounterRowView { + counter: CounterType::Plus1Plus1, + count: state + .objects + .get(&bearer) + .and_then(|o| o.counters.get(&CounterType::Plus1Plus1).copied()) + .unwrap_or(0), + magnitude: crate::game::derived_views::CounterMagnitude::Unbounded, + }], + loyalty: None, + }), + "ARM2 pill projection: the surviving pair really reaches `counter_display` — the \ + store half alone would not prove the display over-keep is visible, got {pills:?}" + ); + + // ARM 3 — THE ANSWER (rules state). Both halves are REMOVALS, not absences. + assert!( + !state.unbounded_resources.contains_key(&PlayerId(0)), + "ARM3a axis set emptied: a surviving DISPLAY pair does not suppress the removal of an \ + axis the collapse actually drove" + ); + assert!( + !state.unbounded_loop_enablers.contains_key(&PlayerId(0)), + "ARM3b enabler dropped: the axis set emptied, so the lockstep drop fires — a surviving \ + display pair does not hold it open" + ); + } + + /// The MATCHED NEGATIVE of + /// `widened_counter_registration_survives_a_driven_collapse_without_moving_the_axis_set`, + /// on the SAME rig builder and the SAME map: a registered pair whose derived axis IS the + /// driven one is filtered out and its entry removed. Without this arm, the survival assertion + /// next door would pass against a boundary that never filters anything. + /// + /// REVERT-PROBE: delete `&& !driven_axes.contains(&collapsed_counter_axis(..))` from the + /// surviving-target filter ⇒ this test reds (the pair survives, the entry is re-inserted, and + /// `contains_key` is true) while the survival test stays green. That is the only mutation of + /// the three that flips THIS arm, and it flips no other. + #[test] + fn a_counter_pair_on_the_driven_axis_is_dropped_at_the_boundary() { + let (mut state, driven_axis, widened_axis) = + widened_counter_rig(CounterType::Generic("charge".to_string())); + let bearer = ObjectId(10); + + // ARM 1 — REACH, identical to its twin: this pair's axis IS the driven one. + assert_eq!( + collapsed_counter_axis(&state, bearer, &CounterType::Generic("charge".to_string())), + driven_axis, + "reach: the Generic pair on a creature bearer derives the DRIVEN axis" + ); + assert_ne!( + widened_axis, driven_axis, + "reach: the two axes this pair of tests separates really are distinct" + ); + assert!( + state.unbounded_counter_targets.contains_key(&PlayerId(0)), + "pre-clear: the pair is registered — the absence below is a REMOVAL" + ); + + state.clear_collapsed_materializations( + PlayerId(0), + &widened_counter_driven_stash(driven_axis), + ); + + // ARM 4 — the pair derives a driven axis, so it is filtered and the entry removed. + assert!( + !state.unbounded_counter_targets.contains_key(&PlayerId(0)), + "ARM4 generic pair filtered out, entry removed: its derived axis WAS collapsed, so its \ + ∞ has genuinely ended" + ); + } + /// `register_unbounded_loop_enablers` is a no-op for an empty set — no entry to /// defuse on later (mirrors `mark_unbounded_loop`'s idempotent set-union contract). #[test] diff --git a/crates/engine/tests/fixtures/kilo_freed_relic_pentad_max_of_one_4p.json.gz b/crates/engine/tests/fixtures/kilo_freed_relic_pentad_max_of_one_4p.json.gz new file mode 100644 index 0000000000..48acb6116c Binary files /dev/null and b/crates/engine/tests/fixtures/kilo_freed_relic_pentad_max_of_one_4p.json.gz differ diff --git a/crates/engine/tests/integration/combo_infinite_pile.rs b/crates/engine/tests/integration/combo_infinite_pile.rs index 1c5dc3e66f..9625a22d80 100644 --- a/crates/engine/tests/integration/combo_infinite_pile.rs +++ b/crates/engine/tests/integration/combo_infinite_pile.rs @@ -57,6 +57,24 @@ use std::collections::BTreeSet; use super::support::shared_card_db; +/// The `DerivedViews` channels both client wire goldens are lifted from, declared ONCE and +/// referenced by path from `kilo_live_offer_from_real_dump` so the two emitters cannot drift. +/// Previously each file hard-coded its own copy of these four names with only a comment coupling +/// them: `filter_map` silently DROPS a name that matches no field, and each file's drift compare +/// then reads a committed golden written by the same typo, so both sides omit the channel and +/// agree with themselves. One shared array makes an edit land on both emitters at once. +/// +/// Neither frame carries all four (this file's has no `counter_display`; kilo's has no +/// `unbounded_pile`), so each non-vacuity guard asserts this set MINUS the one name its frame +/// legitimately lacks — which is what makes the union of the two guards span all four by +/// construction rather than by comment. +pub(crate) const WIRE_GOLDEN_CHANNELS: [&str; 4] = [ + "unbounded_pile", + "unbounded_resources", + "counter_display", + "unbounded_families", +]; + const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); const P2: PlayerId = PlayerId(2); @@ -246,20 +264,16 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { // 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 + // DETERMINISM: `counter_display` is a std `HashMap` + // (derived_views.rs) — the VALUE is a pre-partitioned row set, not a bare counter-type list — + // 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", - "unbounded_families", - ] - .into_iter() - .filter_map(|k| wire.get(k).map(|v| (k.to_string(), v.clone()))) - .collect(); + let golden: serde_json::Map = WIRE_GOLDEN_CHANNELS + .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" @@ -284,6 +298,29 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { "derive_views().unbounded_pile must equal the pile set (battlefield-filtered)" ); + // NON-VACUITY GUARD for the key list above, and it sits HERE — below the WRITE — under this + // emitter's own stated rule, because it reads `golden`, which is derived from `derived`. + // `filter_map` DROPS a name that matches no `DerivedViews` field, and the drift compare below + // then reads a committed file the same typo wrote — so both sides omit the channel and the + // compare agrees with itself. Asserting the exact key SET turns a mistyped name into a RED. + // `BTreeSet` so this does not depend on which container backs `serde_json::Map`. + // + // PER-FILE RESIDUAL, CLOSED BY THE PAIR: this frame legitimately carries no `counter_display`, + // and a name a frame never populates is indistinguishable from a mistyped one from inside that + // frame. `kilo_live_offer_from_real_dump`'s twin guard covers `counter_display` (and this file + // covers the `unbounded_pile` its frame lacks). The union spans all four BY CONSTRUCTION: both + // guards are `WIRE_GOLDEN_CHANNELS` minus the one name their own frame lacks, so a name added + // to the shared array reds whichever frame does not carry it instead of being silently dropped. + let channels: BTreeSet<&str> = golden.keys().map(String::as_str).collect(); + let mut expected = BTreeSet::from(WIRE_GOLDEN_CHANNELS); + expected.remove("counter_display"); + assert_eq!( + channels, expected, + "the golden key list names a field `DerivedViews` does not have, or this frame stopped \ + carrying one it must: a mistyped name is dropped silently and the drift compare below \ + then agrees with itself. Check every name against `DerivedViews`." + ); + // 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")) @@ -835,7 +872,10 @@ fn real_4p_observed_drive_sequence_replays_captured_period_n_times() { .map(|f| f.state); assert_eq!( tokens_state, - Some(FamilyCollapseState::Scheduled(CollapseCertainty::Committed)), + Some(FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(P0), + }), "a DriveSequence replays real cycles and cannot park, so its tokens family is Committed \ (∞→N) — contrast the batched Tokens stash behind unbounded-token-wire.json, which is \ Conditional (∞→?)" @@ -1614,7 +1654,10 @@ fn one_shot_bootstrap_accepted_state() -> GameState { runner.state().clone() } -/// MED-1 (CR 732.2a + CR 110.1): an object-growth `∞` ROW dies with its registered backing. +/// MED-1 (CR 732.2c + CR 110.1): an ACCEPTED object-growth `∞` ROW SURVIVES losing its entire +/// registered backing. Once the last player accepts, the shortcut is taken and the growth will +/// land at the boundary, so a row that vanished with the pile would have the HUD deny a result the +/// table already agreed to. /// /// ONE rig, TWO arms, THE SAME assertion — `derive_views(..).unbounded_resources` contains /// `ResourceAxis::TokensCreated`: @@ -1622,33 +1665,34 @@ fn one_shot_bootstrap_accepted_state() -> GameState { /// | 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** | +/// | subject | the pile's ONLY member (the seed) | **present** | /// /// 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. +/// accept, same `move_to_zone` chokepoint, differing only in WHICH object departs. It runs FIRST +/// on purpose — see the comment at that arm. +/// +/// THE ROW IS NOT KEPT BY ACCIDENT. This state is ACCEPTED, so the projection's FIRST conjunct +/// (`!accepted_axes.contains_key(&axis)`) is false and the backing check is never consulted — and +/// the schedule half asserted below proves the stash really does name this axis, so the conjunct +/// is decided by a live fact rather than by an empty map. The UNACCEPTED half of the same gate, +/// where a dead pile really does revoke the row, is pinned by +/// `derived_views::tests::an_accepted_token_collapse_keeps_its_row_when_its_pile_dies`' +/// NON-VACUITY 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. +/// MUTATION (RUN, and the reason this test exists in this shape): **DROP** the +/// `!accepted_axes.contains_key(&axis)` conjunct from the row loop's gate ⇒ the SUBJECT arm reds +/// with an empty row set (the pre-fix behaviour: an accepted collapse silently losing its badge +/// before it lands), while the CONTROL arm stays green because its backing is still live. /// -/// The store guards below are the anti-"register enablers instead" tripwire: routing this +/// The store and cash-out guards below are what make the surviving row HONEST rather than merely +/// present, and they are more load-bearing here than they were when this arm asserted absence: a +/// kept row is a claim about growth that will still land, so the test drives the real CR 500.5 +/// boundary and mints. They are also 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() { +fn accepted_object_growth_row_survives_losing_its_entire_pile() { use engine::analysis::resource::ResourceAxis; use engine::game::zones::move_to_zone; use engine::types::events::GameEvent; @@ -1734,9 +1778,10 @@ fn object_growth_infinity_row_dies_with_its_last_pile_member() { ); 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:?}" + subject_rows.contains(&ResourceAxis::TokensCreated), + "THE assertion (subject): the table ACCEPTED this collapse (CR 732.2c), so the \ + TokensCreated ∞ row survives its ENTIRE registered pile leaving the battlefield — the \ + growth still lands at the boundary below, got {subject_rows:?}" ); // THE CR 732.2c PIN: a dropped ROW does not cancel the accepted collapse. Doc blocks cite @@ -1789,21 +1834,18 @@ fn object_growth_infinity_row_dies_with_its_last_pile_member() { growth the table unanimously accepted still lands at the boundary (CR 732.2c)" ); - // 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(); + // SCOPE: NOTHING leaves the wire — the projected axis set is exactly the marked set. An + // acceptance gate that is too broad (keeping rows it should not) cannot be caught here, but + // one that is too NARROW is: any axis this rig marks and the projection drops reds this + // equality. 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`; the UNACCEPTED revocation half is + // `derived_views::tests::an_accepted_token_collapse_keeps_its_row_when_its_pile_dies`. assert_eq!( subject_rows.iter().copied().collect::>(), - expected_after, - "scope: exactly the backed axis leaves the wire; every unbacked axis keeps its ∞" + marked, + "scope: with the collapse accepted, every marked axis keeps its ∞ row" ); // STORE: a DISPLAY revocation only. Nothing here may touch the accepted-collapse stash, @@ -2224,7 +2266,11 @@ fn real_4p_counter_observer_drift_in_window_declines_batched_counter_but_still_m assert!( pre_boundary_families.iter().any(|f| f.player == P0 && f.family == UnboundedFamily::Counters - && f.state == FamilyCollapseState::Scheduled(CollapseCertainty::Conditional)), + && f.state + == FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(P0), + }), "in the accept→boundary window the counters family is Scheduled(Conditional) — a batched \ Counters collapse can still be declined, so ∞→? not ∞→N; got {pre_boundary_families:?}" ); @@ -2618,7 +2664,11 @@ fn med_tokens_boundary_mint_pause_preserves_replacement_choice() { .iter() .any(|f| f.player == P0 && f.family == UnboundedFamily::Tokens - && f.state == FamilyCollapseState::Scheduled(CollapseCertainty::Conditional)), + && f.state + == FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Conditional, + prompted: Some(P0), + }), "pre-submit: an accepted Tokens collapse is Scheduled(Conditional) — its boundary mint can \ park on a replacement choice, which is exactly what happens below; got {:?}", derive_views(&state, None).unbounded_families 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 7f62593fcf..e3f526191d 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 @@ -20,8 +20,13 @@ //! The `kilo_reinjected_pinless_history_suppresses_offer` test is the matched-pair proof that the //! migration is load-bearing (re-injecting the stale prefix flips the offer OFF). +use engine::analysis::decision_template::IterationCount; use engine::game::derived_views::{CollapseCertainty, FamilyCollapseState, UnboundedFamily}; use engine::game::engine::apply; +use engine::game::interaction::{ + bind_interaction_authority, derive_viewer_interaction, resolve_interaction_response, +}; +use engine::game::visibility::filter_state_for_viewer; use engine::types::ability::TargetRef; use engine::types::actions::GameAction; use engine::types::game_state::{ @@ -29,8 +34,14 @@ use engine::types::game_state::{ PayableResource, PersistedGameState, PersistentAxisMaterialization, WaitingFor, }; use engine::types::identifiers::ObjectId; +use engine::types::interaction::{ + InteractionOpportunityResponse, InteractionResponse, InteractionResponseSpec, + InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, + InteractionShortcutPin, InteractionSubmission, +}; use engine::types::mana::ManaType; use engine::types::player::PlayerId; +use engine::types::zones::Zone; const P0: PlayerId = PlayerId(0); const KILO: ObjectId = ObjectId(402); @@ -42,6 +53,30 @@ const PENTAD: ObjectId = ObjectId(405); const RELIC_TAP_MANA: usize = 1; const FREED_UNTAP: usize = 1; +/// The four loop permanents, per dump. Both real captures hold the same Kilo/Freed/Relic/Pentad +/// board under P0; only the `ObjectId`s differ, so ONE drive authority serves both and the +/// regression row cannot silently diverge from the rows that already pin this loop's behavior. +struct LoopIds { + kilo: ObjectId, + freed: ObjectId, + relic: ObjectId, + pentad: ObjectId, +} +const FIXTURE_IDS: LoopIds = LoopIds { + kilo: KILO, + freed: FREED, + relic: RELIC, + pentad: PENTAD, +}; +/// MEASURED off the reported capture, not guessed: Kilo 406, Relic 407, Freed 408, Pentad 409. +/// Note the Freed/Relic order is TRANSPOSED relative to the older fixture (403/404). +const CAPTURE_IDS: LoopIds = LoopIds { + kilo: ObjectId(406), + freed: ObjectId(408), + relic: ObjectId(407), + pentad: ObjectId(409), +}; + fn gunzip(gz: &[u8]) -> String { use std::io::Read; let mut json = String::new(); @@ -73,6 +108,22 @@ fn load_migrated_dump() -> GameState { .into_game_state() } +/// Load the REPORTED playtest capture — the dump the "offer says ∞, collapse allows 1" bug was +/// filed from — through the same production restore chokepoint `load_migrated_dump` uses. It is a +/// DIFFERENT game from `kilo_freed_relic_pentad_4p.json.gz` (different seed, board size, phase and +/// ObjectIds); this is the one the regression row drives, so nobody has to argue that the older +/// fixture stands in for it. +fn load_reported_capture() -> GameState { + let json = gunzip(include_bytes!( + "../fixtures/kilo_freed_relic_pentad_max_of_one_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("capture envelope parses as JSON"); + serde_json::from_value::(envelope["gameState"].clone()) + .expect("the reported capture's gameState deserializes through the production decoder") + .into_game_state() +} + /// The acting player for the current beat (choice prompts carry their own `player`; a priority beat /// is answered by the live holder so the multiplayer APNAP pass is authorized). fn beat_actor(state: &GameState) -> PlayerId { @@ -90,12 +141,12 @@ fn beat_actor(state: &GameState) -> PlayerId { /// fire live — this is NOT a simulation probe). Answers each fixed choice with the loop's demanded /// value (tap Kilo, Blue mana, proliferate Pentad), activates Freed once, and settles at the first /// of `{empty-stack Priority, LoopShortcut}` reached after Freed resolves. -fn drive_one_live_cycle(state: &mut GameState) { +fn drive_one_live_cycle(state: &mut GameState, ids: &LoopIds) { apply( state, P0, GameAction::ActivateAbility { - source_id: RELIC, + source_id: ids.relic, ability_index: RELIC_TAP_MANA, }, ) @@ -111,8 +162,14 @@ fn drive_one_live_cycle(state: &mut GameState) { kind: PayCostKind::TapCreatures { .. }, .. } => { - apply(state, actor, GameAction::SelectCards { cards: vec![KILO] }) - .expect("tap Kilo for the Relic mana ability"); + apply( + state, + actor, + GameAction::SelectCards { + cards: vec![ids.kilo], + }, + ) + .expect("tap Kilo for the Relic mana ability"); } // Relic's "add one mana of any color": choose BLUE to pay Freed's {U}. WaitingFor::ChooseManaColor { .. } => { @@ -132,7 +189,7 @@ fn drive_one_live_cycle(state: &mut GameState) { state, actor, GameAction::SelectTargets { - targets: vec![TargetRef::Object(PENTAD)], + targets: vec![TargetRef::Object(ids.pentad)], }, ) .expect("proliferate Pentad"); @@ -147,7 +204,7 @@ fn drive_one_live_cycle(state: &mut GameState) { state, P0, GameAction::ActivateAbility { - source_id: FREED, + source_id: ids.freed, ability_index: FREED_UNTAP, }, ) @@ -213,7 +270,7 @@ fn kilo_migrated_dump_fires_object_growth_offer() { "Pentad carries 3 charge counters in the real dump" ); - drive_one_live_cycle(&mut state); + drive_one_live_cycle(&mut state, &FIXTURE_IDS); // Non-vacuous reach-guard: the live drive rebuilt a clean, fully-recorded 2-step period. assert_eq!( @@ -302,7 +359,7 @@ fn kilo_reinjected_pinless_history_suppresses_offer() { } state.last_loop_action_sequence = pinless; - drive_one_live_cycle(&mut state); + drive_one_live_cycle(&mut state, &FIXTURE_IDS); // The stale pinless prefix makes `try_offer` re-drive from a pinless `seq[0]` and abort ⇒ // no offer surfaces (the C2 / R3.0-A baseline). @@ -349,6 +406,45 @@ fn drive_all_accept_n(state: &mut GameState, n: u32) { } } +/// Declare the offer's OWN stated count — exactly what the real frontend dispatches +/// (`LoopShortcutModal`'s `handleConfirm` sends `count: schema.iteration_count`, there being no +/// declare-time picker) — then accept in APNAP order. Returns the ceiling the SAME offer +/// published, so a caller can compare the CR 500.5 collapse range against it without restating a +/// literal that would pass on both sides of a regression. +fn drive_all_accept_as_offered(state: &mut GameState) -> u32 { + use engine::analysis::loop_check::ShortcutResponse; + let (proposer, ceiling, offered) = match &state.waiting_for { + WaitingFor::LoopShortcut { + proposer, schema, .. + } => ( + *proposer, + schema.max_iterations, + schema.iteration_count.clone(), + ), + other => panic!("expected a CR 732.2a loop-shortcut offer, got {other:?}"), + }; + apply( + state, + proposer, + GameAction::DeclareShortcut { + count: offered, + template: None, + }, + ) + .expect("the proposer declares the offer's own stated count, as the modal does"); + while let WaitingFor::RespondToShortcut { player, .. } = state.waiting_for.clone() { + apply( + state, + player, + GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }, + ) + .expect("each living opponent accepts the as-offered ∞-charge shortcut"); + } + ceiling +} + /// Pass priority (for whichever seat holds it) until the next CR 500.5 phase/step boundary raises /// the deferred-collapse prompt. No player re-drives the loop — the accept cleared the recorded /// `last_loop_action_sequence` — so the phase simply ends and the boundary drain surfaces the @@ -375,22 +471,25 @@ fn drive_to_collapse_boundary(state: &mut GameState) { /// loop marks Pentad Prism's charge counter as an unbounded DISPLAY target — so the frontend /// renders `∞` on that pill — WITHOUT mutating the real charge count. Composite of the new /// field write (`register_unbounded_counter_targets`), the derived-view projection -/// (`DerivedViews::unbounded_counters`), and the serde wire shape, all driven through the real +/// (`DerivedViews::counter_display`), and the serde wire shape, all driven through the real /// accept pipeline from the real 4p dump. /// /// REVERT-PROBE (measured, non-vacuous): deleting the `register_unbounded_counter_targets` -/// write in `materialize_object_growth_shortcut` (or the `grown_generic_counter_targets` -/// re-derivation) leaves `unbounded_counter_targets` empty ⇒ assertions (2) the field write, +/// write in `materialize_object_growth_shortcut` (or the `current_period_counter_growth` +/// derivation it projects from) leaves `unbounded_counter_targets` empty ⇒ assertions (2) the +/// field write, /// (3) the derived-view projection, and (4) the wire round-trip all FLIP to fail. The /// offer-fires reach-guard (1) and the `charge == Some(4)` rules-correctness anchor /// (display-only: the real count is untouched) HOLD BOTH WAYS. #[test] fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { - use engine::game::derived_views::{derive_views, DerivedViews}; + use engine::game::derived_views::{ + derive_views, CounterMagnitude, CounterRowView, DerivedViews, ObjectCounterDisplay, + }; use engine::types::counter::CounterType; let mut state = load_migrated_dump(); - drive_one_live_cycle(&mut state); + drive_one_live_cycle(&mut state, &FIXTURE_IDS); // (1) Reach-guard (holds both ways under revert): the ∞-charge offer surfaced for P0. If // this ever regresses, every downstream assertion is vacuous — so it gates them. @@ -490,20 +589,19 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { // Where a pre-WRITE frame must be asserted, CAPTURE it into a local above and assert the local // below (see combo_infinite_pile.rs's declined-wire emitter). // - // 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 + // DETERMINISM: `counter_display` is a std `HashMap` + // (derived_views.rs) — the VALUE is a pre-partitioned row set, not a bare counter-type list — + // 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", - "unbounded_families", - ] - .into_iter() - .filter_map(|k| wire.get(k).map(|v| (k.to_string(), v.clone()))) - .collect(); + // Shared with `combo_infinite_pile`'s emitter — see `WIRE_GOLDEN_CHANNELS` for why the two + // copies of this list had to become one. + let golden: serde_json::Map = + crate::combo_infinite_pile::WIRE_GOLDEN_CHANNELS + .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" @@ -523,16 +621,40 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { .expect("write the wire golden"); } + // The row's count is READ FROM THE DUMP, never invented — this frame's Pentad really carries + // this many charge counters, and the committed wire golden compared below carries that literal. + let pentad_charge = state + .objects + .get(&PENTAD) + .and_then(|o| o.counters.get(&charge).copied()) + .unwrap_or(0); + assert!( + pentad_charge >= 1, + "reach-guard: this real-dump frame's Pentad must actually carry charge counters, so the \ + row's `count` below is a NONZERO live value and not vacuously 0; got {pentad_charge}" + ); assert_eq!( - views.unbounded_counters.get(&PENTAD), - Some(&vec![charge.clone()]), - "the ∞ charge pill stays projected while the collapse is merely SCHEDULED" + views.counter_display.get(&PENTAD), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: charge.clone(), + count: pentad_charge, + magnitude: CounterMagnitude::Unbounded, + }], + loyalty: None, + }), + "the ∞ charge pill stays projected while the collapse is merely SCHEDULED, and it carries \ + the LIVE count so the display never has to join back to `objects[..].counters`" ); assert!( views.unbounded_families.iter().any(|f| f.player == P0 && f.family == UnboundedFamily::Counters - && f.state == FamilyCollapseState::Scheduled(CollapseCertainty::Committed)), + && f.state + == FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(P0), + }), "the real kilo accept's single DriveSequence yields a Committed family on a REAL \ production dump — that is this witness's distinct property, NOT uniqueness: two other \ Committed witnesses exist on synthetic boards (combo_infinite_pile's grafted \ @@ -543,6 +665,30 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { views.unbounded_families ); + // NON-VACUITY GUARD for the key list above, and it sits HERE — below the WRITE — under this + // emitter's own stated rule, because it reads `golden`, which is derived from `views`. + // `filter_map` DROPS a name that matches no `DerivedViews` field, and the drift compare below + // then reads a committed file the same typo wrote — so both sides omit the channel and the + // compare agrees with itself. Asserting the exact key SET turns a mistyped name into a RED. + // `BTreeSet` so this does not depend on which container backs `serde_json::Map`. + // + // PER-FILE RESIDUAL, CLOSED BY THE PAIR: this frame legitimately carries no `unbounded_pile`, + // and a name a frame never populates is indistinguishable from a mistyped one from inside that + // frame. `combo_infinite_pile`'s twin guard covers `unbounded_pile` (and this file covers the + // `counter_display` its frame lacks). The union spans all four BY CONSTRUCTION: both guards are + // `WIRE_GOLDEN_CHANNELS` minus the one name their own frame lacks, so a name added to the + // shared array reds whichever frame does not carry it instead of being silently dropped. + let channels: std::collections::BTreeSet<&str> = golden.keys().map(String::as_str).collect(); + let mut expected = + std::collections::BTreeSet::from(crate::combo_infinite_pile::WIRE_GOLDEN_CHANNELS); + expected.remove("unbounded_pile"); + assert_eq!( + channels, expected, + "the golden key list names a field `DerivedViews` does not have, or this frame stopped \ + carrying one it must: a mistyped name is dropped silently and the drift compare below \ + then agrees with itself. Check every name against `DerivedViews`." + ); + // 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")) @@ -557,23 +703,189 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { // the wire, and survives a round-trip; an EMPTY derived view omits it (skip_serializing_if). let json = serde_json::to_string(&views).expect("derived views serialize"); assert!( - json.contains("unbounded_counters"), - "the populated ∞-counter channel is present on the wire" + json.contains("counter_display"), + "the populated counter-display channel is present on the wire" ); let round: DerivedViews = serde_json::from_str(&json).expect("derived views round-trip"); assert_eq!( - round.unbounded_counters.get(&PENTAD), - Some(&vec![charge]), - "the ∞ counter channel survives a serde round-trip" + round.counter_display.get(&PENTAD), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: charge, + count: pentad_charge, + magnitude: CounterMagnitude::Unbounded, + }], + loyalty: None, + }), + "the counter-display channel survives a serde round-trip, count and magnitude included" ); let empty_json = serde_json::to_string(&DerivedViews::default()).expect("empty derived views serialize"); assert!( - !empty_json.contains("unbounded_counters"), + !empty_json.contains("counter_display"), "the field is omitted (skip_serializing_if) when empty" ); } +/// TARGET-DEPARTURE RELATION (CR 732.2a / CR 110.1), pinned end-to-end on the real 4p dump: when a +/// registered ∞ counter target leaves the battlefield, the per-object PILL disappears from the wire +/// while the aggregate counter ROW remains — and the STORE keeps the departed pair. +/// +/// That the pill and the row disagree is the point, and the REASON has changed — the assertion +/// outlived its original justification, which is why the discriminator arm below now exists. +/// +/// A pill is keyed by `ObjectId` and departure is an OBJECT event, so a pill has the identity it +/// needs to filter itself, and it filters unconditionally. A row is keyed by `ResourceAxis`. This +/// test used to explain the row's survival by "no axis-scoped backing authority exists" — that is +/// no longer true: `object_growth_backing` answers `Counter(..)` by deriving each registered +/// `(ObjectId, CounterType)` pair's own axis (`collapsed_counter_axis`), and on this very state +/// that answer is `Some(false)`. The row survives for a DIFFERENT reason: this fixture's collapse +/// was ACCEPTED (`drive_all_accept` above), and CR 732.2c makes an accepted shortcut binding, so +/// the acceptance conjunct keeps the row regardless of what happened to its targets. +/// +/// THAT DISTINCTION IS WHY (6) IS LOAD-BEARING. With the stash present, (4) passes whether or not +/// the counter authority works at all — every wrong answer in that subsystem (`None` from an +/// unmatched axis, an unregistered axis, a drifted bridge) also keeps the badge. So (4) alone is +/// vacuous in the direction that matters, and (6) is the arm that removes the acceptance and +/// requires the row to DIE. Only (6) proves the accept registered a pair whose derived axis equals +/// a marked axis — i.e. that the bridge join succeeds on PRODUCTION-DERIVED data rather than on +/// hand-built state, which no building-block test can establish. +/// +/// Nothing pinned this relation before: the token family's analog +/// (`loop_shortcut::stale_pile_member_is_omitted_from_the_wire_but_kept_in_the_store`) covers the +/// pile, and the counter pill's battlefield filter had no runnable guard on a real accept. +/// +/// MUTATIONS (to be RUN and recorded, one expected red each — if any reds more than its own row +/// that is reported, not trimmed): +/// - delete the `!state.battlefield.contains(id)` filter in `derive_views`' counter-pill loop +/// => (3) reds alone; +/// - restore the controller-keyed `Some(false)` `Counter(..)` arm in `object_growth_backing` +/// => (4) reds alone; +/// - "fix" it by pruning the STORE instead of the wire => (5) reds alone. (5) is the discriminator +/// against that wrong fix: the boundary collapse reads the store; +/// - revert the `Counter(..)` arm to `None` (the refusing revision) => (6) reds ALONE, and +/// nothing else here moves. That isolation is the proof (6) is measuring the authority and not +/// the acceptance gate. +#[test] +fn departed_counter_target_drops_its_pill_but_keeps_its_row_and_store_entry() { + use engine::analysis::resource::ResourceAxis; + use engine::game::derived_views::derive_views; + use engine::game::zones::move_to_zone; + use engine::types::counter::CounterType; + use engine::types::events::GameEvent; + use engine::types::zones::Zone; + + let mut state = load_migrated_dump(); + drive_one_live_cycle(&mut state, &FIXTURE_IDS); + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: at the CR 732.2a ∞-charge offer for P0, got {:?}", + state.waiting_for + ); + drive_all_accept(&mut state); + + let charge = CounterType::Generic("charge".into()); + + // (1) REACH-GUARD, holds under every mutation below: the accept registered the target AND it + // is on the battlefield right now — so any divergence after the move is caused by the + // departure and by nothing else. + assert!( + state + .unbounded_counter_targets + .get(&P0) + .is_some_and(|t| t.contains(&(PENTAD, charge.clone()))), + "reach-guard: the accept registered (Pentad, charge) as a ∞ display target" + ); + assert!( + state.battlefield.contains(&PENTAD), + "reach-guard: BEFORE the departure the target is on the battlefield" + ); + + // (2) REACH-GUARD: the pill and the row are BOTH present beforehand. Without this the + // post-departure assertions could pass on a wire that never carried either. + let before = derive_views(&state, None); + assert!( + before + .counter_display + .get(&PENTAD) + .is_some_and(|display| display.pills.iter().any(|r| r.counter == charge)), + "reach-guard: the pill is on the wire before the departure" + ); + let row_axes_before: Vec<_> = before.unbounded_resources.iter().map(|r| r.axis).collect(); + assert!( + row_axes_before + .iter() + .any(|a| matches!(a, ResourceAxis::Counter(..))), + "reach-guard: a counter ROW is on the wire before the departure, got {row_axes_before:?}" + ); + + // The departure itself, through the production chokepoint (CR 110.1: it stops being a + // permanent). + let mut events: Vec = Vec::new(); + move_to_zone(&mut state, PENTAD, Zone::Graveyard, &mut events); + assert!( + !state.battlefield.contains(&PENTAD), + "the departure really happened" + ); + + let after = derive_views(&state, None); + + // (3) THE PILL IS GONE — departure is an object event and the pill has object identity. + assert!( + !after.counter_display.contains_key(&PENTAD), + "(3) the departed target's ∞ pill must leave the wire, got {:?}", + after.counter_display + ); + + // (4) THE ROW REMAINS — because the collapse was ACCEPTED (CR 732.2c), not because nothing + // could revoke it. (6) below is what distinguishes those two explanations. + let row_axes_after: Vec<_> = after.unbounded_resources.iter().map(|r| r.axis).collect(); + assert!( + row_axes_after + .iter() + .any(|a| matches!(a, ResourceAxis::Counter(..))), + "(4) the counter ROW must survive its target's departure — the table already accepted \ + this collapse and it still lands at the boundary, got {row_axes_after:?}" + ); + + // (5) THE STORE IS NOT PRUNED — discriminator against "fixing" this by mutating the store: + // the CR 500.5 boundary collapse reads it. + assert!( + state + .unbounded_counter_targets + .get(&P0) + .is_some_and(|t| t.contains(&(PENTAD, charge.clone()))), + "(5) the STORE must still carry the departed (object, counter) pair — only the wire filters" + ); + + // (6) THE DISCRIMINATOR, and the only non-vacuous half of (4). Same post-departure state with + // the ACCEPTANCE removed: the row must now DIE. This is the single assertion in this file that + // requires the counter authority to actually work — it forces `object_growth_backing` to + // derive the departed pair's axis through `collapsed_counter_axis` and match it against a + // MARKED axis, both sides produced by the real accept on a real dump. Nothing hand-built can + // show that the two agree on production-derived data; that is why this arm lives here rather + // than at building-block level. + // + // `pending_unbounded_materialization` is a public field and this is a local clone, so removing + // it mutates nothing the rest of the test observes. + let mut unaccepted = state.clone(); + unaccepted.pending_unbounded_materialization.clear(); + let after_unaccepted = derive_views(&unaccepted, None); + let unaccepted_axes: Vec<_> = after_unaccepted + .unbounded_resources + .iter() + .map(|r| r.axis) + .collect(); + assert!( + !unaccepted_axes + .iter() + .any(|a| matches!(a, ResourceAxis::Counter(..))), + "(6) with the accepted collapse removed, the departed targets leave the counter row with \ + no live backing and it MUST be revoked — if it survives here, (4) above is passing for \ + no reason and the counter authority is not working, got {unaccepted_axes:?}" + ); +} + /// PERSISTENT-AXIS BOUNDARY COLLAPSE (CR 732.2a / CR 500.5 / CR 701.34a): the accepted Kilo /// proliferate ∞-charge loop is PROMPTED at the next phase/step boundary to name a finite N, then /// resolves to EXACTLY N more charge counters on Pentad Prism — driven end-to-end through the @@ -596,14 +908,16 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { /// (priority advances straight into combat) ⇒ the boundary reach-guard (2) FLIPS to a panic. #[test] fn kilo_accept_collapses_at_boundary_to_exactly_n_counters() { - use engine::game::derived_views::derive_views; + use engine::game::derived_views::{ + derive_views, CounterMagnitude, CounterRowView, ObjectCounterDisplay, + }; use engine::types::counter::CounterType; const N: u32 = 5; let charge = CounterType::Generic("charge".into()); let mut state = load_migrated_dump(); - drive_one_live_cycle(&mut state); + drive_one_live_cycle(&mut state, &FIXTURE_IDS); // (1) Reach-guard (gates everything downstream): the ∞-charge offer surfaced for P0. assert!( @@ -676,10 +990,29 @@ fn kilo_accept_collapses_at_boundary_to_exactly_n_counters() { "the collapsed ∞ counter target is cleared for P0, got {:?}", state.unbounded_counter_targets.get(&P0) ); + // (5b) THE ∞ ANNOTATION CLEARS BUT THE FINITE ROW SURVIVES, on an object that never left the + // battlefield: `clear_collapsed_materializations` drops the registered pair, and the finite + // pass in `counter_display_views` keeps publishing the now-real count — so the pill renders + // the real number rather than `∞`, and it does not vanish. + let display = derive_views(&state, None) + .counter_display + .get(&PENTAD) + .cloned() + .expect( + "after the collapse Pentad still renders a FINITE charge row — the `∞` ANNOTATION is \ + what `clear_collapsed_materializations` clears, not the row itself", + ); assert_eq!( - derive_views(&state, None).unbounded_counters.get(&PENTAD), - None, - "the derived ∞-counter view no longer projects Pentad after the collapse" + display, + ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: charge.clone(), + count: baseline + N, + magnitude: CounterMagnitude::Finite, + }], + loyalty: None, + }, + "the collapsed pair renders as EXACTLY one FINITE row carrying the real collapsed count" ); // (6) The boundary protocol closed cleanly back to ordinary priority (CR 800.4a). @@ -689,3 +1022,222 @@ fn kilo_accept_collapses_at_boundary_to_exactly_n_counters() { state.waiting_for ); } + +/// CR 732.2a + CR 732.2c REGRESSION, driven from the ACTUAL REPORTED PLAYTEST CAPTURE (the dump the +/// "the offer says ∞ but the collapse only allows 1" report was filed from — a DIFFERENT game from +/// the older fixture the rows above drive). +/// +/// The unbounded object-growth producer publishes the global safety limit as its ceiling but seeded +/// its stated count with a bare 1. The frontend echoes that stated count verbatim (there is no +/// declare-time picker), CR 732.2c makes the accepted count binding, and the accepted count caps the +/// CR 500.5 collapse prompt — so a stated count below the published ceiling silently picks the +/// controller's number for them. +/// +/// ONE flipping conjunct and THREE reach-guards. The flipping one is the boundary range: it reads +/// the ceiling off the SAME live offer rather than restating a literal, so no arm of it can pass on +/// both sides of the regression. Pre-fix it reads a collapse max of 1 against a published ceiling of +/// 1000. +/// +/// Never hard-code the declared count here: `drive_all_accept_as_offered` reads +/// `schema.iteration_count` to reproduce the frontend echo exactly, and that echo is what makes the +/// row discriminating. Never submit the amount either — the row stops at the prompt, because +/// asserting the offered RANGE is both the claim under test and the cheap path. +#[test] +fn kilo_reported_capture_offer_states_the_full_ceiling_it_publishes() { + let mut state = load_reported_capture(); + + // (1) LOAD REACH-GUARD (holds both ways): the reported capture is what loaded, not a stand-in + // for it. The board is the untouched 4p playtest capture, with the loop's four permanents on + // the controller's battlefield under the MEASURED ids. + assert_eq!( + state.objects.len(), + 409, + "the reported 4p playtest capture loads intact" + ); + for (label, id) in [ + ("Kilo", CAPTURE_IDS.kilo), + ("Freed", CAPTURE_IDS.freed), + ("Relic", CAPTURE_IDS.relic), + ("Pentad", CAPTURE_IDS.pentad), + ] { + let permanent = &state.objects[&id]; + assert_eq!( + (permanent.zone, permanent.controller), + (Zone::Battlefield, P0), + "{label} is on the loop controller's battlefield in the reported capture" + ); + } + + drive_one_live_cycle(&mut state, &CAPTURE_IDS); + + // (2) OFFER REACH-GUARD (holds both ways; gates 3 and 4). This assertion MUST sit here, between + // the live drive and the accept: `drive_all_accept_as_offered` CONSUMES the offer, and its own + // first statement panics on any non-offer beat, so placed after the accept this guard would be + // dead code and its failure mode unreadable. + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: the CR 732.2a ∞-charge offer surfaced for the loop's controller, got {:?}", + state.waiting_for + ); + + // Declare the offer's OWN stated count and accept in APNAP order — the exact dispatch the modal + // makes. Returns the ceiling that same offer published. + let ceiling = drive_all_accept_as_offered(&mut state); + + // (3) NON-VACUITY FLOOR (holds both ways): a published ceiling of 1 could not tell a capped + // boundary apart from an honest one. + assert!( + ceiling > 1, + "the offer publishes a ceiling above 1, so a capped boundary is a real narrowing" + ); + + drive_to_collapse_boundary(&mut state); + + // (4) THE FLIPPING ASSERTION. CR 732.2c binds the accepted count, and CR 500.5's collapse prompt + // is capped by it — so the range the controller is offered must reach the ceiling the offer + // itself published. Pre-fix this reads a max of 1 against a ceiling of 1000. + let WaitingFor::PayAmountChoice { + player, + resource: PayableResource::LoopCollapse { .. }, + min, + max, + .. + } = &state.waiting_for + else { + panic!( + "the boundary drive must end at the deferred-collapse prompt it exists to reach, \ + got {:?}", + state.waiting_for + ); + }; + assert_eq!( + *player, P0, + "the loop's controller is the seat asked to name the collapse count" + ); + assert_eq!( + *min, 0, + "CR 732.2b: declining to shorten at every place makes every prefix consented to" + ); + assert_eq!( + *max, ceiling, + "CR 732.2c: the collapse prompt offers the very ceiling the accepted offer published" + ); +} + +/// CR 732.2a + CR 732.2c: the offer has TWO live declare authorities, and they must state the same +/// count. `LoopShortcutModal` echoes `schema.iteration_count` verbatim; the interaction wire echoes +/// it through the published `suggested`, and `AcceptSuggested` turns that `suggested` into the +/// declared `IterationCount`. If they disagree, a client on the wire binds a different CR 732.2c +/// count than the React client binds for the SAME offer. +/// +/// Driven from the REPORTED capture through the real producer — no hand-built schema anywhere, which +/// is exactly what the two `interaction_contract` rows this replaces could not offer. +/// +/// TWO assertions, with DIFFERENT jobs — do not read them as two revert-failing conjuncts. +/// The first is the revert-failing one: pre-fix the published pair reads a suggestion of 1 against a +/// max of 1000, it fails, and because a failing assertion panics, the second never evaluates on that +/// arm. The second is a MUTATION GUARD on the arm that maps the published suggestion to the declared +/// count: post-fix both are green, and forcing that arm to declare a bare 1 reds this row and only +/// this row. Without the second assertion that mutation leaves the row green, which would move the +/// coverage gap by one line instead of closing it. +#[test] +fn kilo_reported_capture_interaction_picker_suggests_the_full_ceiling() { + let mut state = load_reported_capture(); + drive_one_live_cycle(&mut state, &CAPTURE_IDS); + + // Reach-guard (holds both ways): the live offer is what we are about to project. + let WaitingFor::LoopShortcut { + proposer, schema, .. + } = &state.waiting_for + else { + // Wording is deliberately unlike every other abort message in this file — the regression + // triage procedure routes on message text, so two sites must never print a near-match. + panic!( + "the interaction-picker row needs the offer still live at this beat, got {:?}", + state.waiting_for + ); + }; + assert_eq!(*proposer, P0, "the loop's controller proposes the shortcut"); + let ceiling = schema.max_iterations; + // Non-vacuity floor (holds both ways): a ceiling of 1 could not discriminate. + assert!(ceiling > 1, "the offer publishes a ceiling above 1"); + + // Probe on a CLONE. `bind_interaction_authority` takes `&mut GameState`, and nothing in this + // row may perturb a drive; cloning makes the whole projection provably inert. + let mut probe = state.clone(); + bind_interaction_authority( + &mut probe, + InteractionSessionId("wb7048-ceiling".to_string()), + ) + .expect("bind the interaction authority over the live offer"); + let filtered = filter_state_for_viewer(&probe, P0); + let view = derive_viewer_interaction(&probe, &filtered, P0); + let opportunity = view + .opportunities + .first() + .expect("the live offer publishes an interaction opportunity"); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { count, points, .. }, + .. + } = &opportunity.response + else { + panic!( + "the live offer publishes a Shortcut response schema, got {:?}", + opportunity.response + ); + }; + let InteractionShortcutCountSpec::Fixed { suggested, max, .. } = count else { + panic!("an Advantage offer publishes a Fixed count spec, got {count:?}"); + }; + + // ASSERTION 1 — THE REVERT-FAILING ONE (hops 1-3): the producer's seed survives the clamp, at + // the offer's own bound. Pre-fix this reads a suggestion of 1 against a max of 1000, fails, and + // panics — so assertion 2 below does not evaluate on the pre-fix arm. + assert_eq!( + (*suggested, *max), + (ceiling, ceiling), + "CR 732.2a: the picker suggests the very ceiling this offer publishes" + ); + + // ASSERTION 2 — THE MUTATION GUARD (hop 4): `AcceptSuggested` declares that suggestion. It is + // green on BOTH arms of the seed fix; what it catches is a change to the arm that maps the + // published suggestion onto the declared count, which assertion 1 cannot see at all. Pins are + // derived from the PUBLISHED points, never by index — one pin per non-read-only point, holding + // exactly that point's `min` choices, which is what the materializer validates. + let pins: Vec = points + .iter() + .filter(|point| !point.read_only) + .map(|point| InteractionShortcutPin { + group: point.group, + choice_ids: point + .candidate_ids + .iter() + .take(point.min as usize) + .cloned() + .collect(), + }) + .collect(); + let action = resolve_interaction_response( + &probe, + P0, + &InteractionSubmission { + interaction_id: opportunity.interaction_id.clone(), + response: InteractionResponse::Shortcut { + decision: InteractionShortcutDecision::AcceptSuggested, + pins, + }, + }, + ) + .expect("AcceptSuggested materializes a declare against the live offer"); + let GameAction::DeclareShortcut { + count: declared, .. + } = &action + else { + panic!("AcceptSuggested materializes a DeclareShortcut, got {action:?}"); + }; + assert_eq!( + *declared, + IterationCount::Fixed(ceiling), + "CR 732.2c: the wire declare binds the same count the React echo binds" + ); +} diff --git a/crates/engine/tests/integration/loop_counter_growth.rs b/crates/engine/tests/integration/loop_counter_growth.rs index bbd31670f8..4ae8ef2f55 100644 --- a/crates/engine/tests/integration/loop_counter_growth.rs +++ b/crates/engine/tests/integration/loop_counter_growth.rs @@ -17,7 +17,9 @@ //! prompt. use engine::analysis::resource::{CounterClass, ResourceAxis}; +use engine::game::derived_views::{CounterMagnitude, CounterRowView, ObjectCounterDisplay}; use engine::game::scenario::{GameRunner, GameScenario}; +use engine::types::ability::AbilityKind; use engine::types::actions::GameAction; use engine::types::counter::CounterType; use engine::types::events::GameEvent; @@ -207,3 +209,640 @@ fn live_charge_growth_off_never_marks() { "reach-guard: the cascade must still run under Off (charge grew); got {charge}" ); } + +/// A FREE, voluntarily-repeatable activation that creates a token AND grows a `+1/+1` counter. +/// +/// BOTH CLAUSES ARE LOAD-BEARING, and the token one is not decoration. `apply_action`'s +/// `ActivateAbility` arm bootstraps `last_loop_action_sequence` ONLY when the activated ability +/// `creates_token` (or when a period for the same controller is already open); any other +/// activation CLEARS it. Mana activations arm it through the separate +/// `record_mana_loop_action_step` path. So a counter-only activation can never open a period, and +/// the CR 732.2a offer — which requires a non-empty sequence — is unreachable without a carrier. +/// The `+1/+1` growth therefore rides a token-creating activation, which is also a realistic +/// shape: a token engine whose creature grows as it works. +const PLUS1_TOKEN_ENGINE: &str = + "{0}: Create a 1/1 colorless Servo artifact creature token. Put a +1/+1 counter on this creature."; + +fn plus1_of(runner: &GameRunner, id: ObjectId) -> u32 { + runner + .state() + .objects + .get(&id) + .and_then(|o| o.counters.get(&CounterType::Plus1Plus1)) + .copied() + .unwrap_or(0) +} + +/// Drives the `PLUS1_TOKEN_ENGINE` rider to a DECLARED, not-yet-accepted CR 732.2a offer. +/// +/// A pure extraction of what was this file's single `+1/+1` fixture: setup, the activation +/// drive, both reach-guards, and `DeclareShortcut`. Two tests share it so the matched pair +/// below differs in exactly ONE line (the counter clear) — one authority for a 100-line +/// drive, because two copies drift. +/// +/// THE WINDOW THIS RETURNS IN IS LOAD-BEARING. CR 732.2b: the declaration fans the offer out +/// to each other player in APNAP order, and CR 732.2c: the shortcut is taken only once the +/// LAST of them accepts. So at the returned instant the shortcut is DECLARED and OFFERED and +/// nothing has been materialized — which is the only window in which a board edit still +/// reaches the accept-time re-derivation. The `RespondToShortcut` assert pins that: with a +/// single living opponent the fan-out lands here, but a future one-opponent-less shape +/// (a conceded seat, a solo proposer) would take CR 732.2c's "nobody else to poll" branch +/// and materialize AT declaration — silently degrading the cleared test below from a `0 -> 1` +/// test into an `N -> N+1` test that still passes. The assert makes that loud. +fn drive_plus1_token_engine_to_declared_offer() -> (GameRunner, ObjectId) { + use engine::analysis::decision_template::IterationCount; + + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(PlayerId(1), 20); + let rider = scenario + .add_creature_from_oracle(P0, "Test Plus One Token Engine", 2, 2, PLUS1_TOKEN_ENGINE) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + + // THE DRIVING SHAPE — two constraints, both MEASURED by building the fixture that violates + // them and watching it fail, not inferred from the code: + // + // 1. It must be an ACTIVATION, not a trigger cascade. `try_offer_object_growth_shortcut` + // requires a non-empty `last_loop_action_sequence` whose every step is + // `is_voluntarily_repeatable()` (CR 601.2a / CR 602.2 / CR 605.3a — casting, activating, and + // mana abilities are each a voluntary choice at priority; the helper's own annotation names + // all three). A trigger cascade drives itself and records no + // action sequence, so it reaches only the Path-C silent mark — which registers no backing + // set at all. The cascade version of this fixture grew its counters and then sat at + // `Priority` with no offer. + // 2. The activation must CREATE A TOKEN. `apply_action`'s `ActivateAbility` arm opens a period + // only for a token-creating ability (or continues one already open for this controller); + // every other activation CLEARS the sequence. A `{0}: Put a +1/+1 counter on this creature.` + // version therefore also sat at `Priority` — each activation wiped the very sequence the + // offer needs. Mana activations arm it by a different path entirely + // (`record_mana_loop_action_step`). + // + // So the reachable production shape for a `+1/+1` ∞ display registration is a counter growth + // riding a token-creating or mana-producing carrier. That is a real constraint on the class, + // worth stating: it is why no such fixture existed to reuse. + let ability_index = runner + .state() + .objects + .get(&rider) + .and_then(|o| { + o.abilities + .iter() + .position(|def| def.kind == AbilityKind::Activated) + }) + .expect("the {0} activated ability parsed onto the rider"); + + let mut offered = false; + let mut activations = 0usize; + let mut halt = String::from("ran to the iteration cap"); + for _ in 0..40 { + if matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }) { + offered = true; + break; + } + match runner.act(GameAction::ActivateAbility { + source_id: rider, + ability_index, + }) { + Ok(_) => activations += 1, + Err(e) => { + halt = format!( + "activation #{} refused: {e:?} (waiting_for {:?})", + activations + 1, + runner.state().waiting_for + ); + break; + } + } + // Settle the activation off the stack; stop early if the offer surfaces mid-settle. + for _ in 0..60 { + match &runner.state().waiting_for { + WaitingFor::LoopShortcut { .. } => break, + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + _ => {} + } + if let Err(e) = runner.act(GameAction::PassPriority) { + halt = format!( + "settle after activation #{activations} stalled: {e:?} (waiting_for {:?})", + runner.state().waiting_for + ); + break; + } + } + } + offered |= matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }); + + // (1) REACH-GUARD: the engine really executed and really grew a `+1/+1` counter, so an empty + // target set below means "the registration missed the class" and not "no loop happened". + // + // THRESHOLD IS ONE, deliberately, and not the `>= 2` the charge cascades above use. Those + // fixtures need the BOARD to iterate because they are witnessing a Path-C mark that only + // recurrence can produce. This one witnesses an OFFER, and the offer fires as soon as a single + // period is recorded and the clone-drive confirms it recurs — the real board never iterates + // twice. Measured: with `>= 2` this guard failed at `got 1 counters after 1 activation(s)` + // while the offer had already surfaced, i.e. the guard was rejecting a working fixture. + // The halt reason rides along because a stalled driver and a broken registration otherwise + // fail identically. + let grown = plus1_of(&runner, rider); + assert!( + grown >= 1, + "reach-guard: the +1/+1 engine must actually run; got {grown} counters after \ + {activations} activation(s) — {halt}" + ); + + // (2) REACH-GUARD: a real offer surfaced, so the accept below drives production's + // `materialize_object_growth_shortcut` rather than a grafted stash. + assert!( + offered, + "reach-guard: the +1/+1 growth loop must raise a natural CR 732.2a offer, got {:?}", + runner.state().waiting_for + ); + + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: None, + }) + .expect("P0 (proposer) declares the +1/+1 growth shortcut"); + + // (RG-0) The offer must be PENDING on a living opponent's response, not already taken. + // See this helper's doc: this is what pins "materialization happens at accept, not at + // declaration" (CR 732.2b fan-out reached, CR 732.2c completion not yet reached). + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ), + "the declared shortcut must be pending a living opponent's response (CR 732.2b), so \ + nothing is materialized yet (CR 732.2c); got {:?}", + runner.state().waiting_for + ); + + (runner, rider) +} + +/// THE `∞` DISPLAY CHANNEL REGISTERS A `+1/+1` GROWTH (CR 122.1 + CR 732.2a). +/// +/// WHY THIS FIXTURE HAD TO BE BUILT rather than reused: a census of every test touching +/// `unbounded_counter_targets` found that all of them grow `charge` — a `Generic` counter, which +/// registers IDENTICALLY under the old cover partition and the current beneficial one. So the +/// whole suite passed byte-for-byte with or without the display/collapse consolidation, and no +/// existing test could distinguish the change from its absence. +/// +/// REACHABILITY, measured rather than assumed — a `+1/+1` loop is detected by a DIFFERENT +/// disjunct than a charge loop, and it matters: `CounterType::Plus1Plus1 +/// ::is_monotone_loop_resource()` is `true`, so `project_out_resources` strips it and the frames +/// read EQUAL under `loop_states_equal_modulo_resources`. (A charge loop cannot do that — +/// `Generic` is preserved, which is exactly why `loop_states_cover_modulo_counter_growth` exists.) +/// So this loop arrives through the base equality disjunct, is offered, and its `+1/+1` growth is +/// materialized at the boundary by `counter_is_beneficial_materializable` — while the DISPLAY +/// registration, when it was partitioned by the `Generic`-only ω-cover rule, saw nothing. That +/// gap is the defect: a real loop whose collapse lands and whose pills never render `∞`. +/// +/// THE REVERT-PROBE (the evidence, run and recorded): restore the display registration to the +/// `Generic`-only derivation — i.e. re-point it at a `grown_generic_counter_targets`-shaped +/// filter instead of projecting `growths` — and assertion (3) flips to an EMPTY target set. Every +/// other assertion here holds under that revert, which is what makes (3) the discriminator rather +/// than a bystander. +/// +/// DIVISION OF LABOUR, stated so neither half is overread: this fixture is DERIVED state (a +/// scenario-built loop), so it proves the registration covers the `+1/+1` class end-to-end +/// through a real offer and accept. It does NOT carry production-dump provenance; that burden is +/// `kilo_live_offer_from_real_dump`'s, on a real 4p dump. +#[test] +fn plus_one_counter_growth_registers_its_infinity_display_target() { + use engine::analysis::loop_check::ShortcutResponse; + use engine::game::derived_views::derive_views; + + let (mut runner, rider) = drive_plus1_token_engine_to_declared_offer(); + + while matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ) { + runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("the opponent accepts"); + } + + // (3) THE ASSERTION — the discriminator. The accept registered the `+1/+1` pair as an `∞` + // DISPLAY target. Under the `Generic`-only registration this set is EMPTY. + let targets = runner + .state() + .unbounded_counter_targets + .get(&P0) + .cloned() + .unwrap_or_default(); + assert!( + targets.contains(&(rider, CounterType::Plus1Plus1)), + "(3) the accept must register the +1/+1 pair as an ∞ display target — this is the \ + assertion the Generic-only display partition failed; got {targets:?}" + ); + + // (4) …and it reaches the WIRE as a pill, which is the user-visible half of (3). Asserted + // separately because (3) could hold while the projection filtered it back out. + // + // (A-3) THE MATCHED POSITIVE for the cleared sibling below. The row carries this object's + // LIVE count, and the helper's `grown >= 1` reach-guard makes that count NONZERO here — so a + // projector that hardcoded `count: 0` (the way to make the sibling's A-1 pass vacuously) reds + // exactly here. The pair is across two tests because a SelfRef-only pump + // ("…on this creature") registers exactly one object, so a zero-count row and a nonzero-count + // row cannot coexist in one frame. + let live = plus1_of(&runner, rider); + let views = derive_views(runner.state(), None); + assert!( + live >= 1, + "(A-3) reach-guard: the un-cleared rider must carry counters, or the count assertion \ + below is vacuous; got {live}" + ); + assert_eq!( + views.counter_display.get(&rider), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: CounterType::Plus1Plus1, + count: live, + magnitude: CounterMagnitude::Unbounded, + }], + loyalty: None, + }), + "(4)/(A-3) the +1/+1 ∞ pill must reach the wire carrying the rider's LIVE count \ + ({live}), got {:?}", + views.counter_display + ); +} + +/// THE `0 -> 1` HALF OF THE PAIR — a registered pair the live object carries NONE of still +/// renders (CR 122.1 + CR 732.2a). +/// +/// THE DEFECT THIS PINS. `unbounded_counter_targets` is derived by diffing a SIMULATED one-period +/// frame against a clone of the LIVE state (`game::engine::drive_one_period_frames` feeding +/// `analysis::resource::grown_beneficial_counter_deltas`, which admits a pair on `a > b` with +/// `b = counters.get(ct).unwrap_or(0)`). So a counter growing `0 -> 1` across that period is +/// registered while the live bearer carries none of it. While the channel published bare counter +/// TYPES, the display could only draw such a mark by finding a matching row in the object's own +/// `counters` map — there was none — so a real, accepted, registered `∞` rendered NOWHERE. That +/// is the subsystem's own polarity violated: it may leave an `∞` standing one boundary too long, +/// never hide a real one. +/// +/// HOW THE `0` IS REACHED, and what is production vs. harness — stated rather than blurred. +/// The MATERIALIZATION is entirely production: a real offer, a real `DeclareShortcut`, a real +/// `RespondToShortcut::Accept` driving `materialize_object_growth_shortcut` -> +/// `current_period_counter_growth` -> `register_unbounded_counter_targets`. The PRECONDITION — +/// the bearer sitting at zero — is harness-injected, and deliberately so: this pump is +/// `PutCounter{this creature}`, so the only object it can ever reach is the rider itself, and the +/// rider was necessarily present for the period that recorded it. The state is nonetheless a real +/// one the engine can reach (`AbilityCost::RemoveCounter` and CR 122.3's `+1/+1`/`-1/-1` +/// annihilation both decrease a battlefield permanent's counters), and it is legal on its own +/// terms: a 2/2 base creature with no `+1/+1` counters is a 2/2, so no state-based action fires. +/// It is injected as a faithful stand-in for that class, not smuggled in as production shape. +/// +/// WHY THE CLEAR CANNOT BE UNDONE BY THE ACCEPT. The accept consumes the proposal already latched +/// in `WaitingFor::RespondToShortcut` and re-derives against LIVE state, which is the point: with +/// the rider cleared, `before` has no `Plus1Plus1` entry and `after` has one, so the `a > b` +/// admission is reached by production code. And `materialize_object_growth_shortcut` only STASHES +/// the concrete finite growth (`register_pending_materialization`) — it is applied at the next +/// phase/step boundary — so the rider's live count is still `0` at `derive_views`. RG-1 and the +/// post-accept re-check below assert both halves rather than assuming them. +#[test] +fn plus_one_counter_growth_registers_a_target_the_bearer_does_not_yet_carry() { + use engine::analysis::loop_check::ShortcutResponse; + use engine::game::derived_views::{derive_views, ClientGameStateRef}; + + let (mut runner, rider) = drive_plus1_token_engine_to_declared_offer(); + + // THE ONE LINE that differs from the sibling above. Placed AFTER `DeclareShortcut` and BEFORE + // the first Accept — the only window that matters, because CR 732.2b's fan-out has happened + // (so declaration-time handling already saw an unmutated board) while CR 732.2c's completion + // has not (so nothing is materialized yet). The helper's RG-0 assert pins that window. + runner + .state_mut() + .objects + .get_mut(&rider) + .expect("the rider is still on the battlefield at the accept beat") + .counters + .remove(&CounterType::Plus1Plus1); + + // (RG-1) MANDATORY PRECONDITION — the only thing separating `0 -> 1` from `N -> N+1`. Nothing + // else in this test reds if it is removed, which is exactly why it is asserted, not assumed. + // NEVER weaken it to make the test pass: RG-1 *is* the proof the `0 -> 1` path was driven. + assert_eq!( + plus1_of(&runner, rider), + 0, + "RG-1: the bearer must carry ZERO +1/+1 counters at the accept beat, or this fixture is \ + an N -> N+1 test wearing a 0 -> 1 label" + ); + + while matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ) { + runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("the opponent accepts"); + } + + // (RG-3) The registration happened. Separates "registration missed the 0 -> 1 pair" from + // "the projection dropped it" — without this, a red A-1 has two explanations. + let targets = runner + .state() + .unbounded_counter_targets + .get(&P0) + .cloned() + .unwrap_or_default(); + assert!( + targets.contains(&(rider, CounterType::Plus1Plus1)), + "RG-3: the accept must register the +1/+1 pair even though the bearer carries none of it \ + — this is production's `a > b` admission with `b == 0`; got {targets:?} (waiting_for \ + {:?})", + runner.state().waiting_for + ); + + // Premise re-check: the stashed growth is applied at the next phase/step boundary, so the + // bearer is STILL at zero here. If a boundary had already run, A-1 would fail with a nonzero + // count and this names the reason. + assert_eq!( + plus1_of(&runner, rider), + 0, + "the accepted growth must still be STASHED, not applied, at the derived-view seam" + ); + + // (A-1) THE DISCRIMINATOR. The row exists and carries `count: 0`. Under the old bare-type + // shape this row was unrenderable; under a projector that filtered on + // `objects[id].counters.contains_key(ct)` it would be absent entirely. + let views = derive_views(runner.state(), None); + assert_eq!( + views.counter_display.get(&rider), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: CounterType::Plus1Plus1, + count: 0, + magnitude: CounterMagnitude::Unbounded, + }], + loyalty: None, + }), + "(A-1) a registered pair the bearer carries NONE of must still publish a renderable row \ + with `count: 0`, got {:?}", + views.counter_display + ); + + // (A-2) …and it survives to the real adapter-visible envelope, not just the in-process view. + let envelope = serde_json::to_value(ClientGameStateRef::wrap(runner.state(), None)) + .expect("the client envelope serializes"); + let rows = envelope + .get("derived") + .and_then(|d| d.get("counter_display")) + .and_then(|c| c.get(rider.0.to_string())) + .unwrap_or_else(|| panic!("(A-2) no wire rows for the bearer; envelope={envelope}")); + // The `"P1P1"` key is the serde authority's spelling (`CounterType::as_str`), written as a + // literal deliberately: this is the adapter-visible contract the TS mirror matches against, + // so deriving it from the enum here would make the assertion agree with itself. + assert_eq!( + rows, + &serde_json::json!({ + "pills": [{ "counter": "P1P1", "count": 0, "magnitude": "Unbounded" }] + }), + "(A-2) the wire row the frontend actually reads must carry `count: 0`" + ); + + // (A-4) RULES STATE STAYED CLEAN. The display row must NOT have been bought by writing a + // `{plus1plus1: 0}` entry into the object — `GameObject::counters` sits inside `PartialEq` + // and these envelopes round-trip into the `.json` dumps engine tests reload, so a phantom + // zero entry would corrupt CR 104.4b / CR 732.2a loop equality: the very subsystem this fix + // repairs. This is what pins DISPLAY-only. + let wire_counters = envelope + .get("state") + .and_then(|s| s.get("objects")) + .and_then(|o| o.get(rider.0.to_string())) + .and_then(|o| o.get("counters")) + .expect("(A-4) the bearer is on the serialized board"); + assert!( + wire_counters.get("P1P1").is_none(), + "(A-4) the display row must not materialize into rules state, got {wire_counters}" + ); +} + +/// CROSS-SEAT DEDUPE — one `(object, counter)` pair registered by TWO seats projects ONE row, +/// and a DISTINCT pair on the same object survives that collapse. +/// +/// THE DEFECT THIS PINS. `GameState::unbounded_counter_targets` is +/// `BTreeMap>`: the `BTreeSet` dedupes WITHIN a seat +/// and nothing dedupes ACROSS seats. The projector iterated `.values()` and pushed every pair +/// unconditionally, so two controllers whose accepted loops pump the same pair emitted the pair +/// TWICE. Both rows are byte-identical — the count is read from `state.objects` keyed only by +/// `(id, ct)`, with no seat input — so this is a duplicate, never a "whose count wins" question. +/// Downstream, all five render sites (`board/PermanentCard`, `card/ArtCropCard`, +/// `card/CardPreview`, `controls/AttackTargetPicker`, `hud/DialogAttachmentCard`) key their pill +/// on the counter TYPE alone, so a duplicate row is two React +/// children sharing one key: undefined reconciliation plus a dev warning. The frontend cannot fix +/// this — deduping engine-published game state in the display layer is exactly what this codebase +/// forbids — so the collapse belongs here, at the seam that owns the row set. +/// +/// WHY A DIRECTLY-CONSTRUCTED STATE IS HONEST HERE. Reachability is STRUCTURAL, not scenario- +/// dependent: `register_unbounded_counter_targets` is the store's single write authority and it +/// keys strictly by the winning controller (`game::engine::materialize_object_growth_shortcut` +/// passes `proposal.proposer`), so "two seats hold the same pair" is reached by two accepted +/// proposals in either order and carries no per-seat state beyond the key. Driving two full +/// concurrent loop accepts would exercise the offer machinery, not this projection. The +/// registrations below therefore go through that same production write authority; only the +/// scheduling around them is harness-built. +/// +/// THE NEGATIVE CONTROL IS IN THIS FIXTURE, NOT A SIBLING. A dedupe is only half-tested by a +/// state where every seat holds the SAME pair: that pins "collapses enough" while leaving +/// "collapses too much" free, so narrowing the set key to `ObjectId` alone would red nothing. +/// One seat here therefore also holds a DISTINCT pair on the SAME object, which must survive +/// alongside the collapsed one. Both directions are live against one state: dropping the dedupe +/// yields three rows, narrowing the key to the object yields one, and the assertion below names +/// exactly two. It also pins the merged multi-seat ORDERING — rows arrive sorted by +/// `(ObjectId, CounterType)`, which nothing else asserts beyond a single seat. +#[test] +fn two_seats_collapse_the_shared_pair_and_keep_the_distinct_one() { + use engine::game::derived_views::derive_views; + use engine::game::zones::create_object; + use engine::types::identifiers::CardId; + use engine::types::zones::Zone; + + const P1: PlayerId = PlayerId(1); + + let mut state = GameState::new_two_player(42); + let bearer = create_object( + &mut state, + CardId(1), + P0, + "Shared Bearer".to_string(), + Zone::Battlefield, + ); + // NONZERO and DISTINCT on purpose: the counts make each surviving row discriminating, so a + // "dedupe" that dropped rows and re-invented one from thin air cannot pass, and a collapse + // that kept the wrong one of the two pairs cannot pass either. + let charge = CounterType::Generic("charge".to_string()); + let bearer_counters = &mut state + .objects + .get_mut(&bearer) + .expect("the bearer is on the board") + .counters; + bearer_counters.insert(CounterType::Plus1Plus1, 3); + bearer_counters.insert(charge.clone(), 7); + + // Both seats register the SAME pair, through the store's real single write authority; one of + // them also registers a DISTINCT pair on the SAME object (the over-collapse control). + state.register_unbounded_counter_targets(P0, vec![(bearer, CounterType::Plus1Plus1)]); + state.register_unbounded_counter_targets( + P1, + vec![(bearer, CounterType::Plus1Plus1), (bearer, charge.clone())], + ); + + // REACH-GUARD (the positive control). Without this, a registration that silently dropped the + // second seat would make the collapse assertion below pass vacuously — the shared pair would + // be projecting one row from one entry, which was never in doubt. + let seats: Vec = state + .unbounded_counter_targets + .iter() + .filter(|(_, pairs)| pairs.contains(&(bearer, CounterType::Plus1Plus1))) + .map(|(seat, _)| *seat) + .collect(); + assert_eq!( + seats, + vec![P0, P1], + "reach-guard: BOTH seats must really hold the pair, or the dedupe below is untested" + ); + // REACH-GUARD (the over-collapse control's own positive control). If the distinct pair never + // landed in the store, "it survives the collapse" below would be asserting nothing. + assert!( + state.unbounded_counter_targets[&P1].contains(&(bearer, charge.clone())), + "reach-guard: the distinct pair must really be stored, or the over-collapse control is \ + vacuous" + ); + + // THE ASSERTION. Exactly two rows: the shared pair collapsed to ONE (not two identical rows + // sharing a React key), the distinct pair NOT collapsed away with it, both sorted by + // `(ObjectId, CounterType)` — `Plus1Plus1` is declared before `Generic`, so it comes first. + let views = derive_views(&state, None); + assert_eq!( + views.counter_display.get(&bearer), + Some(&ObjectCounterDisplay { + pills: vec![ + CounterRowView { + counter: CounterType::Plus1Plus1, + count: 3, + magnitude: CounterMagnitude::Unbounded, + }, + CounterRowView { + counter: charge.clone(), + count: 7, + magnitude: CounterMagnitude::Unbounded, + }, + ], + loyalty: None, + }), + "the (object, counter) pair held by two seats must project ONE row carrying the live \ + count (duplicates collide on the counter-type React key at every render site), while a \ + DISTINCT pair on the same object must survive that collapse in `(ObjectId, CounterType)` \ + order. Got {:?}", + views.counter_display + ); +} + +/// CR 122.2 + CR 110.1 — THE `∞` ROW DIES WITH ITS BEARER WHILE THE STORE DOES NOT. +/// +/// The widened projection is not battlefield-gated for FINITE rows (see +/// `derived_views`' `counter_rows_survive_a_bearer_that_keeps_its_counters_off_the_battlefield`), +/// so the question this fixture answers is the OTHER half: an ordinary bearer's counters cease to +/// exist when it changes zones (CR 122.2) and it stops being a permanent (CR 110.1), so NEITHER +/// magnitude may publish a row — even though the `∞` store still names the pair. +/// +/// THE SPECIFIC REGRESSION THIS PINS. The counter pass must NOT gain an +/// `!accepted_axes.contains_key(..)` KEEP conjunct copied from the axis-row loop: that would make +/// the accepted-collapse SCHEDULE decide a row's EXISTENCE, which the mirror invariant in +/// `derive_views` forbids. Arm 3 reds if it does. +/// +/// Arm ORDER matters. Arm 1 asserts a POPULATED row in the same run, so arm 3's emptiness is a +/// measured transition rather than a fixture that never had rows. Arm 2 separates "the projection +/// gated it" from "the store was wiped" — without it arm 3 has two explanations and proves +/// neither. Arm 4 separates "the `∞` gate fired" from "the finite pass would have emitted a row +/// and something else suppressed it": it proves `zones::move_to_zone`, the single authority, did +/// the clearing and the projection merely declined to invent rows. +#[test] +fn unbounded_counter_row_dies_with_its_bearer_but_the_store_does_not() { + use engine::analysis::loop_check::ShortcutResponse; + use engine::game::derived_views::derive_views; + use engine::game::zones::move_to_zone; + use engine::types::zones::Zone; + + let (mut runner, rider) = drive_plus1_token_engine_to_declared_offer(); + + while matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ) { + runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("the opponent accepts"); + } + + // (1) POSITIVE CONTROL — matched, and FIRST so a regression on the negative cannot skip it. + let live = plus1_of(&runner, rider); + assert!( + live >= 1, + "(1) reach-guard: the bearer must carry counters here, or the populated row below is \ + vacuous; got {live}" + ); + assert_eq!( + derive_views(runner.state(), None) + .counter_display + .get(&rider), + Some(&ObjectCounterDisplay { + pills: vec![CounterRowView { + counter: CounterType::Plus1Plus1, + count: live, + magnitude: CounterMagnitude::Unbounded, + }], + loyalty: None, + }), + "(1) the accepted pair really publishes an ∞ row before the departure" + ); + + // (2) REACH-GUARD / THE DISCRIMINATOR: the departure happens through the production + // chokepoint, and the STORE keeps the pair — only the projection filters. + let mut events: Vec = Vec::new(); + move_to_zone(runner.state_mut(), rider, Zone::Graveyard, &mut events); + assert!( + !runner.state().battlefield.contains(&rider), + "(2) reach-guard: the departure really happened" + ); + assert!( + runner + .state() + .unbounded_counter_targets + .get(&P0) + .is_some_and(|pairs| pairs.contains(&(rider, CounterType::Plus1Plus1))), + "(2) the STORE must still hold the departed pair — the CR 500.5 boundary collapse reads \ + it — so arm 3 can only be explained by the projection's gate, got {:?}", + runner.state().unbounded_counter_targets + ); + + // (3) THE ANSWER. + assert_eq!( + derive_views(runner.state(), None) + .counter_display + .get(&rider), + None, + "(3) the bearer is no longer a permanent and its counters ceased to exist, so no row of \ + either magnitude may be published" + ); + + // (4) THE POLARITY GUARD. + assert!( + runner.state().objects[&rider].counters.is_empty(), + "(4) `move_to_zone` — the single authority — is what cleared the counters; the \ + projection merely declined to invent rows. Got {:?}", + runner.state().objects[&rider].counters + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index bc0ee9bd3b..eec0aaeb54 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4131,7 +4131,7 @@ fn object_growth_random_recast_body_does_not_offer() { /// T1 ⭐: the object-growth (convoke-recast) offer carries exactly ONE `ConvokeTaps` /// decision-point whose `tappable` is the LIVE offer-time `is_convoke_eligible(P0)` set, and an -/// optional-loop `Fixed(1)` iteration seed. Board-derivation (hostile): the creature TAPPED to +/// optional-loop iteration seed EQUAL to the ceiling it publishes. Board-derivation (hostile): the creature TAPPED to /// pay convoke during the real cast is EXCLUDED; an untapped controlled creature is INCLUDED — a /// constant/hard-coded set could not track which creature was spent. Revert-probe: a builder /// that dropped the ConvokeTaps pin (empty points) or hard-coded the set fails these. @@ -4165,8 +4165,13 @@ fn object_growth_offer_schema_has_live_convoke_taps() { schema.points[0].kind ); }; - // Optional Advantage loop ⇒ Fixed(1) frontend count seed (not a determinate drain). - assert_eq!(schema.iteration_count, IterationCount::Fixed(1)); + // CR 732.2a + CR 732.2c: an optional Advantage loop narrows no CR 704 bound, so the offer + // STATES the same global ceiling it publishes — the frontend echoes this value verbatim and + // the accepted count caps the CR 500.5 collapse prompt, so a smaller seed would cap it too. + assert_eq!( + schema.iteration_count, + IterationCount::Fixed(schema.max_iterations) + ); // The tappable set is LIVE-derived from the offer-time board: exactly the untapped creatures // P0 controls (== is_convoke_eligible(P0)), compared as a set. @@ -7529,7 +7534,7 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { let scheduled_families: Vec = views .unbounded_families .iter() - .filter(|f| matches!(f.state, FamilyCollapseState::Scheduled(_))) + .filter(|f| matches!(f.state, FamilyCollapseState::Scheduled { .. })) .map(|f| f.family) .collect(); assert!( @@ -7611,8 +7616,10 @@ fn stale_pile_member_is_omitted_from_the_wire_but_kept_in_the_store() { 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 {}", + wire — the case is about a STALE member, not about the whole backing set dying. The \ + whole-set case is `accepted_object_growth_row_survives_losing_its_entire_pile`, which \ + asserts the row SURVIVES it, because that rig's collapse has been accepted (CR 732.2c); \ + got {}", stored.len() ); assert!( @@ -7757,7 +7764,7 @@ fn unregistered_axis_still_renders_its_infinity_badge() { assert!( v.unbounded_families .iter() - .any(|f| matches!(f.state, FamilyCollapseState::Scheduled(_))) + .any(|f| matches!(f.state, FamilyCollapseState::Scheduled { .. })) && j.contains("\"Scheduled\""), "R3/pre-clear: a registered materialization SCHEDULES a family AND emits it, got {:?}", v.unbounded_families diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index 377f407ca1..b973a188ea 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -1215,7 +1215,10 @@ fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { ); assert_eq!( state_of(UnboundedFamily::Life), - FamilyCollapseState::Scheduled(CollapseCertainty::Committed), + FamilyCollapseState::Scheduled { + certainty: CollapseCertainty::Committed, + prompted: Some(P0), + }, "R4/agree positive: the deferred life family of the SAME stash IS scheduled, so the \ mana assertion above is discriminating rather than vacuous. It is COMMITTED because \ a `DriveSequence` replays real cycles and has no non-push exit (viewer {viewer:?})"