Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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([]);
});
});
Loading
Loading