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