test(chess): add legacy descriptor backward-compat fixture + round-trip test

Proves that descriptors using ONLY the 15 pre-existing primitive kinds
continue to parse, validate, apply, and serialize byte-identically even
after Wave 2 extended the PrimitiveKind union from 15 to 22 kinds. This
is the critical backward-compat proof: old saved descriptors in every
user library must keep working indefinitely.

Fixture: packages/chess/src/modifiers/custom/__fixtures__/legacy-descriptor.json
- 15 top-level primitives, one per legacy kind (each represented once)
- 5 nested primitives inside on-turn-start / on-capture / on-damaged /
  conditional(then+else) → 20 total nodes, depth 2
- Zero Wave-2 kinds (explicit)

Test: legacy-descriptor.test.ts
- parse: parseCustomModifierDescriptor returns version=1, kind set >= 5,
  every kind in legacy set
- validate: validateCustomDescriptor returns {ok: true}
- apply: asserts expected facts on the fixture pawn after
  applyCustomDescriptor — HpBonus, DirectionAdditions, CaptureFlags,
  ReflectDamagePercent, BlockedMoveTypes, RangeBonus, PromotionOverride,
  AuraSpec, and all four hook arrays (OnTurnStartHooks, OnCaptureHooks,
  OnDamagedHooks, ConditionalHooks)
- round-trip: JSON.parse(JSON.stringify(descriptor)) deep-equals the
  raw fixture JSON

Note: applyCustomDescriptor recursively walks childPrimitives() at
apply time, so nested trigger primitives execute immediately IN
ADDITION to seeding their hook attrs. Expectations reflect this
end-to-end contract (Hp=11 not 10; HpBonus=4 not 2; etc.).

Read approach: fs.readFileSync + import.meta.url (tsconfig lacks
resolveJsonModule).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 18:02:59 -06:00
commit b2ca2cae23
No known key found for this signature in database
2 changed files with 213 additions and 0 deletions

View file

@ -0,0 +1,65 @@
{
"type": "data",
"id": "custom:legacy-backward-compat",
"name": "Legacy Backward-Compat",
"description": "Uses only the 15 pre-Wave-2 primitive kinds. Proves descriptors authored before the new trigger primitives still parse, validate, apply, and round-trip byte-equal.",
"version": 1,
"uiForm": "primitive-composer",
"source": "custom",
"targetAttrs": [
"Hp",
"HpBonus",
"RangeBonus",
"PromotionOverride",
"AuraSpec"
],
"primitives": [
{ "kind": "seed-attribute", "params": { "attr": "Hp", "value": 5 } },
{ "kind": "add-to-attribute", "params": { "attr": "HpBonus", "delta": 2 } },
{ "kind": "multiply-attribute", "params": { "attr": "Hp", "factor": 2 } },
{ "kind": "add-direction", "params": { "directions": ["forward", "backward"] } },
{ "kind": "set-capture-flag", "params": { "flag": 2 } },
{ "kind": "absorb-damage-with-attribute", "params": { "attr": "ShieldCharges", "rate": 1 } },
{ "kind": "reflect-damage", "params": { "percentage": 25 } },
{ "kind": "block-move-type", "params": { "moveType": "capture" } },
{ "kind": "modify-movement-range", "params": { "delta": 1 } },
{ "kind": "override-promotion", "params": { "target": "queen" } },
{ "kind": "add-aura", "params": { "radius": 2, "targetAttr": "HpBonus", "delta": 1 } },
{
"kind": "on-turn-start",
"params": {
"primitives": [
{ "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": 1 } }
]
}
},
{
"kind": "on-capture",
"params": {
"primitives": [
{ "kind": "add-to-attribute", "params": { "attr": "HpBonus", "delta": 1 } }
]
}
},
{
"kind": "on-damaged",
"params": {
"primitives": [
{ "kind": "reflect-damage", "params": { "percentage": 50 } }
]
}
},
{
"kind": "conditional",
"params": {
"condition": { "type": "attr-lt", "attr": "Hp", "value": 2 },
"then": [
{ "kind": "set-capture-flag", "params": { "flag": 2 } }
],
"else": [
{ "kind": "add-to-attribute", "params": { "attr": "HpBonus", "delta": 1 } }
]
}
}
]
}

View file

@ -0,0 +1,148 @@
/**
* Backward-compatibility fixture test.
*
* Pins the contract that a CustomModifierDescriptor authored using ONLY
* the 15 pre-Wave-2 primitive kinds (seed-attribute, add-to-attribute,
* multiply-attribute, add-direction, set-capture-flag,
* absorb-damage-with-attribute, reflect-damage, block-move-type,
* modify-movement-range, override-promotion, add-aura, on-turn-start,
* on-capture, on-damaged, conditional) still:
*
* 1. Parses cleanly via parseCustomModifierDescriptor (Zod schema).
* 2. Validates {ok: true} via validateCustomDescriptor (kind-in-registry,
* depth/count caps, params schemas).
* 3. Applies to a live engine session and seeds the expected WME facts
* (HpBonus numeric, OnCaptureHooks present, ConditionalHooks non-empty,
* etc.).
* 4. Round-trips byte-equal through JSON.parse(JSON.stringify(...)) no
* transform drift, no field reordering mismatch at the object level.
*
* The fixture lives in `./__fixtures__/legacy-descriptor.json` and is
* loaded via `fs.readFileSync` to guarantee the bytes on disk are exactly
* what we're testing. No `import ... with { type: 'json' }` — tsconfig
* does not enable `resolveJsonModule`.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import type { EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { applyCustomDescriptor } from "./apply.js";
import { parseCustomModifierDescriptor } from "./schema.js";
import { validateCustomDescriptor } from "./validate.js";
import "../primitives/index.js";
const FIXTURE_PATH = join(
dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
"legacy-descriptor.json",
);
const RAW_FIXTURE_TEXT = readFileSync(FIXTURE_PATH, "utf8");
const RAW_FIXTURE_JSON = JSON.parse(RAW_FIXTURE_TEXT) as unknown;
describe("legacy CustomModifierDescriptor (pre-Wave-2, 15 primitive kinds only)", () => {
it("parses via parseCustomModifierDescriptor with version 1 and no errors", () => {
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE_JSON);
expect(descriptor.version).toBe(1);
expect(descriptor.type).toBe("data");
expect(descriptor.uiForm).toBe("primitive-composer");
expect(descriptor.source).toBe("custom");
// At least 5 different kinds represented, all from the legacy set.
const kinds = new Set(descriptor.primitives.map((p) => p.kind));
expect(kinds.size).toBeGreaterThanOrEqual(5);
const LEGACY_KINDS = new Set([
"seed-attribute",
"add-to-attribute",
"multiply-attribute",
"add-direction",
"set-capture-flag",
"absorb-damage-with-attribute",
"reflect-damage",
"block-move-type",
"modify-movement-range",
"override-promotion",
"add-aura",
"on-turn-start",
"on-capture",
"on-damaged",
"conditional",
]);
for (const k of kinds) {
expect(LEGACY_KINDS.has(k)).toBe(true);
}
});
it("validateCustomDescriptor returns { ok: true }", () => {
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE_JSON);
const result = validateCustomDescriptor(descriptor);
expect(result).toEqual({ ok: true });
});
it("applies cleanly and seeds expected WME facts on the target piece", () => {
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE_JSON);
const engine = new ChessEngine();
const pawnId = findPieceAtSquare(engine, 12); // e2 (white pawn)
applyCustomDescriptor(engine, engine.session, pawnId, descriptor);
// Profile-time apply walks childPrimitives() recursively (mirrors
// apply.test.ts's "descends into nested children" contract), so the
// nested primitives inside on-turn-start / on-capture / on-damaged /
// conditional also execute at apply-time in addition to seeding
// their trigger-hook facts. Totals below reflect that walk:
// Hp: seed=5, multiply*2 => 10, on-turn-start nested add +1 => 11
expect(engine.session.get(pawnId, "Hp")).toBe(11);
// HpBonus: outer add +2, on-capture nested add +1 => 3,
// conditional.else nested add +1 => 4
expect(engine.session.get(pawnId, "HpBonus")).toBe(4);
// Direction additions (forward + backward)
const directions = engine.session.get(pawnId, "DirectionAdditions");
expect(directions).toBeDefined();
// CaptureFlags: bit 2 set
const flags = engine.session.get(pawnId, "CaptureFlags");
expect(typeof flags).toBe("number");
expect(((flags as number) & 2) !== 0).toBe(true);
// reflect-damage: last-write-wins (nested on-damaged has 50 and
// runs after the outer 25 during the recursive walk)
expect(engine.session.get(pawnId, "ReflectDamagePercent")).toBe(50);
// block-move-type
const blocked = engine.session.get(pawnId, "BlockedMoveTypes");
expect(blocked).toBeDefined();
// modify-movement-range +1
expect(engine.session.get(pawnId, "RangeBonus")).toBe(1);
// override-promotion
expect(engine.session.get(pawnId, "PromotionOverride")).toBe("queen");
// add-aura seeds AuraSpec
expect(engine.session.get(pawnId, "AuraSpec")).toBeDefined();
// Trigger hook seeding
expect(engine.session.get(pawnId, "OnTurnStartHooks")).toBeDefined();
expect(engine.session.get(pawnId, "OnCaptureHooks")).toBeDefined();
expect(engine.session.get(pawnId, "OnDamagedHooks")).toBeDefined();
expect(engine.session.get(pawnId, "ConditionalHooks")).toBeDefined();
});
it("round-trips byte-equal through JSON.parse(JSON.stringify(...))", () => {
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE_JSON);
// Re-serialize and re-parse; the resulting plain object must be
// deep-equal to the original fixture JSON object.
const roundTripped = JSON.parse(JSON.stringify(descriptor)) as unknown;
expect(roundTripped).toEqual(RAW_FIXTURE_JSON);
// Also verify the fixture itself is idempotent under stringify.
expect(JSON.parse(JSON.stringify(RAW_FIXTURE_JSON))).toEqual(
RAW_FIXTURE_JSON,
);
});
});
// ── Helpers ───────────────────────────────────────────────────────────
function findPieceAtSquare(engine: ChessEngine, square: number): EntityId {
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
return f.id;
}
}
throw new Error(`no piece at square ${square}`);
}