Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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