1160 lines
43 KiB
TypeScript
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;
|
|
}
|
|
}
|