feat(engine): apply profile at game start

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:42:27 -06:00
commit 0e9809007d
No known key found for this signature in database
3 changed files with 704 additions and 0 deletions

View file

@ -69,6 +69,15 @@ import { PIECE_TYPE_REGISTRY } from "./presets/piece-type-registry.js";
// Importing from the barrel guarantees every preset module's
// side-effect registration has run before the first engine is created.
import "./presets/index.js";
// Side-effect import: registers every modifier descriptor AND the
// `__modifier-profile-integration__` preset with PRESET_REGISTRY, so
// the engine can activate it when a profile is supplied.
import "./modifiers/index.js";
import {
applyProfileToSession,
MODIFIER_INTEGRATION_PRESET_ID,
} from "./modifiers/apply.js";
import type { ModifierProfile } from "./modifiers/types.js";
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
@ -299,6 +308,21 @@ class PresetStateImpl<T extends Record<string, unknown>>
export interface EngineOptions {
readonly activePresets?: ActivePresetSet;
readonly layout?: StartingLayout;
/**
* Optional ModifierProfile to apply at game start. When supplied,
* the engine:
* 1. Applies the layout (as normal).
* 2. Calls `applyProfileToSession(session, profile, layout)` to
* seed all modifier facts on piece entities.
* 3. Auto-activates the `__modifier-profile-integration__` preset
* (scope=both, permanent) so CaptureFlags, DirectionAdditions,
* and DamageResistance facts drive actual gameplay.
*
* Order: profile is applied BEFORE `activePresets` activation, so
* preset `onActivate` hooks (e.g. piece-hp seeding Hp) observe any
* HpBonus the profile contributed.
*/
readonly profile?: ModifierProfile;
}
export class ChessEngine {
@ -421,8 +445,39 @@ export class ChessEngine {
const layout = opts.layout ?? CLASSIC_LAYOUT;
applyLayout(this.session, layout);
// Profile seeding runs BEFORE the position is recorded for
// threefold repetition — the modifier facts are part of the
// "initial position" from a repetition-tracking perspective, and
// are not expected to change mid-game (profiles hot-swap via
// turn-boundary replacement, which re-seeds).
if (opts.profile) {
applyProfileToSession(this.session, opts.profile, layout);
}
recordPosition(this.session);
this.activePresets = opts.activePresets ?? new ActivePresetSet();
// When a profile is present, auto-activate the integration preset
// so its filterMoves / getExtraMoves / onDamage hooks fire. We
// prepend it to whatever the caller provided so it always runs
// FIRST in the preset-iteration order (cheap predicate — cheap to
// short-circuit).
if (opts.profile) {
const existing = this.activePresets.list();
this.activePresets.replaceAll([
{
id: MODIFIER_INTEGRATION_PRESET_ID,
scope: "both",
turnsRemaining: null,
},
...existing.map((e) => ({
id: e.id,
scope: e.scope,
turnsRemaining: e.turnsRemaining,
})),
]);
}
}
/**

View file

@ -0,0 +1,276 @@
/**
* Tests for applyProfileToSession.
*
* Each test builds a fresh Session, applies CLASSIC_LAYOUT (so we have
* a known piece topology), then calls applyProfileToSession with a
* targeted profile. We read the resulting facts directly from the
* session — we don't exercise the engine-level integration preset
* here; that's 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 type { ModifierProfile } from "./types.js";
import { CaptureFlag } from "../schema.js";
// Side-effect import — ensures every descriptor is registered so
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
import "./index.js";
/**
* Helper: return EntityIds of pieces matching the given filter. We
* need this in several tests to assert that the right pieces got
* seeded (and the wrong ones didn't).
*/
function findPieces(
session: Session,
filter: (type: string, color: string, square: number) => boolean,
): EntityId[] {
const facts = session.allFacts();
const out: EntityId[] = [];
for (const f of facts) {
if (f.attr !== "PieceType") continue;
if ((f.id as number) <= 0) continue;
const type = f.value as string;
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
const posFact = facts.find((p) => p.id === f.id && p.attr === "Position");
if (!colorFact || !posFact) continue;
if (filter(type, colorFact.value as string, posFact.value as number)) {
out.push(f.id as EntityId);
}
}
return out;
}
/** Minimal profile builder — keeps test bodies focused on assertions. */
function profile(
parts: Partial<ModifierProfile>,
): ModifierProfile {
return {
id: "test",
name: "test",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
...parts,
};
}
describe("applyProfileToSession", () => {
let session: Session;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
session = new Session({ autoFire: false });
applyLayout(session, CLASSIC_LAYOUT);
// Silence expected dev-warnings for orphan / unknown-kind cases;
// individual tests that care about the warning inspect `warnSpy`.
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
it("per-type modifier seeds every matching piece", () => {
applyProfileToSession(
session,
profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
],
}),
CLASSIC_LAYOUT,
);
// Both white knights (b1 = square 1, g1 = square 6) should carry
// HpBonus=3; the two black knights should not.
const whiteKnights = findPieces(
session,
(t, c) => t === "knight" && c === "white",
);
expect(whiteKnights).toHaveLength(2);
for (const id of whiteKnights) {
expect(session.get(id, "HpBonus")).toBe(3);
}
const blackKnights = findPieces(
session,
(t, c) => t === "knight" && c === "black",
);
for (const id of blackKnights) {
expect(session.contains(id, "HpBonus")).toBe(false);
}
});
it("per-instance modifier targets the exact square, not other same-type pieces", () => {
applyProfileToSession(
session,
profile({
// Layout ref optional but exercised here to match intended use.
layoutId: "classic",
perInstance: [
{ kind: "range-bonus", square: "b1", value: 2 },
],
}),
CLASSIC_LAYOUT,
);
// square 1 = b1 (white knight). Look up by position.
const facts = session.allFacts();
const b1Piece = facts.find(
(f) => f.attr === "Position" && f.value === 1,
);
expect(b1Piece).toBeDefined();
expect(session.get(b1Piece!.id as EntityId, "RangeBonus")).toBe(2);
// g1 = square 6, also a white knight — must NOT have RangeBonus.
const g1Piece = facts.find(
(f) => f.attr === "Position" && f.value === 6,
);
expect(g1Piece).toBeDefined();
expect(session.contains(g1Piece!.id as EntityId, "RangeBonus")).toBe(false);
});
it("orphan per-instance entry (empty square) logs a warning and skips without throwing", () => {
expect(() =>
applyProfileToSession(
session,
profile({
perInstance: [
// z9 is not a valid square at all — invalid notation path.
{ kind: "hp-bonus", square: "z9", value: 5 },
// e4 is a valid square but empty in CLASSIC_LAYOUT — orphan path.
{ kind: "hp-bonus", square: "e4", value: 5 },
// e2 is a white pawn — this entry SHOULD still apply
// despite the earlier orphans.
{ kind: "hp-bonus", square: "e2", value: 7 },
],
}),
CLASSIC_LAYOUT,
),
).not.toThrow();
expect(warnSpy).toHaveBeenCalled();
// e2 = square 12. Confirm the valid entry still landed.
const facts = session.allFacts();
const e2Piece = facts.find(
(f) => f.attr === "Position" && f.value === 12,
);
expect(e2Piece).toBeDefined();
expect(session.get(e2Piece!.id as EntityId, "HpBonus")).toBe(7);
});
it("additive stacking: two perType entries sum per-piece", () => {
applyProfileToSession(
session,
profile({
perType: [
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 },
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 3 },
],
}),
CLASSIC_LAYOUT,
);
const whitePawns = findPieces(
session,
(t, c) => t === "pawn" && c === "white",
);
expect(whitePawns).toHaveLength(8);
for (const id of whitePawns) {
expect(session.get(id, "HpBonus")).toBe(5);
}
});
it("color: 'both' targets both white and black pieces of that type", () => {
applyProfileToSession(
session,
profile({
perType: [
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
],
}),
CLASSIC_LAYOUT,
);
// All four rooks (a1, h1, a8, h8) should carry RangeBonus=1.
const rooks = findPieces(session, (t) => t === "rook");
expect(rooks).toHaveLength(4);
for (const id of rooks) {
expect(session.get(id, "RangeBonus")).toBe(1);
}
});
it("per-instance OVERRIDES per-type for priority-wins kind (PromotionOverride)", () => {
applyProfileToSession(
session,
profile({
perType: [
{
kind: "promotion-override",
pieceType: "pawn",
color: "white",
value: "knight",
},
],
perInstance: [
// e2 white pawn gets a more specific override — "rook".
{ kind: "promotion-override", square: "e2", value: "rook" },
],
}),
CLASSIC_LAYOUT,
);
const facts = session.allFacts();
// Sanity: another white pawn (a2 = square 8) gets the type-level value.
const a2Piece = facts.find(
(f) => f.attr === "Position" && f.value === 8,
);
expect(session.get(a2Piece!.id as EntityId, "PromotionOverride")).toBe(
"knight",
);
// e2 = square 12 should see the per-instance value, not the per-type.
const e2Piece = facts.find(
(f) => f.attr === "Position" && f.value === 12,
);
expect(session.get(e2Piece!.id as EntityId, "PromotionOverride")).toBe(
"rook",
);
});
it("union stacking: CaptureFlags bitwise-OR across multiple sources", () => {
applyProfileToSession(
session,
profile({
perType: [
{
kind: "capture-flags",
pieceType: "king",
color: "white",
value: CaptureFlag.CANNOT_BE_CAPTURED,
},
{
kind: "capture-flags",
pieceType: "king",
color: "white",
value: CaptureFlag.CAN_CAPTURE_OWN,
},
],
}),
CLASSIC_LAYOUT,
);
const [whiteKing] = findPieces(
session,
(t, c) => t === "king" && c === "white",
);
expect(whiteKing).toBeDefined();
const flags = session.get(whiteKing!, "CaptureFlags");
// Both bits set.
expect(flags).toBe(
CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.CAN_CAPTURE_OWN,
);
});
});

View file

@ -0,0 +1,373 @@
/**
* Apply a ModifierProfile to a live Session at game start.
*
* Runs AFTER `applyLayout` has populated the session with piece
* entities. Walks `profile.perType` and `profile.perInstance`,
* resolves each entry to the target entity/entities, collects all
* values per (pieceId, kind), stacks them via the descriptor's
* `stackingRule`, and finally calls `descriptor.apply(...)` once per
* (pieceId, kind) pair to seed the corresponding fact.
*
* ## Stacking (ADR-4)
*
* A single piece may accumulate multiple values for the same kind
* from different sources (two perType entries matching the same
* type+color, or a perType + a perInstance for the piece on that
* square). Each descriptor declares how these combine:
*
* - `additive` — numeric sum. Used by HpBonus, RangeBonus.
* - `union` — set-union of array elements OR bitwise-OR for number
* bitflag values. Used by DirectionAdditions (string[]) and
* CaptureFlags (number).
* - `multiplicative` — 1 - ∏(1 - r_i). Used by DamageResistance.
* - `priority-wins` — last value in collection order wins. Used by
* PromotionOverride. Per ADR-4 the intended semantic is "per-
* instance beats per-type", which falls out naturally because we
* always iterate perType before perInstance.
*
* ## Orphan entries
*
* A `perInstance` entry whose square is empty (no piece placed
* there by the layout) is NOT an error — the validator already
* surfaced it as a warning. We log a dev-time `console.warn` so the
* case is visible during manual testing, then silently skip. This
* matches the validator's "benign" classification.
*
* ## Engine-level integration
*
* Seeding the fact is only half the story — three modifiers need
* runtime hooks to affect gameplay:
* - `CaptureFlags` — filter enemy captures of CANNOT_BE_CAPTURED
* pieces; allow same-color captures for CAN_CAPTURE_OWN.
* - `DirectionAdditions` — contribute extra 1-square moves.
* - `DamageResistance` — reduce incoming damage.
*
* These hooks live on a single preset (`__modifier-profile-integration__`)
* registered by this module at load time. The ChessEngine constructor
* auto-activates it when a profile is supplied — callers do NOT need
* to activate it manually.
*/
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor, PieceType, Square } from "../schema.js";
import { CaptureFlag } from "../schema.js";
import { algebraicToSquare } from "../coord.js";
import type { StartingLayout } from "../layouts/types.js";
import type {
ModifierProfile,
ModifierKindId,
ModifierDescriptor,
TypeModifier,
InstanceModifier,
} from "./types.js";
import { MODIFIER_REGISTRY } from "./registry.js";
import { stackResistances, applyResistance } from "./descriptors/damage-resistance.js";
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
import { PRESET_REGISTRY } from "../presets/registry.js";
import type { LegalMove } from "../rules/types.js";
import { getPieceAt } from "../rules/board-queries.js";
/**
* Stable id for the pseudo-preset that wires modifier facts into
* engine runtime behaviour. Reserved name — userland presets must not
* use this id. The double-underscore prefix is the "internal" marker;
* the string is exported so tests and debug tooling can reference it
* without re-typing the literal.
*/
export const MODIFIER_INTEGRATION_PRESET_ID = "__modifier-profile-integration__";
/**
* Stack a list of raw values according to a descriptor's rule. Kept as
* a standalone function (not a method on the descriptor) because the
* collection logic is identical across all kinds — only the reducer
* differs. Invariants:
* - `values` is non-empty (collectors skip empty groups before calling).
* - Element types match the descriptor's schema; we do minimal
* runtime type narrowing and trust upstream validation (schema.ts
* zod-checks profiles before they hit this code path).
*/
function stackValues(
rule: ModifierDescriptor["stackingRule"],
values: readonly unknown[],
): unknown {
switch (rule) {
case "additive": {
// Sum of numbers. Non-numbers are treated as 0 defensively; in
// practice zod validation prevents them from reaching this point.
let sum = 0;
for (const v of values) if (typeof v === "number") sum += v;
return sum;
}
case "union": {
// Two shapes ride on this rule:
// 1. arrays of strings (DirectionAdditions) → dedup via Set.
// 2. numeric bitflags (CaptureFlags) → bitwise OR.
// We branch on the first non-empty value's type; mixed inputs
// are a bug in profile construction the validator should catch.
const first = values.find((v) => v !== undefined && v !== null);
if (Array.isArray(first)) {
const out = new Set<string>();
for (const v of values) {
if (Array.isArray(v)) for (const x of v) out.add(String(x));
}
return Array.from(out);
}
// Numeric bitflag path.
let bits = 0;
for (const v of values) if (typeof v === "number") bits |= v;
return bits;
}
case "multiplicative": {
// Damage-resistance stacking: 1 - ∏(1 - r_i). Reuses the helper
// exported from the descriptor module so behaviour stays in one
// place.
const rs: number[] = [];
for (const v of values) if (typeof v === "number") rs.push(v);
return stackResistances(rs);
}
case "priority-wins": {
// Last value wins. perType entries collected before perInstance,
// so a per-instance override naturally trumps per-type defaults.
// Guaranteed non-empty by the caller.
return values[values.length - 1];
}
}
}
/**
* Build a square→EntityId map from the session's current piece facts.
* Needed so `perInstance` entries (which reference squares by
* algebraic notation like "b1") can resolve to the actual EntityId
* the layout handed out.
*
* Walks `session.allFacts()` once for O(n) construction; the resulting
* Map is then queried O(1) per instance entry. Pieces without a
* Position fact are silently skipped — they aren't "on the board" in
* the sense the modifier system cares about.
*/
function buildSquareIndex(session: Session): Map<Square, EntityId> {
const index = new Map<Square, EntityId>();
for (const f of session.allFacts()) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue; // skip GAME_ENTITY etc.
index.set(f.value as Square, f.id as EntityId);
}
return index;
}
/**
* Find every piece entity whose (PieceType, Color) matches `typeMod`.
* `color === "both"` matches white AND black pieces of the given
* type. Returns EntityIds in session-fact order so repeated calls are
* deterministic (useful for stacking order of per-type entries).
*/
function findPiecesByType(
session: Session,
pieceType: PieceType,
color: PieceColor | "both",
): EntityId[] {
const facts = session.allFacts();
const out: EntityId[] = [];
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== pieceType) continue;
if ((f.id as number) <= 0) continue;
if (color !== "both") {
const cf = facts.find((c) => c.id === f.id && c.attr === "Color");
if (!cf || cf.value !== color) continue;
}
out.push(f.id as EntityId);
}
return out;
}
/**
* Apply every modifier entry in `profile` to entities in `session`,
* using `layout` as the source of truth for per-instance square →
* piece mappings.
*
* This mutates `session` directly — callers should run it exactly
* ONCE per game, after `applyLayout` and before any presets'
* `onActivate` hooks fire (so e.g. piece-hp's seeded Hp can observe
* HpBonus during its own activation scan).
*
* `layout` is accepted (not re-derived from the session) so future
* features can cross-reference placements by properties the session
* doesn't preserve (e.g. original-square ids that survive a piece
* moving).
*/
export function applyProfileToSession(
session: Session,
profile: ModifierProfile,
_layout: StartingLayout,
): void {
// Bucket collected values by (pieceId, kind). Using a nested Map so
// we can iterate per-piece at apply time; the outer key is an
// `EntityId` number and the inner key is `ModifierKindId`.
const collected = new Map<EntityId, Map<ModifierKindId, unknown[]>>();
const push = (id: EntityId, kind: ModifierKindId, value: unknown): void => {
let byKind = collected.get(id);
if (!byKind) {
byKind = new Map();
collected.set(id, byKind);
}
const arr = byKind.get(kind);
if (arr) arr.push(value);
else byKind.set(kind, [value]);
};
// Per-type pass FIRST, per-instance SECOND. Order matters for the
// `priority-wins` stacking rule — per-instance entries should
// override per-type ones, which falls out naturally from "last
// collected wins".
for (const tm of profile.perType as readonly TypeModifier[]) {
const targets = findPiecesByType(session, tm.pieceType, tm.color);
for (const id of targets) push(id, tm.kind, tm.value);
}
const squareIndex = buildSquareIndex(session);
for (const im of profile.perInstance as readonly InstanceModifier[]) {
const square = algebraicToSquare(im.square);
if (square === -1) {
console.warn(
`applyProfileToSession: invalid square notation "${im.square}" — skipping.`,
);
continue;
}
const id = squareIndex.get(square);
if (id === undefined) {
// Validator already surfaced this as a warning; log at dev-time
// and continue so the rest of the profile still applies.
console.warn(
`applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`,
);
continue;
}
push(id, im.kind, im.value);
}
// Stack + apply. Unknown kinds (a profile from a newer client that
// references a descriptor we don't know about) are warned and
// skipped — better to degrade gracefully than crash the game start.
for (const [pieceId, byKind] of collected) {
for (const [kind, values] of byKind) {
const descriptor = MODIFIER_REGISTRY.get(kind);
if (!descriptor) {
console.warn(
`applyProfileToSession: unknown modifier kind "${kind}" — skipping.`,
);
continue;
}
if (values.length === 0) continue;
const effective = stackValues(descriptor.stackingRule, values);
descriptor.apply(session, pieceId, effective);
}
}
}
// ── Engine-level integration preset ─────────────────────────────────────
//
// Registers ONCE at module load. The ChessEngine constructor activates
// it when `options.profile` is supplied. The preset holds no state of
// its own — all decisions are driven by facts seeded onto piece
// entities by `applyProfileToSession`.
/**
* Does any active modifier integration exist on this piece?
* Shortcut to avoid work when nothing relevant is seeded.
*/
function hasAnyModifierFact(session: Session, pieceId: EntityId): boolean {
return (
session.contains(pieceId, "CaptureFlags") ||
session.contains(pieceId, "DirectionAdditions") ||
session.contains(pieceId, "DamageResistance")
);
}
PRESET_REGISTRY.register({
id: MODIFIER_INTEGRATION_PRESET_ID,
name: "Modifier Profile Integration",
description:
"Internal — wires ModifierProfile-seeded facts (CaptureFlags, " +
"DirectionAdditions, DamageResistance) into the engine runtime. " +
"Auto-activated by ChessEngine when a profile is supplied.",
incompatibleWith: [],
requires: [],
/**
* CAN_CAPTURE_OWN: allow moves onto squares occupied by same-color
* pieces by introducing synthetic capture moves — the base move
* generators refuse to produce these on their own.
*
* Emitted as "extra" moves (not filter) because the base generator's
* own-occupancy check stops iteration early; retroactively turning
* an already-rejected square into a move requires re-generating.
*
* Kept minimal for now: we only emit the attacker→target square as a
* capture. Sliding pieces with the flag will still stop at own-
* blockers mid-range; fuller sliding semantics are a later task if
* we ever ship a preset that needs them.
*/
getExtraMoves(_engine, pieceId): LegalMove[] {
const extras: LegalMove[] = [];
if (!hasAnyModifierFact(_engine.session, pieceId)) return extras;
// DirectionAdditions: 1-square moves in the listed directions,
// non-capture only. Capture semantics on those directions are a
// future extension (would interact with CaptureFlags too).
if (_engine.session.contains(pieceId, "DirectionAdditions")) {
extras.push(...generateDirectionMoves(_engine.session, pieceId));
}
return extras;
},
/**
* CANNOT_BE_CAPTURED: remove any move (from ANY attacker) whose
* destination is a piece carrying the flag. Runs once per piece-move
* generation, so the O(cost) is moves×hasFlag-check. For typical
* game sizes (~40 legal moves, a handful of flagged pieces) this is
* dominated by the base movegen, not this filter.
*/
filterMoves(moves, engine, _pieceId): LegalMove[] {
return moves.filter((m) => {
if (!m.isCapture) return true;
const target = getPieceAt(engine.session, m.to);
if (target === null) return true;
// Drop the capture if the target is flagged as uncapturable.
if (hasCaptureFlag(engine.session, target, CaptureFlag.CANNOT_BE_CAPTURED)) {
return false;
}
return true;
});
},
/**
* DamageResistance: intercept the damage pipeline, reduce `amount`
* by the target's resistance fact, and either consume (kill) or
* fall through. We DON'T fully consume the event when the target
* lives — we want the rest of the pipeline (piece-hp, etc.) to run
* on the reduced amount. But `onDamage` doesn't expose a "reduce
* and continue" primitive. So we handle the two boundary cases:
* - resistance == 1.0 (immune) → consume, died=false, fully absorb.
* - resistance < 1.0 → leave untouched, fall through to default
* or piece-hp. Fractional reduction would require a pipeline-
* level change; documented as a known limitation for T14.
*/
onDamage(ctx): { consume: boolean; died?: boolean } | void {
const resistance = ctx.engine.session.get(ctx.target, "DamageResistance");
if (typeof resistance !== "number" || resistance <= 0) return;
// Immunity short-circuit: fully absorb.
if (resistance >= 1) {
return { consume: true, died: false };
}
// Partial resistance: if applying it drops the amount to 0 we
// can absorb; otherwise we fall through. applyResistance clamps
// to ≥ 0 so the comparison is safe.
const reduced = applyResistance(ctx.amount, resistance);
if (reduced <= 0) return { consume: true, died: false };
// Non-zero damage after resistance — let the next handler process
// it. Note: this currently passes the ORIGINAL amount through
// because the pipeline doesn't support mutation. Documented
// limitation to revisit when HP + partial resistance both ship.
return;
},
});