Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
6 changes: 6 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2846,6 +2846,12 @@ export interface DerivedViews {
* of every unbounded-resource loop. Empty/omitted when no loop is active. The
* FE maps each axis to a display family and never re-derives attribution.
* Mirrors `engine::game::derived_views::DerivedViews::unbounded_resources`.
*
* This channel and its two siblings below stay POPULATED after all players accept a
* shortcut, until the engine applies the growth at the next CR 500.5 boundary. Deferring
* the application across that window is an engine deviation, pre-existing and deliberate.
* What matters to the FE is only that the mark and its enablers are still live there, so
* `∞` is current engine state, not a stale mark. Render it.
*/
unbounded_resources?: UnboundedResourceView[];
/**
Expand Down
18 changes: 18 additions & 0 deletions client/src/test/fixtures/unbounded-counter-wire.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"unbounded_counters": {
"405": [
"charge"
]
},
"unbounded_resources": [
{
"axis": {
"Counter": [
"Other",
"Other"
]
},
"player": 0
}
]
}
14 changes: 14 additions & 0 deletions client/src/test/fixtures/unbounded-token-wire.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"unbounded_pile": [
402,
403,
404,
407
],
"unbounded_resources": [
{
"axis": "TokensCreated",
"player": 0
}
]
}
114 changes: 114 additions & 0 deletions client/src/viewmodel/__tests__/unboundedWireSeam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* ∞-channel cross-seam pin. Both JSON files are ENGINE-EMITTED by
* `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` and
* `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_target`,
* each driving a REAL 4-player dump through the REAL APNAP accept. Regenerate with
* `UPDATE_WIRE_GOLDEN=1 cargo test -p phase-engine --test integration <fn>`. Never hand-edit them.
* Every existing client test that touches these channels hand-writes its own `derived` block, so
* this file is the only place the engine's wire shape and the client's readers meet.
* Both goldens are captured AFTER the accept, while a finite collapse is merely SCHEDULED β€” the
* engine defers APPLYING the growth to the next CR 500.5 boundary (an engine deviation,
* pre-existing and deliberate), and the marks stay live through that window, so the ∞ channels are
* still populated. If the engine went back to hiding them there, both goldens would regenerate
* empty and every assertion below would red.
* The `unbounded_pile β†’ Set` hop is performed here rather than by `gameStateView.ts`, because
* driving that function would require committing a whole `GameState`; the ids, the field name and
* the value encoding β€” the parts that actually differ across the language boundary β€” are
* engine-authored.
*/
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import type { DerivedViews, GameObject, ObjectId, ResourceAxis } from "../../adapter/types";
import { familyOf } from "../../components/hud/HudBadges";
import { useUnboundedCounterTypes } from "../../hooks/useUnboundedCounterTypes";
import { buildGameObject } from "../../test/factories/gameObjectFactory";
import { buildGameState } from "../../test/factories/gameStateFactory";
import counterWire from "../../test/fixtures/unbounded-counter-wire.json";
import tokenWire from "../../test/fixtures/unbounded-token-wire.json";
import { setGameStoreForTest } from "../../test/helpers/gameStoreHelpers";
import { groupByName } from "../battlefieldProps";

const saproling = (id: ObjectId, tapped: boolean): GameObject =>
buildGameObject({ id, name: "Saproling", tapped, card_id: 0, controller: 0, owner: 0 });

describe("unbounded ∞ wire seam (engine-emitted goldens)", () => {
// RESIDUAL: this closes the ID/shape half only β€” the TS-side `GameObject`s are factory-built, so
// the test cannot see an engine/client group-PARTITION mismatch, and `isUnboundedPile`'s
// `members.every(...)` (`battlefieldProps.ts`) degrades such a mismatch silently to `Γ—N` rather
// than failing, which is exactly the user's symptom class.

it("emits populated ∞ channels and omits the empty ones", () => {
// (1) reach-guard: the engine emitted a populated pile, so the group assertions below are
// not run against an empty set.
expect(tokenWire.unbounded_pile).toEqual([402, 403, 404, 407]);
// (2) reach-guard + the two counter seam facts: the map key is a JSON STRING, and
// `CounterType` serializes FLAT ("charge", not {"Generic":"charge"}). A regressed Serialize
// would silently blank every ∞ pill.
expect(counterWire.unbounded_counters).toEqual({ "405": ["charge"] });
// (3) omit-when-empty, engine-attested in BOTH directions.
expect("unbounded_pile" in counterWire).toBe(false);
expect("unbounded_counters" in tokenWire).toBe(false);
Comment on lines +49 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | 🟠 Major | ⚑ Quick win

Test the omitted scheduled_collapse wire form.

These assertions prove omission only for unbounded_pile and unbounded_counters. Both new fixtures include scheduled_collapse. Add an engine-emitted empty fixture and assert that the client receives no scheduled_collapse field. This must fail if the engine emits scheduled_collapse: [] instead of omitting the optional field.

As per path instructions, omitted optional fields require full engine-to-wire-to-client coverage.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/viewmodel/__tests__/unboundedWireSeam.test.ts` around lines 44 -
46, Extend the omit-when-empty coverage in the unbounded wire seam test to
include an engine-emitted empty scheduled-collapse fixture, then assert the
client-facing wire result omits the scheduled_collapse field. Ensure the
assertion would fail if the engine serialized scheduled_collapse as an empty
array, while preserving the existing unbounded_pile and unbounded_counters
checks.

Source: Path instructions

});

it("drives the real groupByName pile predicate off engine ids", () => {
const unboundedPileIds: ReadonlySet<ObjectId> = new Set(tokenWire.unbounded_pile);
const objects: GameObject[] = [
...[402, 403, 404, 407].map((id) => saproling(id, true)),
...[406, 408, 409, 410].map((id) => saproling(id, false)),
buildGameObject({
id: 401,
name: "Witherbloom, the Balancer",
tapped: true,
card_id: 9001,
controller: 0,
owner: 0,
}),
];

const groups = groupByName(objects, new Set(), unboundedPileIds);
const groupOf = (id: ObjectId) => {
const group = groups.find((g) => g.ids.includes(id));
expect(group, `no group contains ${id}`).toBeDefined();
return group!;
};

// NEGATIVES FIRST, POSITIVE LAST β€” deliberate. A failing `expect` throws and skips the rest of
// the `it`, and the regression class this file exists to catch (the engine stops emitting the
// pile) reds the POSITIVE. Asserting the negatives first keeps them observable as the paired
// control in that same run instead of being skipped by the positive's throw.
//
// (5) paired NEGATIVE from the SAME groupByName call: same name, differs only on `tapped`.
expect(groupOf(406).ids).toEqual([406, 408, 409, 410]);
expect(groupOf(406).isUnboundedPile).toBe(false);
// (6) free third negative: tapped, but not a pile member β€” so it is not "everything tapped".
expect(groupOf(401).isUnboundedPile).toBe(false);
// (4) paired POSITIVE: the tapped Saprolings the engine named.
expect(groupOf(402).ids).toEqual([402, 403, 404, 407]);
expect(groupOf(402).isUnboundedPile).toBe(true);
});

it("decodes both externally-tagged axis shapes through the real familyOf", () => {
// (7) unit variant β€” a bare string on the wire.
expect(familyOf(tokenWire.unbounded_resources[0].axis as ResourceAxis)).toBe("tokens");
// (8) data variant β€” a single-key object on the wire.
expect(
familyOf(counterWire.unbounded_resources[0].axis as unknown as ResourceAxis),
).toBe("counters");
// (9) redundant reinforcement, kept as documentation of intent: it cannot fail unless (7) or
// (8) already has.
expect(familyOf(tokenWire.unbounded_resources[0].axis as ResourceAxis)).not.toBe(
familyOf(counterWire.unbounded_resources[0].axis as unknown as ResourceAxis),
);
});

it("feeds the real useUnboundedCounterTypes hook from the engine wire", () => {
setGameStoreForTest({
gameState: buildGameState({ derived: counterWire as unknown as DerivedViews }),
});
// (10) paired POSITIVE through the real zustand selector.
expect(renderHook(() => useUnboundedCounterTypes(405)).result.current).toEqual(["charge"]);
// (11) paired NEGATIVE: 404 is on the same battlefield and carries no ∞ mark.
expect(renderHook(() => useUnboundedCounterTypes(404)).result.current).toEqual([]);
});
});
113 changes: 47 additions & 66 deletions crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -793,53 +793,53 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
views.turn_order = turn_order;
views.viewer_turn_number = viewer_turn_number;

// CR 732.2c: once every player accepted the shortcut it IS taken, at the finite N the
// proposal named β€” so an axis with a scheduled collapse is already BOUNDED and must not
// render `∞` anywhere beside the finite totals it is growing. ONE authority
// (`GameState::scheduled_collapse_axes`), THREE consumers below: the per-axis resource
// badge rows, the ∞ object pile, and the ∞ counter pills. The gate is computed here,
// once per controller, precisely so no surface can re-derive it and drift β€” a HUD that
// hides the resource badge while a card group still shows ∞ is internally inconsistent.
// WHY THE THREE ∞ CHANNELS BELOW ARE UNCONDITIONAL β€” the acceptβ†’boundary window.
//
// Filter the PROJECTION, never the store: `unbounded_resources` +
// `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep until the CR 500.5
// boundary applies the growth, which is what keeps `zones::apply_zone_exit_cleanup`'s
// defuse armed in the meantime.
// THE WINDOW IS AN ENGINE DEVIATION, PRE-EXISTING AND DELIBERATE β€” NOT A RULES ENTITLEMENT, and
// no CR is cited as licensing it. CR 732.2c has the shortcut taken the moment the last player
// accepts, with the game advancing to the last proposed ending point; this engine instead parks
// at priority with the accepted count recorded but its results UNAPPLIED, and settles them at
// the next CR 500.5 boundary (`game::turns`, unchanged by this projection). Nothing below
// claims otherwise. What IS resolved at accept is the count itself
// (`pending_materialization_count`); what is deferred is applying it, plus `turns.rs`' `min: 0`
// under-delivery tolerance, which that file documents in its own words.
//
// FAIL-CLOSED: only axes a registered materialization really collapses are hidden, so an
// unregistered ∞ axis (a mana engine registers none) still renders.
// The two CRs this code does rely on, each for what it actually governs:
// β€’ CR 732.2c β€” the shortcut is taken at the count every player accepted, so the collapse may
// not EXCEED it. `turns.rs`' `max:` reads the recorded bound for exactly that reason and
// `SubmitPayAmount` rejects an over-collapse. That is a CEILING on the collapse; it says
// nothing about what the display may show, and this projection does not read it.
// β€’ CR 500.5 β€” the TIMING LANDMARK only: it defines the step/phase end, at which
// until-end-of-step effects expire and unspent mana empties. That mana drain is the one
// thing here CR 500.5 genuinely governs (`turns::drain_pending_phase_transition_progress`,
// and it is why a `Mana(_)` ∞ ends there). It does NOT license CASHING OUT the deferred
// token/life/counter growth at that moment β€” the engine chose that landmark, and that
// choice is part of the same uncited deviation described above.
//
// CLASS RULE for the hide-set: hide only axes whose growth is still DEFERRED; never hide an
// axis that is ALREADY MATERIALIZED and spendable right now. `Tokens` / `Counters` / `Life`
// are deferred by construction β€” the growth is not on the board until the boundary applies
// it β€” so an `∞` for them is exactly the lie this gate kills. A `DriveSequence` is the one
// item that does NOT name a deferral: its `collapsed_axes` is `proposal.unbounded`, i.e.
// EVERY axis of the whole loop, and a `Mana(_)` among them is live *now* β€”
// `mana_payment::refill_infinite_mana` tops that controller's pool back to
// `INFINITE_MANA_PER_TYPE` off the STORE (which this projection deliberately never touches)
// after every action. Hiding it would show no `∞` beside a pool that keeps refilling: the
// same internally-inconsistent HUD as an `∞ Life` badge on a finite life total, inverted.
// CR 500.5: `turns::drain_pending_phase_transition_progress` clears the mana axis when the
// step/phase ends, and THAT is what legitimately ends the badge β€” not this projection.
// WHY `∞` IS RIGHT HERE IS AN ENGINE-STATE ARGUMENT, NOT A RULES ONE. Throughout the window the
// enablers are still on the battlefield and `unbounded_resources` + `unbounded_loop_enablers`
// are deliberately held in lockstep (below), so the controller really does still hold a set of
// actions that could be repeated indefinitely β€” a CR-732.1b-SHAPED capability, which is the
// same sense the rest of this crate cites CR 732.1b in. `∞` renders that live mark honestly.
//
// `Mana(_)` is today's only already-materialized axis: census of production readers of
// `GameState::unbounded_resources` (`refill_infinite_mana`, the CR 500.5 clear in `turns`,
// and this projection) shows it is the only axis any reader turns back into a spendable
// resource. Widen this `retain` only for an axis that gains the same property.
let scheduled_collapse: BTreeMap<PlayerId, BTreeSet<ResourceAxis>> = state
.pending_unbounded_materialization
.iter()
.map(|(&controller, items)| {
let mut axes = state.scheduled_collapse_axes(items);
axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)));
(controller, axes)
})
.collect();
let collapse_scheduled = |controller: PlayerId, axis: &ResourceAxis| -> bool {
scheduled_collapse
.get(&controller)
.is_some_and(|axes| axes.contains(axis))
};
// And hiding it is strictly worse on display coherence, which is what the old "the badge is a
// lie" comment was really about. The BASE gate filtered the PROJECTION while the STORE still
// said `∞` β€” a HUD contradicting its own engine β€” and it also suppressed an already-
// materialized `Mana(_)` axis that `mana_payment::refill_infinite_mana` keeps topping back up,
// i.e. it hid a badge beside a pool the player can visibly keep spending.
//
// The three loops below therefore read only their own stores; none consults
// `GameState::scheduled_collapse_axes` (whose sole production caller is
// `clear_collapsed_materializations`). The stores are not filtered either:
// `unbounded_resources` + `unbounded_loop_enablers` are held in lockstep until the boundary
// applies the growth. That lockstep is an ENGINE-STATE invariant, required by no CR β€” it exists
// for exactly one consumer: `zones::apply_zone_exit_cleanup` reads the enabler map to defuse a
// capability whose enabler leaves, so a desynced store would leave that defuse unarmed.
//
// What ends each `∞` is the boundary, never this projection:
// `clear_collapsed_materializations` drops the collapsed axes once the growth is applied, and
// `turns::drain_pending_phase_transition_progress` clears a `Mana(_)` axis when the step or
// phase ends (CR 500.5).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// CR 732.2a: project every unbounded-resource loop into per-(player, axis)
// `∞` HUD rows. Runs in every format (placed BEFORE the Commander
Expand All @@ -848,9 +848,6 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
// (`attribution_player`); the frontend only formats each axis to a family.
for (&controller, axes) in &state.unbounded_resources {
for &axis in axes {
if collapse_scheduled(controller, &axis) {
continue;
}
views.unbounded_resources.push(UnboundedResourceView {
player: attribution_player(axis, controller),
axis,
Expand All @@ -863,14 +860,8 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
// left the battlefield (stale member). Public board state (no viewer filtering);
// the frontend renders `∞` on any group whose members are all pile members.
//
// CR 732.2c: same gate, same authority as the badge rows above. The pile IS the
// `TokensCreated` axis β€” `clear_collapsed_materializations` drops it on exactly that
// axis collapsing β€” so once that axis has a scheduled finite mint the group must stop
// rendering ∞ in lockstep with its resource badge.
for (&controller, ids) in &state.unbounded_loop_pile {
if collapse_scheduled(controller, &ResourceAxis::TokensCreated) {
continue;
}
// Unconditional while a collapse is merely scheduled β€” see the engine-deviation block above.
for ids in state.unbounded_loop_pile.values() {
for id in ids {
if state.battlefield.contains(id) {
views.unbounded_pile.push(*id);
Expand All @@ -885,22 +876,12 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
// `unbounded_pile`; the frontend renders `∞` (not `Γ—N`) on any counter pill whose
// type is in this set. Runs in every format (BEFORE the Commander short-circuit).
//
// CR 732.2c: same gate, same authority. A pill's axis is derived by the SHARED
// `(object, counter) -> ResourceAxis` mapping the collapse itself uses
// (`collapsed_counter_axis`), so a pill can never disagree with the badge it mirrors.
// The class lookup is LIVE by design here: this loop only emits pills for bearers still
// on the battlefield, so the object is present and its class is current.
for (&controller, targets) in &state.unbounded_counter_targets {
// Unconditional while a collapse is merely scheduled β€” see the engine-deviation block above.
for targets in state.unbounded_counter_targets.values() {
for (id, ct) in targets {
if !state.battlefield.contains(id) {
continue;
}
if collapse_scheduled(
controller,
&crate::types::game_state::collapsed_counter_axis(state, *id, ct),
) {
continue;
}
views
.unbounded_counters
.entry(*id)
Expand Down
Loading
Loading