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:
parent
d9df4b64ca
commit
b2ca2cae23
2 changed files with 213 additions and 0 deletions
|
|
@ -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 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
148
packages/chess/src/modifiers/custom/legacy-descriptor.test.ts
Normal file
148
packages/chess/src/modifiers/custom/legacy-descriptor.test.ts
Normal 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}`);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue