feat(engine): hot-swap reconciliation
This commit is contained in:
parent
4b7d943edc
commit
c34c11af92
2 changed files with 521 additions and 0 deletions
294
packages/chess/src/modifiers/reconcile.test.ts
Normal file
294
packages/chess/src/modifiers/reconcile.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
227
packages/chess/src/modifiers/reconcile.ts
Normal file
227
packages/chess/src/modifiers/reconcile.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue