feat(engine): hot-swap reconciliation

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:48:31 -06:00
commit c34c11af92
No known key found for this signature in database
2 changed files with 521 additions and 0 deletions

View file

@ -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>): 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);
});
});

View file

@ -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<EntityId>();
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<EntityId, number> {
const snapshot = new Map<EntityId, number>();
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<EntityId, number>,
): 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);
}