feat(chess): preset-flexibility architecture — decouple HP, damage, piece-types, state, effects

Decouples five cross-cutting concerns from engine core so new presets
compose without special-casing:

- Piece attributes: CORE_PIECE_ATTRS + PresetDef.pieceAttributes;
  engine.effectivePieceAttrs unions them. HP is now preset-owned.
- Spawn pipeline: engine.spawnPiece() + onPieceSpawn hook replaces
  hand-rolled materialization. Queen-splits seeds HP via the hook,
  not via direct knowledge of piece-hp.
- Hook signatures: all hooks now take single context objects
  (LifecycleContext, MoveHookContext, CaptureHookContext, DamageHookContext,
  SelfCheckFilterContext, GameResultHookContext, PieceSpawnContext,
  BeforeMoveContext, TurnStartContext, DescribeMoveEffectContext).
- Piece-type registry: PIECE_TYPE_REGISTRY + core-piece-types.ts
  replaces hardcoded FIDE switch-tables in engine/check/checkmate/
  stalemate/Piece.tsx. Custom piece types plug in without engine edits.
- Preset-scoped state: engine.presetState<T>(id) backed by facts on
  PRESET_STATE_ENTITY. Auto-cleared on deactivate. capture-to-win
  migrated off GAME_ENTITY.Winner.
- Move log: engine.moveLog + MoveRecord + describeMoveEffect hook.
- Visual effects: engine.emitEffect/subscribeEffects + VisualEffect +
  VisualEffectLayer. Explosion, heal, poison renderers. No-op when
  no subscribers.
- Phase hooks: onBeforeMove (with cancel), onTurnStart. Scope-aware
  dispatch routes onDamage/onBeforeMove/onTurnStart by target/mover
  color.

All 5 production presets migrated. 4 prototype presets (Shield, Cannon,
Berserker, PawnStamina) in integration.test.ts exercise every hook.
Added test-utils.ts and PRESET-API.md for preset authors.

930 tests passing; bun run check clean.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 16:26:17 -06:00
commit cc30545ced
No known key found for this signature in database
32 changed files with 3611 additions and 304 deletions

View file

@ -6,16 +6,13 @@
*/
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import { GAME_ENTITY, type PieceType, type PieceColor } from "./schema.js";
import { generateStartingPosition } from "./starting-position.js";
import { getLegalPawnMoves } from "./rules/pawn.js";
import { getLegalKnightMoves } from "./rules/knight.js";
import {
getLegalRookMoves,
getLegalBishopMoves,
getLegalQueenMoves,
} from "./rules/sliding.js";
import { getLegalKingMoves } from "./rules/king.js";
GAME_ENTITY,
PRESET_STATE_ENTITY,
type PieceType,
type PieceColor,
} from "./schema.js";
import { generateStartingPosition } from "./starting-position.js";
import {
getCastlingMoves,
applyCastlingMove,
@ -33,6 +30,7 @@ import {
} from "./rules/promotion.js";
import {
filterSelfCheckMoves,
isInCheck,
isSquareAttacked,
} from "./rules/check.js";
import { isCheckmate } from "./rules/checkmate.js";
@ -44,28 +42,50 @@ import {
recordPosition,
isThreefoldRepetition,
} from "./rules/draws.js";
import { PIECE_ATTRS } from "./rules/capture.js";
import type { DamageContext } from "./presets/registry.js";
import { CORE_PIECE_ATTRS } from "./rules/capture.js";
import type {
BeforeMoveContext,
CaptureHookContext,
DamageContext,
DamageHookContext,
DescribeMoveEffectContext,
GameResultHookContext,
LifecycleContext,
MoveHookContext,
PieceSpawnContext,
PieceSpawnReason,
SelfCheckFilterContext,
TurnStartContext,
} from "./presets/registry.js";
import type { ChessAttrKey } from "./schema.js";
import type { LegalMove } from "./rules/types.js";
import {
ActivePresetSet,
type ActivationRequest,
} from "./presets/active-set.js";
import { PRESET_REGISTRY } from "./presets/registry.js";
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";
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
const PIECE_MOVE_GETTERS: Record<PieceType, MoveGetter> = {
pawn: getLegalPawnMoves,
knight: getLegalKnightMoves,
bishop: getLegalBishopMoves,
rook: getLegalRookMoves,
queen: getLegalQueenMoves,
king: getLegalKingMoves,
};
/**
* Look up the move generator for a piece type via the registry.
* Returns `null` for unknown types (degenerate — indicates a fact
* with a piece type nobody registered; the engine treats such pieces
* as immobile).
*
* This replaces the hardcoded `PIECE_MOVE_GETTERS` table that only
* knew about FIDE pieces. Custom types (Cannon, Amazon, Nightrider)
* register their move generators in the same registry and are
* dispatched automatically.
*/
function lookupMoveGenerator(type: string): MoveGetter | null {
const def = PIECE_TYPE_REGISTRY.get(type);
return def?.moveGenerator ?? null;
}
export type GameResult =
| "checkmate"
@ -81,6 +101,187 @@ export type GameResult =
| "black-wins"
| "ongoing";
/**
* Reserved attribute prefix for preset-state facts. Every preset-
* state attribute stored on PRESET_STATE_ENTITY is prefixed with
* this to guarantee no collision with game-level or piece-level
* attribute keys.
*
* Changing this value breaks serialized save-games that contain
* preset-state facts — don't.
*/
const PRESET_STATE_ATTR_PREFIX = "__preset__/";
/**
* Thrown from `applyMove` when a preset's `onBeforeMove` hook vetoes
* the move. The caller (UI / server) should catch this, surface
* `reason` to the user, and leave the game state unchanged — no
* mutation happens before the cancel check runs.
*
* Fatal the move attempt, not the session. Callers should NOT treat
* this as a server error.
*/
export class MoveCancelledError extends Error {
readonly presetId: string;
constructor(message: string, presetId: string) {
super(message);
this.name = "MoveCancelledError";
this.presetId = presetId;
}
}
/**
* A single line in the engine's move history.
*
* Every successful `applyMove` appends one of these to the engine's
* `moveLog`. Callers that need history (UI move-list panel, PGN
* export, replay, analysis) read this rather than diffing session
* facts.
*
* `presetEffects` is populated by presets implementing
* `describeMoveEffect`: each return value contributes one entry
* (presetId + human-readable summary). Example: queen-splits would
* push `{ presetId: "queen-splits", summary: "Queen split into
* Rook/Bishop" }` on a fission move.
*
* Growable — future fields should be optional so existing consumers
* don't break. Favor primitive/string values so the record is
* trivially JSON-serializable for save/export.
*/
/**
* An ephemeral visual effect emitted by a preset for the UI layer
* to render.
*
* Effects are decoupled from gameplay — the engine's state is
* authoritative and independent of whether anyone subscribed. Emit
* liberally from presets; the UI decides whether / how to render a
* given kind. Unknown kinds are rendered as a generic pulse by the
* default VisualEffectLayer.
*
* `kind` is a free-form string so presets can introduce new effect
* types (explosion, heal, poison, lightning, shield-break, …) without
* editing engine/UI tables. The UI layer dispatches to per-kind
* components; an unregistered kind falls back to a generic overlay.
*
* `square` is where the effect visually anchors. `null` is allowed
* for full-board effects (e.g. a "shockwave" that sweeps the whole
* board). `ttl` is how long (in ms) the effect should persist before
* auto-expiring.
*
* `data` is a typed-free-form bag for kind-specific payloads
* (color, intensity, direction, count). Presets document what they
* emit; renderers read what they need.
*/
export interface VisualEffect {
readonly kind: string;
readonly square: number | null;
readonly ttl: number;
readonly data?: Record<string, unknown>;
}
/** Handler signature for effect subscribers. Return nothing. */
export type VisualEffectHandler = (effect: VisualEffect) => void;
/** Unsubscribe callback returned by `subscribeEffects`. */
export type Unsubscribe = () => void;
export interface MoveRecord {
readonly from: number;
readonly to: number;
readonly mover: PieceColor;
readonly movingType: PieceType;
/** Captured piece's id BEFORE the capture resolved (null if no capture). */
readonly capturedId: EntityId | null;
/** Captured piece's type BEFORE the capture resolved (null if no capture). */
readonly capturedType: PieceType | null;
/** Did the move put the opponent in check? */
readonly isCheck: boolean;
/** Did the move end the game (checkmate or preset-terminal)? */
readonly terminal: boolean;
readonly isEnPassant: boolean;
readonly isCastling: boolean;
readonly promotion: PieceType | null;
/** Engine timestamp in ms (Date.now()); for client-side animation timing. */
readonly timestamp: number;
/** Non-empty when presets contributed effect descriptions. */
readonly presetEffects: ReadonlyArray<{ presetId: string; summary: string }>;
}
/**
* Typed accessor for a preset's slice of engine-scoped state.
*
* Obtain one via `ChessEngine.presetState<T>(presetId)`. Calls are
* scoped: two presets calling `state.set("x", 1)` with different
* preset IDs each get their own `x`.
*/
export interface PresetState<T extends Record<string, unknown>> {
/** Read a state key. Returns undefined when unset. */
get<K extends keyof T>(key: K): T[K] | undefined;
/** Write a state key. Overwrites any previous value. */
set<K extends keyof T>(key: K, value: T[K]): void;
/** Delete a state key. Returns true if it was set, false otherwise. */
delete<K extends keyof T>(key: K): boolean;
/** Returns the full state snapshot as a plain object. */
all(): Partial<T>;
/** True if `key` has been set. */
has<K extends keyof T>(key: K): boolean;
}
class PresetStateImpl<T extends Record<string, unknown>>
implements PresetState<T>
{
readonly #session: Session;
readonly #prefix: string;
constructor(session: Session, presetId: string) {
this.#session = session;
this.#prefix = `${PRESET_STATE_ATTR_PREFIX}${presetId}/`;
}
#attrOf(key: string): string {
return `${this.#prefix}${key}`;
}
get<K extends keyof T>(key: K): T[K] | undefined {
const attr = this.#attrOf(key as string);
if (!this.#session.contains(PRESET_STATE_ENTITY, attr)) return undefined;
return this.#session.get(PRESET_STATE_ENTITY, attr) as T[K];
}
set<K extends keyof T>(key: K, value: T[K]): void {
// The rete session validates that `value` is a legal FactValue
// (string | number | boolean | null). Passing objects here will
// throw — a deliberate constraint to keep facts serialization-safe.
this.#session.insert(
PRESET_STATE_ENTITY,
this.#attrOf(key as string),
value as unknown as import("@paratype/rete").FactValue,
);
}
delete<K extends keyof T>(key: K): boolean {
const attr = this.#attrOf(key as string);
if (!this.#session.contains(PRESET_STATE_ENTITY, attr)) return false;
this.#session.retract(PRESET_STATE_ENTITY, attr);
return true;
}
has<K extends keyof T>(key: K): boolean {
return this.#session.contains(PRESET_STATE_ENTITY, this.#attrOf(key as string));
}
all(): Partial<T> {
const result: Record<string, unknown> = {};
for (const f of this.#session.allFacts()) {
if ((f.id as number) !== (PRESET_STATE_ENTITY as number)) continue;
if (!f.attr.startsWith(this.#prefix)) continue;
const key = f.attr.slice(this.#prefix.length);
result[key] = f.value;
}
return result as Partial<T>;
}
}
export class ChessEngine {
public readonly session: Session;
/**
@ -96,6 +297,69 @@ export class ChessEngine {
*/
public readonly activePresets: ActivePresetSet;
/**
* Chronological log of every successful applyMove. Callers consume
* this read-only for move-history UIs, PGN export, analysis. Writing
* to it is a private concern of applyMove.
*
* Unbounded — games are short-lived enough that we don't need a
* ring buffer. If that ever changes, add a `maxLogSize` option.
*/
readonly #moveLog: MoveRecord[] = [];
/** Read-only view of the move log. */
get moveLog(): ReadonlyArray<MoveRecord> {
return this.#moveLog;
}
/**
* Active visual-effect subscribers. Presets call `engine.emitEffect`
* to push effects; UI layers call `subscribeEffects` to listen.
*
* Using a Set (not an Array) so unsubscribe is O(1) and insertion
* order doesn't matter (effects are fire-and-forget — we iterate
* the full set on every emit).
*/
readonly #effectHandlers = new Set<VisualEffectHandler>();
/**
* Emit a visual effect to all subscribers. Fire-and-forget —
* handlers that throw are isolated (caught) so one bad subscriber
* doesn't break others.
*
* Safe to call from inside any preset hook. The engine does NOT
* persist effects; they're pure UI signals. If no subscribers are
* attached, the call is essentially a no-op.
*/
emitEffect(effect: VisualEffect): void {
for (const handler of this.#effectHandlers) {
try {
handler(effect);
} catch (err) {
// Effect handlers are UI-side and shouldn't tank gameplay.
// Log to console so dev-mode surfaces the problem without
// throwing.
console.error("VisualEffect handler threw:", err);
}
}
}
/**
* Register a subscriber for visual effects. Returns a function that
* removes the subscription. Typically called from React `useEffect`
* on component mount, and the return is returned as the cleanup.
*
* Handlers are called synchronously from `emitEffect`. For React
* subscribers, the handler should schedule a state update rather
* than render inline — use `useSyncExternalStore` or equivalent.
*/
subscribeEffects(handler: VisualEffectHandler): Unsubscribe {
this.#effectHandlers.add(handler);
return () => {
this.#effectHandlers.delete(handler);
};
}
constructor(activePresets?: ActivePresetSet) {
this.session = new Session({ autoFire: false });
generateStartingPosition(this.session);
@ -103,6 +367,80 @@ export class ChessEngine {
this.activePresets = activePresets ?? new ActivePresetSet();
}
/**
* The full set of piece attributes the engine should treat as
* "per-piece state" for this game, combining FIDE's core attrs
* with any preset-declared additions.
*
* Used by the damage pipeline's default-death path, piece retract
* helpers inside presets (queen-splits fissioning the queen,
* explosive-rook blast fallback), and any future save-state
* serialization. This is the CANONICAL answer to "what facts
* represent a piece."
*
* Computed on every read because the active preset set is mutable;
* the list is tiny (< 10 entries in practice), so we don't cache.
* If perf becomes a concern, invalidate a cache in
* `setActivePresets` / `ActivePresetSet.replaceAll`.
*/
get effectivePieceAttrs(): readonly ChessAttrKey[] {
const attrs = new Set<ChessAttrKey>(CORE_PIECE_ATTRS);
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
if (!def?.pieceAttributes) continue;
for (const a of def.pieceAttributes) attrs.add(a);
}
return [...attrs];
}
/**
* Typed, per-preset, per-engine state bag.
*
* Returns an accessor scoped to `presetId` that reads/writes facts
* on the reserved `PRESET_STATE_ENTITY`. Attribute keys are
* namespaced as `__preset__/${presetId}/${key}` under the hood so
* two presets can never collide.
*
* Because state is backed by session facts it:
* - serializes automatically with the rest of save-state (no extra
* plumbing — `session.allFacts()` already includes it)
* - is engine-scoped, so two concurrent games (server-side) hold
* independent state under the same preset id — no module-level
* globals
* - is cleared when the preset deactivates (see
* `clearPresetState` called from `setActivePresets` /
* `tickAfterMove`)
*
* The type parameter is trusted for convenience — `set("x", 5)`
* doesn't verify that `x` is `number` in `T`. Use sparingly or wrap
* in a factory function per preset that enforces its own shape.
*/
presetState<T extends Record<string, unknown> = Record<string, unknown>>(
presetId: string,
): PresetState<T> {
return new PresetStateImpl<T>(this.session, presetId);
}
/**
* Retract every preset-state fact owned by `presetId`. Called from
* `setActivePresets` and `tickAfterMove` when a preset deactivates,
* so stale state doesn't leak across activation cycles.
*/
private clearPresetState(presetId: string): void {
const prefix = `${PRESET_STATE_ATTR_PREFIX}${presetId}/`;
const toRetract = this.session
.allFacts()
.filter(
(f) =>
(f.id as number) === (PRESET_STATE_ENTITY as number) &&
f.attr.startsWith(prefix),
)
.map((f) => f.attr);
for (const attr of toRetract) {
this.session.retract(PRESET_STATE_ENTITY, attr);
}
}
/**
* Replace the active preset set and fire lifecycle hooks on transitions.
*
@ -131,15 +469,19 @@ export class ChessEngine {
this.activePresets.replaceAll(requests);
const newIds = new Set(requests.map((r) => r.id));
const ctx: LifecycleContext = { engine: this };
for (const id of oldIds) {
if (newIds.has(id)) continue;
const def = PRESET_REGISTRY.get(id);
def?.onDeactivate?.(this);
def?.onDeactivate?.(ctx);
// Clear preset-state AFTER onDeactivate so the hook can read
// (or explicitly preserve) its own state during teardown.
this.clearPresetState(id);
}
for (const req of requests) {
if (oldIds.has(req.id)) continue;
const def = PRESET_REGISTRY.get(req.id);
def?.onActivate?.(this);
def?.onActivate?.(ctx);
}
}
@ -158,14 +500,69 @@ export class ChessEngine {
color: PieceColor,
): boolean {
let consumed = false;
const ctx: CaptureHookContext = {
engine: this,
attacker,
target,
mover: color,
};
for (const preset of this.activePresets.getForColor(color)) {
if (!preset.onBeforeCapture) continue;
const result = preset.onBeforeCapture(this, attacker, target);
const result = preset.onBeforeCapture(ctx);
if (result && result.consume === true) consumed = true;
}
return consumed;
}
/**
* Create a new piece entity on `square` and fire `onPieceSpawn` hooks.
*
* This is the CANONICAL spawn path. Every code site that materializes
* a piece — the queen-splits fission, promotion, hypothetical
* summon/resurrect presets — should go through here so:
* - `onPieceSpawn` hooks get a chance to seed preset-declared
* attributes (Hp from piece-hp, Shield from piece-shield, …)
* WITHOUT those presets having to implement idempotent
* onActivate scans.
* - core attrs are inserted in a consistent order.
* - new spawn reasons can be added to the `PieceSpawnReason` union
* without editing this function.
*
* The initial starting-position generation does NOT go through here
* (it precedes any active presets — nothing to hook). Preset-authored
* initial placements should activate AFTER piece seeding or use
* this function explicitly in their `onActivate`.
*/
spawnPiece(
type: PieceType,
color: PieceColor,
square: number,
opts: {
readonly reason?: PieceSpawnReason;
readonly hasMoved?: boolean;
} = {},
): EntityId {
const id = this.session.nextId();
this.session.insert(id, "PieceType", type);
this.session.insert(id, "Color", color);
this.session.insert(id, "Position", square);
this.session.insert(id, "HasMoved", opts.hasMoved ?? false);
const ctx: PieceSpawnContext = {
engine: this,
pieceId: id,
type,
color,
square,
reason: opts.reason ?? "summon",
};
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
def?.onPieceSpawn?.(ctx);
}
return id;
}
/**
* Deal `amount` damage to `target`, running it through the preset
* damage pipeline before applying the default kill-on-damage rule.
@ -199,21 +596,44 @@ export class ChessEngine {
): { died: boolean } {
if (amount <= 0) return { died: false };
// Consult damage interceptors in active-preset list order. First
// consumer wins. Scope is not considered here because damage is a
// board-level event that can strike any piece regardless of whose
// turn it is (e.g. explosive rook hitting friendly pieces).
// Scope-aware dispatch: pre-resolve the target's color so we can
// skip presets whose scope doesn't cover it. A `scope=white` preset
// only damages/protects white pieces; without this filter, a white-
// only Shield preset would absorb damage on black pieces too.
// Presets without a color-sensitive hook opt in to universal
// dispatch by leaving scope as "both" (the default).
const targetColor = this.session.get(target, "Color") as
| PieceColor
| undefined;
const hookCtx: DamageHookContext = {
engine: this,
target,
amount,
kind: ctx.kind,
...(ctx.attacker !== undefined ? { attacker: ctx.attacker } : {}),
};
for (const entry of this.activePresets.list()) {
// If we know the target's color, require the preset's scope to
// cover that color. If we don't (degenerate — target has no
// Color fact), fire on every preset regardless.
if (
targetColor !== undefined &&
entry.scope !== "both" &&
entry.scope !== targetColor
) {
continue;
}
const def = PRESET_REGISTRY.get(entry.id);
const result = def?.onDamage?.(this, target, amount, ctx);
const result = def?.onDamage?.(hookCtx);
if (result?.consume === true) {
return { died: result.died === true };
}
}
// Default: any damage is lethal. Retract all piece attributes so
// downstream queries see the piece as truly gone.
for (const attr of PIECE_ATTRS) {
// Default: any damage is lethal. Retract every effective piece
// attribute (core + preset-declared) so downstream queries see the
// piece as truly gone.
for (const attr of this.effectivePieceAttrs) {
if (this.session.contains(target, attr)) {
this.session.retract(target, attr);
}
@ -240,7 +660,7 @@ export class ChessEngine {
.filter(p => p.type !== undefined);
for (const piece of pieces) {
const getter = PIECE_MOVE_GETTERS[piece.type];
const getter = lookupMoveGenerator(piece.type);
if (!getter) continue;
let pieceMoves = getter(this.session, piece.id);
@ -304,8 +724,9 @@ export class ChessEngine {
// This is how `piece-hp` allows the king to stay on an attacked
// square — a "hit" costs HP rather than losing the game.
let applyFilter = true;
const selfCheckCtx: SelfCheckFilterContext = { engine: this, color };
for (const preset of this.activePresets.getForColor(color)) {
if (preset.shouldFilterSelfCheck?.(this, color) === false) {
if (preset.shouldFilterSelfCheck?.(selfCheckCtx) === false) {
applyFilter = false;
break;
}
@ -315,6 +736,28 @@ export class ChessEngine {
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
const color = this.getCurrentTurn();
// Phase hook: give presets a chance to veto the move. Scope-aware
// (fires only for presets whose scope covers the mover). We do
// this BEFORE any mutation so a veto leaves state clean.
const beforeCtx: BeforeMoveContext = {
engine: this,
mover: color,
pieceId: move.pieceId,
from: move.from,
to: move.to,
isCapture: move.isCapture === true,
};
for (const preset of this.activePresets.getForColor(color)) {
const result = preset.onBeforeMove?.(beforeCtx);
if (result?.cancel === true) {
throw new MoveCancelledError(
result.reason ?? `Move cancelled by preset "${preset.id}"`,
preset.id,
);
}
}
const facts = this.session.allFacts();
const movingType = facts.find(
f => f.id === move.pieceId && f.attr === "PieceType",
@ -329,6 +772,26 @@ export class ChessEngine {
const isCastling = (move as CastlingMove).isCastling === true;
// Capture pre-move state for the MoveRecord we'll log at the end.
// Taking the snapshot NOW — before any mutation — is the only way
// to know what was on the destination square, since the capture
// path may retract it mid-function.
const capturedIdForLog: EntityId | null = move.isCapture
? (isEnPassant
? this.getPieceAt(
color === "white"
? ((move.to - 8) as number)
: ((move.to + 8) as number),
)
: this.getPieceAt(move.to))
: null;
const capturedTypeForLog: PieceType | null =
capturedIdForLog !== null
? (facts.find(
(f) => f.id === capturedIdForLog && f.attr === "PieceType",
)?.value as PieceType | undefined) ?? null
: null;
if (isEnPassant) {
// En passant captures the pawn on the SKIPPED square, not on
// `move.to`. We still fire `onBeforeCapture` first so presets
@ -440,10 +903,12 @@ export class ChessEngine {
// ticks only when white plays; a `scope=both` ticks on every
// half-move. Entries reaching 0 are removed AND fire onDeactivate
// so they can tear down any board state they installed.
const lifecycleCtx: LifecycleContext = { engine: this };
const expired = this.activePresets.tickAfterMove(color);
for (const id of expired) {
const def = PRESET_REGISTRY.get(id);
def?.onDeactivate?.(this);
def?.onDeactivate?.(lifecycleCtx);
this.clearPresetState(id);
}
// Post-move preset hooks: fire against EVERY still-active preset
@ -451,12 +916,73 @@ export class ChessEngine {
// responsibility — king-heals affects the non-mover, poisoned-squares
// affects the mover, so a single engine-level scope filter can't
// serve both.
const moveCtx: MoveHookContext = { engine: this, mover: color };
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
def?.onAfterMove?.(this, color);
def?.onAfterMove?.(moveCtx);
}
return this.checkGameResult();
// Phase hook: a new turn is starting (nextColor is now to move).
// Scope-aware — presets scoped to a specific color only fire when
// that color's turn is beginning. Ideal for resource regen /
// cooldown ticks — runs BEFORE the player asks for their legal
// moves, so regenerated stamina / refreshed cooldowns are
// reflected in the first legal-move query.
const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor };
for (const preset of this.activePresets.getForColor(nextColor)) {
preset.onTurnStart?.(turnStartCtx);
}
// Determine terminal state BEFORE we log, so the MoveRecord
// reflects game-over status on the move that caused it.
const gameResult = this.checkGameResult();
const terminal = gameResult !== "ongoing";
// Check-detection: after the move + preset hooks, is the opponent
// (the side whose turn is NOW) in check?
const opponentInCheck = isInCheck(this.session, nextColor);
// Collect preset contributions to the move description.
const describeCtx: DescribeMoveEffectContext = {
engine: this,
mover: color,
from: move.from,
to: move.to,
movingType,
capturedType: capturedTypeForLog,
isEnPassant,
isCastling,
promotion:
movingType === "pawn" && isPromotionMove(effectiveTo, color)
? ((move as LegalMove & { promoteTo?: PieceType }).promoteTo ?? promoteTo)
: null,
};
const presetEffects: Array<{ presetId: string; summary: string }> = [];
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const summary = def?.describeMoveEffect?.(describeCtx);
if (summary !== undefined && summary !== "") {
presetEffects.push({ presetId: entry.id, summary });
}
}
this.#moveLog.push({
from: move.from,
to: move.to,
mover: color,
movingType,
capturedId: capturedIdForLog,
capturedType: capturedTypeForLog,
isCheck: opponentInCheck,
terminal,
isEnPassant,
isCastling,
promotion: describeCtx.promotion as PieceType | null,
timestamp: Date.now(),
presetEffects,
});
return gameResult;
}
checkGameResult(): GameResult {
@ -464,9 +990,10 @@ export class ChessEngine {
// in registration order. First non-undefined return wins. This lets
// capture-to-win and last-piece-standing redefine "game over"
// without touching engine internals.
const resultCtx: GameResultHookContext = { engine: this };
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const override = def?.onCheckGameResult?.(this);
const override = def?.onCheckGameResult?.(resultCtx);
if (override !== undefined) return override;
}