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