/** * 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; } /** 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(presetId)`. Calls are * scoped: two presets calling `state.set("x", 1)` with different * preset IDs each get their own `x`. */ export interface PresetState> { /** Read a state key. Returns undefined when unset. */ get(key: K): T[K] | undefined; /** Write a state key. Overwrites any previous value. */ set(key: K, value: T[K]): void; /** Delete a state key. Returns true if it was set, false otherwise. */ delete(key: K): boolean; /** Returns the full state snapshot as a plain object. */ all(): Partial; /** True if `key` has been set. */ has(key: K): boolean; } class PresetStateImpl> implements PresetState { 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(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(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(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(key: K): boolean { return this.#session.contains(PRESET_STATE_ENTITY, this.#attrOf(key as string)); } all(): Partial { const result: Record = {}; 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; } } /** * 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 { 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(); /** * 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(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 = Record>( presetId: string, ): PresetState { return new PresetStateImpl(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( (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; } }