Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 80 additions & 16 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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<string, string[]>;
counter_display?: Record<string, ObjectCounterDisplay>;
}

/** Mirrors `engine::types::game_state::NextSpellModifier` (serde tag="type"). */
Expand Down
21 changes: 12 additions & 9 deletions client/src/components/board/PermanentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -920,6 +921,7 @@ export const PermanentCard = memo(function PermanentCard({
<LoyaltyBadge
amount={obj.loyalty}
kind="total"
isUnbounded={isUnbounded(counterDisplay.loyalty)}
size="battlefield"
className="absolute bottom-0 right-0 z-30"
style={{ position: "absolute" }}
Expand Down Expand Up @@ -965,17 +967,18 @@ export const PermanentCard = memo(function PermanentCard({
{/* Top-right overlay stack: counter badges kept clear of the
bottom-right P/T box. */}
<div className="absolute right-0.5 top-0.5 z-[60] flex flex-col items-end gap-0.5">
{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 (
<CounterTooltip
key={type}
type={type}
count={count}
isUnbounded={isUnbounded}
count={row.count}
isUnbounded={unbounded}
>
<span
className={`flex items-center gap-0.5 rounded px-1 text-[10px] font-bold text-white ${COUNTER_COLORS[type] ?? "bg-purple-600"}`}
Expand All @@ -987,7 +990,7 @@ export const PermanentCard = memo(function PermanentCard({
label={formatCounterType(type)}
/>
)}
{formatCounterType(type)} {isUnbounded ? "∞" : `x${count}`}
{formatCounterType(type)} {unbounded ? "∞" : `x${row.count}`}
</span>
</CounterTooltip>
);
Expand Down
108 changes: 104 additions & 4 deletions client/src/components/board/__tests__/PermanentCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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;
Expand Down
Loading
Loading