houserules/packages/chess/src/engine.ts

1160 lines
43 KiB
TypeScript

/**
* ChessEngine — integrates all rule modules into a playable chess game.
*
* Uses Session as working memory; calls rule functions directly (no Rete
* production network — that integration is future work).
*/
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import {
GAME_ENTITY,
PRESET_STATE_ENTITY,
type PieceType,
type PieceColor,
} from "./schema.js";
import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js";
import type { StartingLayout } from "./layouts/types.js";
import {
getCastlingMoves,
applyCastlingMove,
type CastlingMove,
} from "./rules/castling.js";
import {
getEnPassantMoves,
setEnPassantTarget,
clearEnPassantTarget,
} from "./rules/enpassant.js";
import {
isPromotionMove,
applyPromotion,
getPromotionMoves,
} from "./rules/promotion.js";
import {
filterSelfCheckMoves,
isInCheck,
isSquareAttacked,
} from "./rules/check.js";
import { isCheckmate } from "./rules/checkmate.js";
import { isStalemate } from "./rules/stalemate.js";
import { isInsufficientMaterial } from "./rules/insufficient.js";
import {
isFiftyMoveDraw,
updateHalfmoveClock,
recordPosition,
isThreefoldRepetition,
} from "./rules/draws.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";
// 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[];
/**
* 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"
| "stalemate"
| "draw-50"
| "draw-3fold"
| "draw-insufficient"
// Preset-defined terminal states. The regular checkmate result always
// implicitly means "the side to move loses" (standard chess), but
// variants like capture-to-win or last-piece-standing need to name
// the winner explicitly because the side to move may be the WINNER.
| "white-wins"
| "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>;
}
}
/**
* Options bag for the `ChessEngine` constructor.
*
* All fields optional. Omitted fields use a sensible default:
* - `activePresets` → a fresh empty ActivePresetSet (no presets).
* - `layout` → `CLASSIC_LAYOUT` (standard FIDE starting position).
*
* Passing a `layout` is how callers start from a non-FIDE position
* (Dunsany, Monster, Horde, custom editor output, etc.). The engine
* applies the layout FIRST, then activates presets, so every
* preset's `onActivate` scans the layout's pieces — no per-layout
* special-casing inside presets.
*/
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 {
public readonly session: Session;
/**
* Per-engine preset activation. Every engine owns its own set so two
* concurrent games (most importantly: the authoritative server session
* and a client's predicted clone) can hold different rule sets without
* a shared-module dance.
*
* Mutable by design — UIs and servers replace its contents via
* `.replaceAll()`. The engine reads it fresh on every call to
* `getAllLegalMoves()` so mid-game toggles take effect on the next
* move calculation with no reset.
*/
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);
};
}
/**
* Construct a new engine.
*
* Two calling conventions are supported:
*
* 1. Legacy (no arg, or `activePresets` only):
* `new ChessEngine()`
* `new ChessEngine(activePresets)`
*
* 2. Options bag (preferred for new code):
* `new ChessEngine({ activePresets, layout })`
* `new ChessEngine({ layout: EMPTY_LAYOUT })`
*
* The options bag is how new callers specify a starting layout
* other than FIDE. When `layout` is omitted, defaults to
* `CLASSIC_LAYOUT` (the FIDE starting position), which keeps every
* pre-layouts call site working unchanged.
*
* `activePresets` is applied AFTER the layout, so every preset's
* `onActivate` hook sees the initial board state — piece-hp seeds
* `Hp` on every piece in the layout (FIDE, Dunsany, Horde, …) with
* no per-layout plumbing.
*/
constructor(activePresets?: ActivePresetSet);
constructor(opts: EngineOptions);
constructor(arg?: ActivePresetSet | EngineOptions) {
this.session = new Session({ autoFire: false });
// Normalize the argument: ActivePresetSet stays as-is (legacy
// form); an options bag destructures. We detect by checking for
// `replaceAll` — an internal ActivePresetSet method that isn't
// on plain option bags.
const isLegacyPresetArg =
arg !== undefined &&
arg !== null &&
typeof (arg as ActivePresetSet).replaceAll === "function";
const opts: EngineOptions = isLegacyPresetArg
? { activePresets: arg as ActivePresetSet }
: ((arg as EngineOptions | undefined) ?? {});
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,
})),
]);
}
}
/**
* 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.
*
* This is the ONLY public entry point that routes through preset
* `onActivate` / `onDeactivate` hooks — direct mutation of
* `activePresets` (via `.replaceAll`) is still allowed for tests but
* bypasses the hooks.
*
* Transition ordering, by design:
* 1. Snapshot the old id set.
* 2. Validate + apply the new set via `ActivePresetSet.replaceAll`.
* If validation throws, no hooks fire and the old set is intact.
* 3. Fire `onDeactivate` for ids in old-but-not-new.
* 4. Fire `onActivate` for ids in new-but-not-old.
*
* Scope or turns-remaining changes on an id present in both sets do
* NOT re-fire any hook — the preset is considered "continuously
* active" across the transition.
*
* Deactivate-before-activate is deliberate: it lets a preset tear
* down state cleanly before the incoming preset reads the board.
* Ordering within each phase follows the input list's natural order.
*/
setActivePresets(requests: readonly ActivationRequest[]): void {
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
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?.(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?.(ctx);
}
}
/**
* Attempt a preset-intercepted capture. Dispatches `onBeforeCapture`
* on every currently-active preset for the mover's color; if any
* preset returns `{ consume: true }` the default capture is skipped
* and we return `true`. Otherwise the caller should proceed with the
* standard capture path.
*
* `color` is the color of the capturing piece (i.e. whose turn it is).
*/
private tryInterceptCapture(
attacker: EntityId,
target: EntityId,
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(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.
*
* This is the primitive that lets presets compose cleanly:
* - Without any damage-interceptor preset (standard chess,
* explosive-rook alone, etc.), damage ≥ 1 retracts the target.
* - With `piece-hp` active, the hook decrements the target's Hp
* fact and only retracts when Hp would go ≤ 0.
*
* Callers should use this instead of retracting piece facts directly
* whenever they want to compose with HP/armor/shield-like presets.
* See `explosive-rook` (AoE calls dealDamage on every victim) and
* `poisoned-squares` (tick calls dealDamage on every occupant of a
* poisoned square) for the canonical call sites.
*
* The first preset whose `onDamage` returns `consume: true` wins —
* further presets are not consulted for that event. This is
* deliberate: HP is the ONE authority on damage→death; two damage
* interceptors would race and produce zombie state (the bug this
* pipeline was designed to eliminate).
*
* Returns `{ died: true }` if the target was retracted (either by a
* hook that killed or by the default path), `{ died: false }` if it
* survived (HP absorbed it, or amount was 0).
*/
dealDamage(
target: EntityId,
amount: number,
ctx: DamageContext,
): { died: boolean } {
if (amount <= 0) return { died: false };
// 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?.(hookCtx);
if (result?.consume === true) {
return { died: result.died === true };
}
}
// 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);
}
}
return { died: true };
}
getCurrentTurn(): PieceColor {
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
}
getAllLegalMoves(): LegalMove[] {
const color = this.getCurrentTurn();
const facts = this.session.allFacts();
const moves: LegalMove[] = [];
const pieces = facts
.filter(f => f.attr === "Color" && f.value === color && (f.id as number) > 0)
.map(f => ({
id: f.id as EntityId,
type: facts.find(t => t.id === f.id && t.attr === "PieceType")
?.value as PieceType,
}))
.filter(p => p.type !== undefined);
for (const piece of pieces) {
const baseGetter = lookupMoveGenerator(piece.type);
// Fold the transformMoveGenerator chain over all active presets
// (scope-filtered for `color`). Each preset's wrapper receives the
// OUTPUT of the previous — composing cleanly. We seed with the
// type-registry generator, or a degenerate empty-move generator if
// the piece type is unknown (keeps later wrappers well-defined).
const scopedPresets = this.activePresets.getForColor(color);
const seedGetter: MoveGetter =
baseGetter ?? ((_s: Session, _id: EntityId) => [] as LegalMove[]);
const getter: MoveGetter = scopedPresets
.filter(p => p.transformMoveGenerator)
.reduce<MoveGetter>(
(gen, preset) => preset.transformMoveGenerator!(this, piece.id, gen),
seedGetter,
);
// Skip pieces with no known type AND no transform — same semantics
// as the original "unknown type ⇒ immobile" guard, but now a
// transform preset can still produce moves for an otherwise unknown
// type.
if (!baseGetter && scopedPresets.every(p => !p.transformMoveGenerator)) {
continue;
}
let pieceMoves = getter(this.session, piece.id);
// Add en passant for pawns
if (piece.type === "pawn") {
pieceMoves = [
...pieceMoves,
...getEnPassantMoves(this.session, piece.id),
];
}
// Add castling for kings
if (piece.type === "king") {
const enemyColor: PieceColor = color === "white" ? "black" : "white";
const castling = getCastlingMoves(
this.session,
piece.id,
(s, sq) => isSquareAttacked(s, sq, enemyColor),
);
pieceMoves = [...pieceMoves, ...castling];
}
// Replace raw promotion-rank pawn moves with promotion-tagged variants
if (piece.type === "pawn") {
const promotions = getPromotionMoves(this.session, piece.id);
if (promotions.length > 0) {
const promotionTos = new Set(promotions.map(m => m.to));
pieceMoves = pieceMoves.filter(m => !promotionTos.has(m.to));
pieceMoves = [...pieceMoves, ...promotions];
}
}
// Apply active preset rules for the color whose turn it is. The
// per-color filter implements `scope=white` / `scope=black` — a
// white-only preset never contributes moves while black is on move.
//
// Order matters — `getExtraMoves` contributes to the set that
// `filterMoves` operates on, so every active preset sees the full
// aggregated set (including prior presets' additions).
const activePresets = scopedPresets;
for (const preset of activePresets) {
if (preset.getExtraMoves) {
pieceMoves = [
...pieceMoves,
...preset.getExtraMoves(this, piece.id),
];
}
}
for (const preset of activePresets) {
if (preset.filterMoves) {
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
}
}
moves.push(...pieceMoves);
}
// Self-check filter: skip it iff any active preset (for this color)
// explicitly opts out via shouldFilterSelfCheck returning false.
// 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?.(selfCheckCtx) === false) {
applyFilter = false;
break;
}
}
return applyFilter ? filterSelfCheckMoves(this.session, moves, color) : moves;
}
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",
)?.value as PieceType;
const epTarget = this.session.get(GAME_ENTITY, "EnPassantTarget");
const isEnPassant =
movingType === "pawn" &&
epTarget !== null &&
epTarget !== undefined &&
epTarget === move.to;
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
// like queen-splits / explosive-rook get a chance to transform
// the capture wholesale. If none consume, we route the target
// through `dealDamage` so the HP pipeline gets to absorb it.
const capturedSquare =
color === "white" ? ((move.to - 8) as number) : ((move.to + 8) as number);
const capturedId = this.getPieceAt(capturedSquare);
let consumed = false;
let targetDied = true; // default: no captured pawn found (degenerate), treat as "died"
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
const result = this.dealDamage(capturedId, 1, {
kind: "capture",
attacker: move.pieceId,
});
targetDied = result.died;
}
}
// Attacker advances diagonally unless a preset consumed the
// capture (it owns positioning) or the captured pawn survived
// HP damage (poke — attacker stays).
if (!consumed && targetDied) {
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
} else if (isCastling) {
applyCastlingMove(this.session, move as CastlingMove);
} else {
// Normal move. Two intercept layers:
// 1. onBeforeCapture — lets a preset replace the capture
// mechanic entirely (queen-splits fission, explosive-rook
// AoE, capture-to-win winner-recording). If consumed the
// engine does nothing else for this capture.
// 2. dealDamage — runs after, only if nothing consumed. This
// is the pipeline HP hooks, and is what makes "rook
// captures pawn" decrement the pawn's Hp by 1 rather than
// instantly killing. On default (no interceptor) the target
// is retracted and `died=true` is returned.
//
// The attacker advances onto the target square iff the target
// ACTUALLY DIED. Non-lethal captures leave the attacker in
// place; turn still advances so the move counts as a "poke".
let consumed = false;
let captureResolved = true; // default: no capture happened, treat as "died"
if (move.isCapture) {
const capturedId = this.getPieceAt(move.to);
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
const { died } = this.dealDamage(capturedId, 1, {
kind: "capture",
attacker: move.pieceId,
});
captureResolved = died;
} else {
// A consuming preset owns ALL post-capture behaviour,
// including whether the attacker moved. We don't run the
// standard advance below.
captureResolved = false;
}
}
}
// Advance attacker unless:
// - a preset consumed the capture (it handled positioning), OR
// - the capture was non-lethal (attacker stays, target stands)
if (!consumed && captureResolved) {
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
}
// Handle promotion (pawn reaching last rank)
const effectiveTo = isCastling ? (move as CastlingMove).to : move.to;
if (movingType === "pawn" && isPromotionMove(effectiveTo, color)) {
const promoteAs =
(move as LegalMove & { promoteTo?: PieceType }).promoteTo ?? promoteTo;
applyPromotion(this.session, move.pieceId, promoteAs);
}
// Set/clear en passant target
if (movingType === "pawn" && Math.abs(move.to - move.from) === 16) {
setEnPassantTarget(this.session, move.from, move.to);
} else {
clearEnPassantTarget(this.session);
}
// Update halfmove clock
updateHalfmoveClock(this.session, move, movingType === "pawn");
// Switch turn
const nextColor: PieceColor = color === "white" ? "black" : "white";
this.session.insert(GAME_ENTITY, "Turn", nextColor);
// Increment fullmove number after black's move
if (color === "black") {
const fn =
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
}
// Record position for threefold repetition
recordPosition(this.session);
// Tick preset durations with the color that JUST moved. Player-local
// turn counting: a `scope=white` preset with 3 turns remaining
// 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?.(lifecycleCtx);
this.clearPresetState(id);
}
// Post-move preset hooks: fire against EVERY still-active preset
// (regardless of scope). Scope-aware behaviour is the preset's
// 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?.(moveCtx);
}
// 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 {
// Preset override path: run onCheckGameResult on every active preset
// 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?.(resultCtx);
if (override !== undefined) return override;
}
const nextColor = this.getCurrentTurn();
if (isCheckmate(this.session, nextColor)) return "checkmate";
if (isStalemate(this.session, nextColor)) return "stalemate";
if (isFiftyMoveDraw(this.session)) return "draw-50";
if (isThreefoldRepetition(this.session)) return "draw-3fold";
if (isInsufficientMaterial(this.session)) return "draw-insufficient";
return "ongoing";
}
/** Find first legal move matching from+to squares and optional promoteTo. */
findMove(from: number, to: number, promoteTo?: PieceType): LegalMove | null {
return (
this.getAllLegalMoves().find(
m =>
m.from === from &&
m.to === to &&
(!m.promoteTo || m.promoteTo === (promoteTo ?? "queen")),
) ?? null
);
}
private getPieceAt(square: number): EntityId | null {
const f = this.session
.allFacts()
.find(x => x.attr === "Position" && x.value === square && (x.id as number) > 0);
return f ? (f.id as EntityId) : null;
}
}