From c34c11af92990c55707dc1afd81b80cea9852711 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:48:31 -0600 Subject: [PATCH] feat(engine): hot-swap reconciliation --- .../chess/src/modifiers/reconcile.test.ts | 294 ++++++++++++++++++ packages/chess/src/modifiers/reconcile.ts | 227 ++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 packages/chess/src/modifiers/reconcile.test.ts create mode 100644 packages/chess/src/modifiers/reconcile.ts diff --git a/packages/chess/src/modifiers/reconcile.test.ts b/packages/chess/src/modifiers/reconcile.test.ts new file mode 100644 index 0000000..72291cc --- /dev/null +++ b/packages/chess/src/modifiers/reconcile.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for reconcileProfileSwap. + * + * Setup pattern: build a session via `applyLayout(CLASSIC_LAYOUT)`, + * optionally seed `Hp` on pieces (simulating `piece-hp` active), run + * reconcile, assert the post-state. + * + * We don't actually activate `piece-hp` as a preset here — these + * tests are scoped to reconcile's own contract, so we write `Hp` + * directly. Higher-level integration with `piece-hp` is covered by + * engine-surface tests elsewhere. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js"; +import { applyProfileToSession } from "./apply.js"; +import { reconcileProfileSwap } from "./reconcile.js"; +import type { ModifierProfile } from "./types.js"; +import { CaptureFlag } from "../schema.js"; +// Side-effect imports so MODIFIER_REGISTRY is populated for apply / +// reconcile to resolve descriptors. +import "./index.js"; + +function profile(parts: Partial): ModifierProfile { + return { + id: "p", + name: "p", + description: "", + perType: [], + perInstance: [], + version: 1, + source: "custom", + ...parts, + }; +} + +/** Locate the white knight entity on b1 (square 1). */ +function findB1Knight(session: Session): EntityId { + const facts = session.allFacts(); + const pos = facts.find((f) => f.attr === "Position" && f.value === 1); + expect(pos).toBeDefined(); + return pos!.id as EntityId; +} + +/** Locate the white pawn on e2 (square 12). */ +function findE2Pawn(session: Session): EntityId { + const facts = session.allFacts(); + const pos = facts.find((f) => f.attr === "Position" && f.value === 12); + expect(pos).toBeDefined(); + return pos!.id as EntityId; +} + +describe("reconcileProfileSwap", () => { + let session: Session; + + beforeEach(() => { + session = new Session({ autoFire: false }); + applyLayout(session, CLASSIC_LAYOUT); + // Silence orphan warnings; individual tests that care spy directly. + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + it("is idempotent: reconcile(A, A) twice produces identical session state", () => { + const P = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }, + { kind: "range-bonus", pieceType: "rook", color: "both", value: 1 }, + ], + perInstance: [ + { kind: "capture-flags", square: "e1", value: CaptureFlag.CANNOT_BE_CAPTURED }, + ], + }); + + reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT); + const after1 = session.allFacts().map((f) => ({ ...f })); + + reconcileProfileSwap(session, P, P, CLASSIC_LAYOUT); + const after2 = session.allFacts().map((f) => ({ ...f })); + + expect(after2).toEqual(after1); + }); + + it("HpBonus shrinks: clamps current Hp down to new max, never below", () => { + // Seed an HpBonus=+3 profile, then simulate `piece-hp` by writing + // Hp=max=5 on b1 knight (BASELINE_HP=2 + bonus=3). + const big = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 }, + ], + }); + reconcileProfileSwap(session, null, big, CLASSIC_LAYOUT); + const knight = findB1Knight(session); + expect(session.get(knight, "HpBonus")).toBe(3); + + // Simulate the knight took 0 damage — full HP = 5. + session.insert(knight, "Hp", 5); + + // Swap to a profile with HpBonus=0. new max = 2, Hp clamps to 2. + const small = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 0 }, + ], + }); + reconcileProfileSwap(session, big, small, CLASSIC_LAYOUT); + + // HpBonus fact may be 0 (applied) or absent depending on stacking — + // either way the effective max is baseline + bonus = 2. + expect(session.get(knight, "Hp")).toBe(2); + }); + + it("HpBonus grows: Hp does NOT grow (damaged pieces stay damaged)", () => { + // Piece starts under a zero-bonus profile, with Hp=1 (simulating + // it took 1 damage). + const small = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 0 }, + ], + }); + reconcileProfileSwap(session, null, small, CLASSIC_LAYOUT); + const knight = findB1Knight(session); + session.insert(knight, "Hp", 1); + + // Upgrade to HpBonus=+5. New max=7 but current Hp should stay 1. + const big = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 5 }, + ], + }); + reconcileProfileSwap(session, small, big, CLASSIC_LAYOUT); + + expect(session.get(knight, "HpBonus")).toBe(5); + // Crucially: not rewritten to 7. + expect(session.get(knight, "Hp")).toBe(1); + }); + + it("full swap across all kinds: old facts gone, new facts seeded", () => { + // Old profile populates HpBonus, RangeBonus, DirectionAdditions. + const old = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 }, + { kind: "range-bonus", pieceType: "rook", color: "white", value: 1 }, + { + kind: "direction-additions", + pieceType: "pawn", + color: "white", + value: ["backward"], + }, + ], + }); + reconcileProfileSwap(session, null, old, CLASSIC_LAYOUT); + + // New profile targets DIFFERENT kinds on different pieces entirely. + // After swap, none of the old facts should remain, only the new. + const next = profile({ + perType: [ + { + kind: "capture-flags", + pieceType: "bishop", + color: "white", + value: CaptureFlag.CAN_CAPTURE_OWN, + }, + { + kind: "damage-resistance", + pieceType: "queen", + color: "white", + value: 0.5, + }, + ], + perInstance: [ + { kind: "promotion-override", square: "e2", value: "rook" }, + ], + }); + reconcileProfileSwap(session, old, next, CLASSIC_LAYOUT); + + // Old-profile facts ALL gone. + const pawn = findE2Pawn(session); + expect(session.contains(pawn, "HpBonus")).toBe(false); + expect(session.contains(pawn, "DirectionAdditions")).toBe(false); + // Even other pieces that got old-profile facts are clean. + const facts = session.allFacts(); + const anyRangeBonus = facts.some((f) => f.attr === "RangeBonus"); + expect(anyRangeBonus).toBe(false); + + // New-profile facts are present where expected. + expect(session.get(pawn, "PromotionOverride")).toBe("rook"); + const bishops = facts.filter( + (f) => f.attr === "PieceType" && f.value === "bishop", + ); + for (const b of bishops) { + const colorFact = facts.find( + (c) => c.id === b.id && c.attr === "Color", + ); + if (colorFact?.value === "white") { + expect(session.get(b.id as EntityId, "CaptureFlags")).toBe( + CaptureFlag.CAN_CAPTURE_OWN, + ); + } else { + expect(session.contains(b.id as EntityId, "CaptureFlags")).toBe(false); + } + } + }); + + it("null → newProfile: equivalent to a fresh applyProfileToSession", () => { + const P = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "pawn", color: "both", value: 1 }, + ], + }); + + // Reference run: plain apply on a separate session. + const ref = new Session({ autoFire: false }); + applyLayout(ref, CLASSIC_LAYOUT); + applyProfileToSession(ref, P, CLASSIC_LAYOUT); + const refFacts = ref + .allFacts() + .filter((f) => f.attr === "HpBonus") + .map((f) => `${f.id as number}:${String(f.value)}`) + .sort(); + + // Our run: null → P. + reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT); + const ourFacts = session + .allFacts() + .filter((f) => f.attr === "HpBonus") + .map((f) => `${f.id as number}:${String(f.value)}`) + .sort(); + + expect(ourFacts).toEqual(refFacts); + }); + + it("oldProfile → null: every modifier fact retracted, no new facts seeded", () => { + const P = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "pawn", color: "both", value: 1 }, + { kind: "range-bonus", pieceType: "rook", color: "both", value: 2 }, + ], + perInstance: [ + { kind: "capture-flags", square: "e1", value: CaptureFlag.CANNOT_BE_CAPTURED }, + ], + }); + reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT); + + // Pre-check: modifier facts exist. + const preFacts = session.allFacts(); + expect(preFacts.some((f) => f.attr === "HpBonus")).toBe(true); + expect(preFacts.some((f) => f.attr === "RangeBonus")).toBe(true); + expect(preFacts.some((f) => f.attr === "CaptureFlags")).toBe(true); + + // Swap to null. + reconcileProfileSwap(session, P, null, CLASSIC_LAYOUT); + + const postFacts = session.allFacts(); + for (const attr of [ + "HpBonus", + "RangeBonus", + "DirectionAdditions", + "CaptureFlags", + "PromotionOverride", + "DamageResistance", + ] as const) { + expect(postFacts.some((f) => f.attr === attr)).toBe(false); + } + + // Core piece facts still intact (we didn't touch PieceType / Color / Position / HasMoved). + expect(postFacts.some((f) => f.attr === "PieceType")).toBe(true); + expect(postFacts.some((f) => f.attr === "Position")).toBe(true); + }); + + it("Hp without piece-hp active: no-op on Hp (only HpBonus is touched)", () => { + // piece-hp isn't active so no Hp fact exists on any piece. Swap + // still works without throwing, and leaves the non-existent Hp + // untouched (no spurious inserts). + const P1 = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 }, + ], + }); + const P2 = profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 1 }, + ], + }); + + reconcileProfileSwap(session, null, P1, CLASSIC_LAYOUT); + const knight = findB1Knight(session); + expect(session.contains(knight, "Hp")).toBe(false); + + reconcileProfileSwap(session, P1, P2, CLASSIC_LAYOUT); + // Hp still absent, HpBonus updated. + expect(session.contains(knight, "Hp")).toBe(false); + expect(session.get(knight, "HpBonus")).toBe(1); + }); +}); diff --git a/packages/chess/src/modifiers/reconcile.ts b/packages/chess/src/modifiers/reconcile.ts new file mode 100644 index 0000000..9fbbd94 --- /dev/null +++ b/packages/chess/src/modifiers/reconcile.ts @@ -0,0 +1,227 @@ +/** + * Reconcile a ModifierProfile hot-swap at a turn boundary. + * + * The problem: `applyProfileToSession` assumes a clean slate — running + * it twice on the same session would double-add values for `additive` + * kinds (HpBonus, RangeBonus) and silently clobber others. To support + * mid-game profile changes (ADR-3 "hot swap at turn boundary"), we + * need to: + * + * 1. Retract the old profile's modifier facts so stale values don't + * leak into the new effective state. + * 2. Apply the new profile via the normal `applyProfileToSession` + * path (which handles stacking from scratch). + * 3. Special-case `Hp` (current HP): when `HpBonus` shrinks, a + * piece's effective max HP shrinks too, and any current HP above + * the new max gets clamped. HP NEVER grows on swap — a piece that + * lost 1 HP in combat shouldn't magically heal because the new + * profile bumped max. + * + * ## Reconciliation rules per kind (ADR-3) + * + * | Kind | Rule | + * |----------------------|------------------------------------------| + * | HpBonus | Recompute max; clamp current Hp down | + * | RangeBonus | Overwrite (or retract if removed) | + * | DirectionAdditions | Overwrite (or retract if removed) | + * | CaptureFlags | Overwrite (or retract if removed) | + * | PromotionOverride | Overwrite (or retract if removed) | + * | DamageResistance | Overwrite (or retract if removed) | + * + * "Overwrite" falls out naturally from the retract-then-apply flow: + * after retraction the fact is gone; the new apply pass re-inserts + * only if the new profile contributed a value. + * + * ## Idempotency + * + * `reconcileProfileSwap(s, P, P)` produces the same session state on + * the second call as the first (modulo HP — see below). We retract + * every modifier fact the profile system owns, then re-seed from + * scratch. Hp clamping is idempotent too: `min(current, max)` called + * twice returns the same value. + * + * The one subtle case: `Hp` is only managed by the `piece-hp` preset. + * If `piece-hp` is not active, there is no `Hp` fact and the clamp + * step is a no-op. The baseline max HP comes from `piece-hp` itself + * (its `DEFAULT_HP = 2` constant), imported here as a single source + * of truth. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { ChessAttrKey } from "../schema.js"; +import type { StartingLayout } from "../layouts/types.js"; +import type { ModifierProfile } from "./types.js"; +import { MODIFIER_REGISTRY } from "./registry.js"; +import { applyProfileToSession } from "./apply.js"; + +/** + * The attribute name every modifier descriptor writes to. Mirrors the + * six registered descriptors' `attrName` values; collected once at + * module load so we don't walk the registry per call. + * + * We deliberately consume MODIFIER_REGISTRY here rather than + * hard-coding the list. If a future descriptor is added (or renamed), + * reconciliation picks it up automatically as long as the barrel + * import fires first. The runtime `Array.from` captures a snapshot at + * call time — the registry is append-only after module load, so this + * is safe. + */ +function modifierAttrNames(): readonly ChessAttrKey[] { + return MODIFIER_REGISTRY.list().map((d) => d.attrName); +} + +/** + * Default baseline HP, matching `piece-hp`'s `DEFAULT_HP` constant. + * Duplicated (rather than imported) because importing the preset file + * here would trigger its `PRESET_REGISTRY.register` side-effect from + * this otherwise preset-agnostic module. When `piece-hp` changes its + * default, update this literal too — documented in the preset file. + * + * A future refactor could expose this via a public API on the preset + * module; that's a wider change than T15's scope. + */ +const BASELINE_HP = 2; + +/** + * Find every piece entity (id > 0 with a PieceType fact). Separate + * helper because both the retraction pass and the HP-clamp pass need + * it, and walking `session.allFacts()` twice differently-filtered is + * cheaper than projecting it twice. + */ +function pieceIds(session: Session): EntityId[] { + const ids = new Set(); + for (const f of session.allFacts()) { + if (f.attr === "PieceType" && (f.id as number) > 0) ids.add(f.id); + } + return [...ids]; +} + +/** + * Retract every modifier-owned attribute from every piece. Called + * before re-applying the new profile so stacking starts clean. + * + * Does NOT touch `Hp` — that's a preset-owned attribute (piece-hp), + * not a modifier-owned one. `Hp` clamping is handled separately in + * `reconcileProfileSwap`. + */ +function retractAllModifierFacts(session: Session): void { + const attrs = modifierAttrNames(); + for (const id of pieceIds(session)) { + for (const attr of attrs) { + if (session.contains(id, attr)) { + session.retract(id, attr); + } + } + } +} + +/** + * Snapshot `(pieceId → old HpBonus)` BEFORE we retract. Needed because + * after retraction we've lost the old bonus, and after re-apply we + * have only the new one — we never have both at the same time + * otherwise. The diff is what drives the Hp clamp. + * + * Pieces without an HpBonus fact default to 0 — no bonus → max HP is + * just the baseline. Same convention as `piece-hp` when no HpBonus is + * present. + */ +function snapshotHpBonuses(session: Session): Map { + const snapshot = new Map(); + for (const id of pieceIds(session)) { + const b = session.get(id, "HpBonus"); + snapshot.set(id, typeof b === "number" ? b : 0); + } + return snapshot; +} + +/** + * For each piece that has a live `Hp` fact, clamp it to the new + * effective max (baseline + newHpBonus). Never grows Hp — a piece + * below the new max stays where it is. + * + * The invariant enforced: Hp ≤ BASELINE_HP + HpBonus (post-swap). + * + * Pieces without `Hp` are untouched: `piece-hp` isn't active, HP + * isn't being tracked, clamping is meaningless. + */ +function clampHpToNewMax( + session: Session, + oldBonuses: Map, +): void { + for (const id of pieceIds(session)) { + if (!session.contains(id, "Hp")) continue; + const current = session.get(id, "Hp") as number; + const newBonus = + typeof session.get(id, "HpBonus") === "number" + ? (session.get(id, "HpBonus") as number) + : 0; + const newMax = BASELINE_HP + newBonus; + // Only act when the new max is below current HP. If HP is already + // at or below max (including the equal case), leave it alone — + // rewriting the same value is a wasted write. + if (current > newMax) { + session.insert(id, "Hp", Math.max(0, newMax)); + } + // Unused-variable shimmy: oldBonuses is here to document intent + // and allow a future "only clamp when bonus SHRANK" optimization. + // Right now we always compare against newMax, which is equivalent + // for the correctness argument (if oldBonus == newBonus then + // newMax hasn't changed, and current <= oldMax == newMax so we + // skip naturally). + void oldBonuses; + } +} + +/** + * Swap one profile for another on a live session. + * + * Callers (ChessEngine / server) should invoke this at a turn + * boundary so the effective rule set is constant within any one + * move's legal-move generation. Mid-move swaps are not supported — + * the engine's move validation assumes stable modifiers between + * `getAllLegalMoves` and `applyMove`. + * + * `oldProfile === null` is the initial-apply case (equivalent to + * calling `applyProfileToSession` directly; provided here so callers + * can funnel everything through a single API). + * + * `newProfile === null` strips every modifier fact and leaves the + * session with base-rules pieces only. + * + * `oldProfile === newProfile` (or structurally equal profiles) is a + * valid idempotent call; session state converges to the fully-applied + * shape. + */ +export function reconcileProfileSwap( + session: Session, + oldProfile: ModifierProfile | null, + newProfile: ModifierProfile | null, + layout: StartingLayout, +): void { + // Step 1: snapshot HpBonus BEFORE we mutate anything. We need the + // pre-swap bonus in scope for clamp-to-new-max; once retraction + // happens, it's gone. + // + // We snapshot unconditionally (even if old === null) because a + // previous non-tracked application may have left HpBonus facts — + // we'd rather be correct than rely on the caller honestly + // reporting the prior state. + void oldProfile; // only used to document the intent of step 1 below + const oldHpBonuses = snapshotHpBonuses(session); + + // Step 2: retract every modifier-owned attribute from every piece. + // This makes step 3's `applyProfileToSession` operate on a clean + // board, so additive stacking doesn't double-count and union / + // priority-wins rules don't merge stale values. + retractAllModifierFacts(session); + + // Step 3: reapply the new profile from scratch. When newProfile is + // null, we do nothing — the session is now modifier-free. + if (newProfile !== null) { + applyProfileToSession(session, newProfile, layout); + } + + // Step 4: clamp current Hp against the new effective max. Runs + // AFTER re-apply so we have the new HpBonus values in hand. If no + // pieces have Hp facts (piece-hp inactive), this is a no-op. + clampHpToNewMax(session, oldHpBonuses); +}