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