test(server): add wire-parity fixtures for 7 new trigger primitive kinds

The server's EffectPrimitiveNodeWireSchema already uses kind:
z.string().min(1) by design (see comment block in protocol.ts:525-538),
so new primitive kinds do NOT require server schema changes. This
commit adds parity-test fixtures proving that holds for each of the
incoming trigger kinds.

Adds 10 positive fixtures exercising on-move, on-turn-end,
on-promotion, on-check-received, on-check-delivered, both filter
variants of on-moved-onto-square (squares list + file/rank predicate),
both target variants of on-captured ({relation:'ally'} + 'attacker'),
and a nested on-capture→on-move composition.

Plus one 51-primitive negative fixture using new kinds, confirming the
.max(50) cap applies uniformly. Squares throughout use the numeric 0..63
convention (e4 = 28, d5 = 35), matching the engine's Square type.

Documented a spec vs implementation divergence: wire schemas accept
depth-4 because params is z.unknown() — depth enforcement lives in the
client-side validator (validate.ts MAX_RECURSION_DEPTH = 3). A positive
depth-4 fixture now pins this contract explicitly.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 16:58:17 -06:00
commit 161bc0e78a
No known key found for this signature in database

View file

@ -70,6 +70,171 @@ describe("wire-shape parity: client v4 ↔ server v3 (Q4.2)", () => {
createdAt: 1700000000000,
};
// ---------------------------------------------------------------------------
// T13 — Fixtures exercising the 7 new trigger primitive kinds.
//
// Both schemas declare `kind: z.string().min(1)` + `params: z.unknown()`
// (protocol.ts:540-548, chess custom/schema.ts:34-39) — deliberately
// structural so new primitive kinds shipped from the client don't
// require mirroring the catalog on the server (see protocol.ts:525-538
// comment). These fixtures pin that contract: the wire layer stays
// permissive of all 7 new kinds while preserving agreement with
// the client-side schema.
//
// Squares are numeric 0..63 — e4 = 28, d5 = 35, a1 = 0, h8 = 63.
// ---------------------------------------------------------------------------
const addHpBonusChild = {
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 1 },
};
const validWithOnMove = {
...validMinimal,
id: "custom:parity-on-move",
name: "Parity OnMove",
primitives: [
{
kind: "on-move",
params: { primitives: [addHpBonusChild] },
},
],
};
const validWithOnTurnEnd = {
...validMinimal,
id: "custom:parity-on-turn-end",
name: "Parity OnTurnEnd",
primitives: [
{
kind: "on-turn-end",
params: { primitives: [addHpBonusChild] },
},
],
};
const validWithOnPromotion = {
...validMinimal,
id: "custom:parity-on-promotion",
name: "Parity OnPromotion",
primitives: [
{
kind: "on-promotion",
params: { primitives: [addHpBonusChild] },
},
],
};
const validWithOnCheckReceived = {
...validMinimal,
id: "custom:parity-on-check-received",
name: "Parity OnCheckReceived",
primitives: [
{
kind: "on-check-received",
params: { primitives: [addHpBonusChild] },
},
],
};
const validWithOnCheckDelivered = {
...validMinimal,
id: "custom:parity-on-check-delivered",
name: "Parity OnCheckDelivered",
primitives: [
{
kind: "on-check-delivered",
params: { primitives: [addHpBonusChild] },
},
],
};
// on-moved-onto-square: discriminated filter (decisions.md §Trigger)
// Variant A — kind:'squares'. e4 = 28, d5 = 35.
const validWithOnMovedOntoSquareSquares = {
...validMinimal,
id: "custom:parity-on-moved-onto-square-squares",
name: "Parity OnMovedOntoSquare/Squares",
primitives: [
{
kind: "on-moved-onto-square",
params: {
filter: { kind: "squares", squares: [28, 35] },
primitives: [addHpBonusChild],
},
},
],
};
// Variant B — kind:'predicate' with file narrowing (file:3 = d-file).
const validWithOnMovedOntoSquarePredicate = {
...validMinimal,
id: "custom:parity-on-moved-onto-square-predicate",
name: "Parity OnMovedOntoSquare/Predicate",
primitives: [
{
kind: "on-moved-onto-square",
params: {
filter: { kind: "predicate", file: 3 },
primitives: [addHpBonusChild],
},
},
],
};
// on-captured: target is a TargetResolver (union). Schemas see it as
// `unknown` inside params, so both shapes — string literal and object
// — must pass structurally.
const validWithOnCapturedAllyRelation = {
...validMinimal,
id: "custom:parity-on-captured-ally",
name: "Parity OnCaptured/Ally",
primitives: [
{
kind: "on-captured",
params: {
target: { relation: "ally" },
primitives: [addHpBonusChild],
},
},
],
};
const validWithOnCapturedAttacker = {
...validMinimal,
id: "custom:parity-on-captured-attacker",
name: "Parity OnCaptured/Attacker",
primitives: [
{
kind: "on-captured",
params: {
target: "attacker",
primitives: [addHpBonusChild],
},
},
],
};
// Depth-2 combo: legacy on-capture wrapping a new on-move trigger.
const validNestedNewTriggers = {
...validMinimal,
id: "custom:parity-nested-new-triggers",
name: "Parity Nested New Triggers",
primitives: [
{
kind: "on-capture",
params: {
primitives: [
{
kind: "on-move",
params: { primitives: [addHpBonusChild] },
},
],
},
},
],
};
describe("both schemas accept", () => {
it("minimal valid descriptor", () => {
const { client, server } = checkAgreement(validMinimal);
@ -96,6 +261,85 @@ describe("wire-shape parity: client v4 ↔ server v3 (Q4.2)", () => {
expect(client).toBe(true);
expect(server).toBe(true);
});
// T13 — 7 new trigger primitive kinds. Each fixture carries one
// new trigger at the top level; structural `kind: z.string().min(1)`
// contract means both schemas must accept.
const newTriggerFixtures: Array<[string, unknown]> = [
["on-move trigger", validWithOnMove],
["on-turn-end trigger", validWithOnTurnEnd],
["on-promotion trigger", validWithOnPromotion],
["on-check-received trigger", validWithOnCheckReceived],
["on-check-delivered trigger", validWithOnCheckDelivered],
[
"on-moved-onto-square trigger with {kind:'squares'} filter",
validWithOnMovedOntoSquareSquares,
],
[
"on-moved-onto-square trigger with {kind:'predicate'} filter",
validWithOnMovedOntoSquarePredicate,
],
[
"on-captured trigger with target: {relation:'ally'}",
validWithOnCapturedAllyRelation,
],
[
"on-captured trigger with target: 'attacker'",
validWithOnCapturedAttacker,
],
[
"new trigger nested inside existing trigger (on-capture > on-move)",
validNestedNewTriggers,
],
];
for (const [label, desc] of newTriggerFixtures) {
it(label, () => {
const { client, server } = checkAgreement(desc);
expect(client).toBe(true);
expect(server).toBe(true);
});
}
// T13 — Document that the wire schemas do NOT enforce nesting depth.
// `params: z.unknown()` means depth-3-plus trees pass structurally;
// the depth-3 cap is the client's `validate.ts` semantic concern
// (MAX_RECURSION_DEPTH = 3), enforced BEFORE send. Server intent
// is explicit: protocol.ts:525-538 says "Deep nesting is the
// client validator's responsibility — the server sees flat arrays
// because nested children are inside `params`." This test pins
// that contract so a future tightening of the wire schema
// (e.g. recursive `z.lazy()` validation of params) is flagged.
it("depth-4 nesting passes both schemas (schemas are structural only)", () => {
const depth4 = {
...validMinimal,
id: "custom:parity-depth4",
name: "Parity Depth4",
primitives: [
{
kind: "on-capture",
params: {
primitives: [
{
kind: "on-move",
params: {
primitives: [
{
kind: "on-turn-start",
params: { primitives: [addHpBonusChild] },
},
],
},
},
],
},
},
],
};
const { client, server } = checkAgreement(depth4);
expect(client).toBe(true);
expect(server).toBe(true);
});
});
describe("both schemas reject", () => {
@ -137,6 +381,24 @@ describe("wire-shape parity: client v4 ↔ server v3 (Q4.2)", () => {
})),
},
],
// T13 — same length cap applies uniformly to the 7 new trigger
// kinds; the wire schema distinguishes nothing about the kind
// field beyond min(1). Included to guard against any future
// per-kind carve-out regressing the flat .max(50) contract.
[
"51 on-move triggers (new-kind length cap still enforced)",
{
...validMinimal,
primitives: Array.from({ length: 51 }, () => ({
kind: "on-move",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
],
},
})),
},
],
];
for (const [label, desc] of rejectionCases) {