Compare commits

..

No commits in common. "68835394263fc9118f1ad73d21d10e0b11ea3dc9" and "af9973adfab4b4cec45b2191c256a88cdac04af7" have entirely different histories.

25 changed files with 46 additions and 1868 deletions

View file

@ -47,11 +47,7 @@ import {
} from "./rules/draws.js";
import { applyCapture } from "./rules/capture.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 { ActivePresetSet } from "./presets/active-set.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";
@ -73,12 +69,6 @@ export type GameResult =
| "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";
export class ChessEngine {
@ -103,69 +93,6 @@ export class ChessEngine {
this.activePresets = activePresets ?? new ActivePresetSet();
}
/**
* 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));
for (const id of oldIds) {
if (newIds.has(id)) continue;
const def = PRESET_REGISTRY.get(id);
def?.onDeactivate?.(this);
}
for (const req of requests) {
if (oldIds.has(req.id)) continue;
const def = PRESET_REGISTRY.get(req.id);
def?.onActivate?.(this);
}
}
/**
* 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;
for (const preset of this.activePresets.getForColor(color)) {
if (!preset.onBeforeCapture) continue;
const result = preset.onBeforeCapture(this, attacker, target);
if (result && result.consume === true) consumed = true;
}
return consumed;
}
getCurrentTurn(): PieceColor {
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
}
@ -265,45 +192,19 @@ export class ChessEngine {
const isCastling = (move as CastlingMove).isCastling === true;
if (isEnPassant) {
// En passant captures the pawn on the SKIPPED square, not on
// `move.to`. Dispatch the preset hook against that off-square
// target so e.g. piece-hp can decrement HP on the captured pawn.
const capturedSquare =
color === "white" ? ((move.to - 8) as number) : ((move.to + 8) as number);
const capturedId = this.getPieceAt(capturedSquare);
const consumed =
capturedId !== null &&
this.tryInterceptCapture(move.pieceId, capturedId, color);
if (consumed) {
// Preset handled the capture (e.g. damaged the pawn). The
// attacker does NOT move — consuming the move as a "poke"
// ends the turn without a positional change.
} else {
applyEnPassantCapture(this.session, move, color);
}
applyEnPassantCapture(this.session, move, color);
} else if (isCastling) {
applyCastlingMove(this.session, move as CastlingMove);
} else {
// Normal move: handle capture, then update position. The preset
// capture hook is our chance to short-circuit the default
// retract-and-move behaviour (used by piece-hp for non-lethal
// damage). If any preset consumes the capture we skip BOTH the
// retraction AND the attacker's move: the preset turned the
// capture into a "poke" that just ends the turn.
let consumed = false;
// Normal move: handle capture, then update position
if (move.isCapture) {
const capturedId = this.getPieceAt(move.to);
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
applyCapture(this.session, capturedId);
}
applyCapture(this.session, capturedId);
}
}
if (!consumed) {
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
// Handle promotion (pawn reaching last rank)
@ -341,38 +242,13 @@ export class ChessEngine {
// 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 expired = this.activePresets.tickAfterMove(color);
for (const id of expired) {
const def = PRESET_REGISTRY.get(id);
def?.onDeactivate?.(this);
}
// 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.
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
def?.onAfterMove?.(this, color);
}
// half-move. Entries reaching 0 are removed.
this.activePresets.tickAfterMove(color);
return this.checkGameResult();
}
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.
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const override = def?.onCheckGameResult?.(this);
if (override !== undefined) return override;
}
const nextColor = this.getCurrentTurn();
if (isCheckmate(this.session, nextColor)) return "checkmate";
if (isStalemate(this.session, nextColor)) return "stalemate";

View file

@ -55,11 +55,7 @@ export function useChessEngine() {
// over the session (not a stored `InCheck` fact), so we call the
// helper directly against the side that just received the move.
const opponentColor = engine.getCurrentTurn();
const decisive =
result === 'checkmate' ||
result === 'white-wins' ||
result === 'black-wins';
if (decisive) {
if (result === 'checkmate') {
audio.play('checkmate');
} else if (isInCheck(engine.session, opponentColor)) {
audio.play('check');
@ -113,10 +109,7 @@ export function useChessEngine() {
* the local UI updates immediately (no server round-trip).
*/
const setPresets = useCallback((activations: PresetActivation[]) => {
// Route through setActivePresets so presets' onActivate /
// onDeactivate lifecycle hooks fire (piece-hp needs this to seed
// and clean up Hp facts). Fall through to autosave + re-render.
engine.setActivePresets(activations);
engine.activePresets.replaceAll(activations);
saveAutoSave(engine.session.allFacts());
setTick(t => t + 1);
}, [engine]);

View file

@ -197,13 +197,7 @@ export function useMultiplayerGame(code: string, token: string) {
const moveResult = eng.checkGameResult();
// Local sound playback for the moving player. The opponent's client
// plays its own sound off the `game.delta` event.
// Decisive results (checkmate AND variant wins) play the
// checkmate sound; ongoing / draws play the normal move sound.
const decisive =
moveResult === 'checkmate' ||
moveResult === 'white-wins' ||
moveResult === 'black-wins';
if (decisive) audio.play('checkmate');
if (moveResult === 'checkmate') audio.play('checkmate');
else audio.play('move');
return moveResult;
},

View file

@ -130,23 +130,10 @@ export class PredictionManager {
* previously-legal optimistic moves, and re-validating them here would
* duplicate server logic. Simpler to let the next user action
* re-predict against the freshly-synced base.
*
* We route through `setActivePresets` (not bare `activePresets.replaceAll`)
* so preset `onActivate` / `onDeactivate` lifecycle hooks fire on the
* client's base engine. That's essential for rules like `piece-hp`
* which install per-piece state (Hp facts) from onActivate — without
* this the client would know the preset is active but have no Hp
* facts to render, because `game.presets` carries only the
* activation list, not the resulting facts.
*
* Idempotence guarantee: preset hooks are expected to be idempotent
* (piece-hp.onActivate only inserts Hp when absent). Server and
* client run the same hook logic, so both arrive at the same state.
* The next `game.state` or `game.delta` reconciles any drift.
*/
private applyPresets(payload: GamePresetsPayload): void {
try {
this.baseEngine.setActivePresets(payload.activations);
this.baseEngine.activePresets.replaceAll(payload.activations);
} catch {
// A bad set from the server shouldn't crash the client; the server
// already validated, so this branch is defensive only. We clear

View file

@ -191,13 +191,8 @@ export class ActivePresetSet {
* This implements the "player-local turns" policy: a `scope=white`
* preset ticks only after white moves; `scope=both` ticks on every
* half-move.
*
* Returns the list of ids that expired during this tick so the engine
* can fire the preset `onDeactivate` lifecycle hook against them. The
* ActivePresetSet itself stays engine-unaware — the hook dispatch is
* strictly a ChessEngine concern.
*/
tickAfterMove(moverColor: "white" | "black"): string[] {
tickAfterMove(moverColor: "white" | "black"): void {
const toRemove: string[] = [];
for (const entry of this.entries.values()) {
if (entry.scope !== "both" && entry.scope !== moverColor) continue;
@ -210,7 +205,6 @@ export class ActivePresetSet {
}
}
for (const id of toRemove) this.entries.delete(id);
return toRemove;
}
/** All active entries, in registration order. Used by UI + wire sync. */

View file

@ -1,72 +1,10 @@
/**
* Preset: `capture-to-win` (First Blood, RULES.md rule #11)
*
* The first player to capture ANY enemy piece wins the game
* immediately. Checkmate is never reached in practice because any
* capture ends the game first.
*
* Wiring:
* - `onBeforeCapture` — record the capturer's color on the game
* entity via a `Winner` fact. Does NOT consume the capture, so the
* engine's default retract-and-move still runs (the target piece
* dies normally, the attacker advances onto its square). This
* keeps the final board state intuitive for players to inspect
* ("why is white's pawn on d5?") rather than freezing the board
* mid-capture.
* - `onCheckGameResult` — if a Winner fact was recorded, convert
* it into the corresponding GameResult. Otherwise return undefined
* and let the default checkmate/stalemate logic run (nothing
* to do until the first capture happens).
*
* Incompatible with `last-piece-standing` — both redefine "when is
* the game over" and would compete for the onCheckGameResult hook.
*/
import { PRESET_REGISTRY } from "./registry.js";
import { GAME_ENTITY, type PieceColor } from "../schema.js";
import type { ChessEngine, GameResult } from "../engine.js";
import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({
id: "capture-to-win",
name: "First Blood",
description:
"The first player to capture any enemy piece wins the game immediately. Makes every piece precious.",
description: "A player wins immediately upon capturing any enemy piece (first capture wins).",
incompatibleWith: ["last-piece-standing"],
requires: [],
onBeforeCapture(engine: ChessEngine, attacker: EntityId, _target: EntityId) {
const session = engine.session;
// Only record the first capture — don't stomp an earlier winner
// if multiple captures somehow occur in the same applyMove
// (shouldn't happen, but defensive).
if (session.contains(GAME_ENTITY, "Winner")) {
const existing = session.get(GAME_ENTITY, "Winner");
if (existing !== null) return; // already set, leave it
}
const colorFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "Color");
if (!colorFact) return;
session.insert(GAME_ENTITY, "Winner", colorFact.value as PieceColor);
// Don't consume — let the capture resolve normally so the board
// visibly reflects the killing blow.
},
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
const session = engine.session;
if (!session.contains(GAME_ENTITY, "Winner")) return undefined;
const winner = session.get(GAME_ENTITY, "Winner");
if (winner === "white") return "white-wins";
if (winner === "black") return "black-wins";
return undefined; // "draw" or null — let default logic run
},
onDeactivate(engine: ChessEngine) {
// Clear any stored winner when the preset is turned off so the
// game doesn't stay in a won state after the rule is lifted.
const session = engine.session;
if (session.contains(GAME_ENTITY, "Winner")) {
session.retract(GAME_ENTITY, "Winner");
}
},
// Win condition checked in ChessEngine (P3.11)
});

View file

@ -1,112 +1,10 @@
/**
* Preset: `explosive-rook` (Detonating Rook, RULES.md rule #10)
*
* When a rook captures, it detonates on the target square, removing
* every piece (friendly or enemy, excluding the capturing rook itself)
* within Chebyshev distance <= 2 on the same rank or file. Diagonal
* neighbours are NOT affected — detonation propagates along orthogonal
* rays only, matching the rook's own movement.
*
* Implementation via `onBeforeCapture`:
* - If the attacker isn't a rook, do nothing (default capture runs).
* - Otherwise consume the hook, then manually:
* 1. Remove the target piece.
* 2. Remove every piece within distance 2 on the target's rank or
* file (friendly or enemy; rook itself excluded).
* 3. Move the rook onto the target square (so the explosion
* visually "lands" there).
*
* Explosion does NOT chain — if a captured piece happened to be a
* second rook, its detonation does not re-trigger. Keeping the
* mechanic finite.
*
* Incompatible with `piece-hp` — HP's "capture deals 1 damage" and
* explosive-rook's "capture wipes AoE" are contradictory capture
* resolutions.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
import { fileOf, rankOf, squareOf } from "../coord.js";
import type { Square } from "../schema.js";
const DETONATION_RADIUS = 2;
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
function retractEntity(session: Session, id: EntityId): void {
for (const attr of PIECE_ATTRS) {
if (session.contains(id, attr)) session.retract(id, attr);
}
}
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
if (f.attr === "Position" && f.value === sq && (f.id as number) > 0) {
return f.id;
}
}
return null;
}
PRESET_REGISTRY.register({
id: "explosive-rook",
name: "Detonating Rook",
description:
"When a Rook captures, it detonates: every piece within 2 squares on the same rank or file is removed (friend and foe alike). Diagonals are spared.",
description: "When a Rook captures, it also removes all pieces within 2 squares on the same rank and file.",
incompatibleWith: ["piece-hp"],
requires: [],
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
const session = engine.session;
const attackerTypeFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "PieceType");
if (attackerTypeFact?.value !== "rook") return; // default capture runs
const targetPos = session.get(target, "Position") as Square | undefined;
if (targetPos === undefined) return;
const targetFile = fileOf(targetPos);
const targetRank = rankOf(targetPos);
// Collect detonation victims (orthogonal neighbours within radius).
// We include the target itself — it gets removed first. We skip
// the attacker so the rook survives.
const victims = new Set<EntityId>();
victims.add(target);
for (let d = 1; d <= DETONATION_RADIUS; d++) {
for (const [df, dr] of [
[d, 0], [-d, 0], [0, d], [0, -d],
] as const) {
const f = targetFile + df;
const r = targetRank + dr;
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
const sq = squareOf(f, r) as Square;
const id = pieceAtSquare(session, sq);
if (id === null) continue;
if (id === attacker) continue;
victims.add(id);
}
}
// Retract every victim's facts — their pieces are gone.
for (const v of victims) retractEntity(session, v);
// The rook still "captures" by moving onto the target square and
// has HasMoved set. Default engine path is consumed, so we apply
// these mutations ourselves.
session.insert(attacker, "Position", targetPos);
session.insert(attacker, "HasMoved", true);
return { consume: true };
},
// Full integration happens in ChessEngine (P3.11)
});

View file

@ -1,398 +0,0 @@
/**
* Integration tests for presets that were previously stubs:
* - king-heals (onAfterMove)
* - poisoned-squares (onAfterMove)
* - capture-to-win (onBeforeCapture + onCheckGameResult)
* - last-piece-standing (onCheckGameResult)
* - explosive-rook (onBeforeCapture with consume)
* - queen-splits (onBeforeCapture with consume, spawning)
*
* Each block tests one preset's mechanic end-to-end through the
* ChessEngine so we catch any hook-wiring regressions.
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { algebraicToSquare, squareOf } from "../coord.js";
import type { EntityId } from "@paratype/rete";
import type { Square } from "../schema.js";
function pieceAt(engine: ChessEngine, sq: string): EntityId | null {
const target = algebraicToSquare(sq);
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === target) return f.id;
}
return null;
}
function typeOf(engine: ChessEngine, id: EntityId): string | null {
if (!engine.session.contains(id, "PieceType")) return null;
return engine.session.get(id, "PieceType") as string;
}
function hpOf(engine: ChessEngine, id: EntityId): number | null {
if (!engine.session.contains(id, "Hp")) return null;
return engine.session.get(id, "Hp") as number;
}
/** Clear a rank of all pieces. Used to sculpt board positions cheaply. */
function clearRank(engine: ChessEngine, rank: number): void {
const retractIds: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "Position") continue;
if (Math.floor((f.value as number) / 8) !== rank) continue;
retractIds.push(f.id);
}
for (const id of retractIds) {
for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
}
}
}
// ─────────────────────────────────────────────────────────────────────
// king-heals
// ─────────────────────────────────────────────────────────────────────
describe("king-heals", () => {
it("heals the non-mover's king by 1 HP (max 3) per half-move when not in check", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "king-heals", scope: "both", turnsRemaining: null },
]);
// Damage black king to 1 HP so we can observe healing.
const blackKing = pieceAt(engine, "e8")!;
engine.session.insert(blackKing, "Hp", 1);
// White plays a neutral move. onAfterMove fires, sees black king
// not in check, heals +1 → HP 2.
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
expect(hpOf(engine, blackKing)).toBe(2);
// Black plays; white's king wasn't damaged so heal is a no-op
// (already at full 2 — unless we also damage it, but leave it).
// Then white plays again → black king heals to 3 (cap).
engine.applyMove(
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
);
expect(hpOf(engine, blackKing)).toBe(3);
// Further heals stop at the cap.
engine.applyMove(
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("h2"), algebraicToSquare("h3"))!,
);
expect(hpOf(engine, blackKing)).toBe(3);
});
it("does not heal a king currently in check", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "king-heals", scope: "both", turnsRemaining: null },
]);
// Scholar's-Mate-setup to get black king in check after Qh5.
// 1. e4 e5 2. Bc4 Nc6 3. Qh5 — threatens Qxf7# which is check.
// Actually Qh5 doesn't check; we want something that checks. Use
// 1. e4 d5 2. exd5 3. Qh5+ — now black king is in check.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
// Damage both kings to 1 so we can observe differential healing.
const whiteKing = pieceAt(engine, "e1")!;
const blackKing = pieceAt(engine, "e8")!;
engine.session.insert(whiteKing, "Hp", 1);
engine.session.insert(blackKing, "Hp", 1);
// White's move triggers heal on BLACK king (non-mover). Black not
// in check → heals to 2.
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
expect(hpOf(engine, blackKing)).toBe(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// poisoned-squares
// ─────────────────────────────────────────────────────────────────────
describe("poisoned-squares", () => {
it("damages a piece ending a half-move on d4/e4/d5/e5", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "poisoned-squares", scope: "both", turnsRemaining: null },
]);
// 1. e4 — white pawn lands on e4 which is poisoned. After white's
// move onAfterMove fires, poison damage applies → pawn goes from
// HP 2 to HP 1.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
const e4Pawn = pieceAt(engine, "e4")!;
expect(hpOf(engine, e4Pawn)).toBe(1);
// Black plays non-poisoned move — e4 pawn gets damaged AGAIN
// because it's still on the poisoned square at end of half-move.
engine.applyMove(engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!);
expect(pieceAt(engine, "e4")).toBeNull(); // pawn retracted at HP 0
});
it("does not damage pieces NOT on a poisoned square", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "poisoned-squares", scope: "both", turnsRemaining: null },
]);
engine.applyMove(engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!);
const a3Pawn = pieceAt(engine, "a3")!;
expect(hpOf(engine, a3Pawn)).toBe(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// capture-to-win
// ─────────────────────────────────────────────────────────────────────
describe("capture-to-win", () => {
it("first capture ends the game with the capturer winning", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
]);
// 1. e4 d5 2. exd5 — white captures first.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
const result = engine.applyMove(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
);
expect(result).toBe("white-wins");
});
it("game stays ongoing until any capture occurs", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
]);
const r1 = engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
expect(r1).toBe("ongoing");
const r2 = engine.applyMove(
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
expect(r2).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// last-piece-standing
// ─────────────────────────────────────────────────────────────────────
describe("last-piece-standing", () => {
it("game stays ongoing until one color has 0 pieces", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]);
expect(engine.checkGameResult()).toBe("ongoing");
});
it("white wins when black has no pieces left", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]);
// Scorched-earth: retract every black piece.
const blackIds: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "Color" || f.value !== "black") continue;
if ((f.id as number) <= 0) continue;
blackIds.push(f.id);
}
for (const id of blackIds) {
for (const attr of ["PieceType", "Color", "Position", "HasMoved"] as const) {
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
}
}
expect(engine.checkGameResult()).toBe("white-wins");
});
it("overrides default checkmate: cornered king is NOT game-over", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]);
// From the starting position the base engine isn't near checkmate
// yet, but the key point is checkGameResult never returns
// "checkmate" while last-piece-standing is active. Sanity check.
expect(engine.checkGameResult()).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// explosive-rook
// ─────────────────────────────────────────────────────────────────────
describe("explosive-rook", () => {
it("rook capture detonates orthogonal neighbours within distance 2", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "explosive-rook", scope: "both", turnsRemaining: null },
]);
// Sculpt a controlled board: clear everything from rank 4 so we
// can plant a rook + target + bystanders.
clearRank(engine, 3);
clearRank(engine, 4);
clearRank(engine, 5);
// White rook on a4 (file 0, rank 3). Black pawn on d4 (target).
// Black pawn on f4 (distance 2, survives? No — f4 is file 5, d4
// is file 3, Chebyshev along file = 2, so f4 IS in range).
// Black pawn on b5 (diagonal, should SURVIVE explosion).
// White pawn on d2 (distance 2 on file, should be detonated).
const whiteRook = pieceAt(engine, "a1")!;
engine.session.insert(whiteRook, "Position", squareOf(0, 3)); // a4
// Find any black pawn and reposition; need 4 black pawns for the test.
const blackPawns: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
const cfact = engine.session.allFacts().find(x => x.id === f.id && x.attr === "Color");
if (cfact?.value === "black") blackPawns.push(f.id);
}
engine.session.insert(blackPawns[0]!, "Position", squareOf(3, 3)); // d4 target
engine.session.insert(blackPawns[1]!, "Position", squareOf(5, 3)); // f4 in-range
engine.session.insert(blackPawns[2]!, "Position", squareOf(1, 4)); // b5 diagonal
// White rook captures on d4.
const capture = engine.findMove(
algebraicToSquare("a4"),
algebraicToSquare("d4"),
);
expect(capture).not.toBeNull();
engine.applyMove(capture!);
// d4 target: gone.
// f4: gone (file 3 -> 5 is distance 2 on file, rank unchanged).
// b5: survives (diagonal neighbour, not on d4's rank or file).
expect(pieceAt(engine, "d4")).toBe(whiteRook); // rook moved here
expect(pieceAt(engine, "f4")).toBeNull();
expect(pieceAt(engine, "b5")).not.toBeNull(); // diagonal survives
});
it("non-rook captures are unaffected (default capture runs)", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "explosive-rook", scope: "both", turnsRemaining: null },
]);
// 1. e4 d5 2. exd5 — pawn capture, no detonation.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
// d5 holds the white pawn. No adjacent pieces affected.
expect(typeOf(engine, pieceAt(engine, "d5")!)).toBe("pawn");
expect(pieceAt(engine, "e7")).not.toBeNull(); // black pawn intact
});
});
// ─────────────────────────────────────────────────────────────────────
// queen-splits
// ─────────────────────────────────────────────────────────────────────
describe("queen-splits", () => {
it("queen capture spawns a rook on target and a bishop on an empty neighbour", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "queen-splits", scope: "both", turnsRemaining: null },
]);
// Sculpt: white queen on d4, black pawn on e5 (diagonal capture).
// Neighbours of e5: d6, e6, f6, f5, f4, e4, d4 (WHITE QUEEN!), d5.
// Clockwise from N: e6 → f6 → f5 → f4 → e4 → d4 (queen — gone
// after fission, but at the moment of the clockwise scan it's
// already retracted) → d5 → d6. So the first empty neighbour
// after capture will be e6 (empty in starting position).
clearRank(engine, 3);
clearRank(engine, 4);
const whiteQueen = pieceAt(engine, "d1")!;
engine.session.insert(whiteQueen, "Position", squareOf(3, 3)); // d4
const blackPawns: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
const cfact = engine.session.allFacts().find(x => x.id === f.id && x.attr === "Color");
if (cfact?.value === "black") blackPawns.push(f.id);
}
engine.session.insert(blackPawns[0]!, "Position", squareOf(4, 4)); // e5
const capture = engine.findMove(
algebraicToSquare("d4"),
algebraicToSquare("e5"),
);
expect(capture).not.toBeNull();
engine.applyMove(capture!);
// Queen is gone.
expect(engine.session.contains(whiteQueen, "Position")).toBe(false);
// Target pawn is gone.
expect(pieceAt(engine, "e5")).not.toBe(blackPawns[0]!);
// A white rook sits on e5 (target square).
const onE5 = pieceAt(engine, "e5")!;
expect(typeOf(engine, onE5)).toBe("rook");
expect(engine.session.get(onE5, "Color")).toBe("white");
// A white bishop sits on one of e5's clockwise-N-first empty
// neighbours. Starting position has black pawns on rank 7 which
// is all occupied from e8 down to rank 7… wait, e5's N is e6
// which is empty. Assert we find a bishop adjacent.
const neighborFiles = [-1, 0, 1];
const neighborRanks = [-1, 0, 1];
let bishopFound = false;
for (const df of neighborFiles) {
for (const dr of neighborRanks) {
if (df === 0 && dr === 0) continue;
const sq = squareOf(4 + df, 4 + dr) as Square;
if (sq < 0 || sq > 63) continue;
const id = pieceAt(
engine,
String.fromCharCode(97 + 4 + df) + (5 + dr),
);
if (id === null) continue;
if (typeOf(engine, id) === "bishop") {
bishopFound = true;
break;
}
}
if (bishopFound) break;
}
expect(bishopFound).toBe(true);
});
it("non-queen captures are unaffected", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "queen-splits", scope: "both", turnsRemaining: null },
]);
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
// Pawn stays a pawn — no fission.
const onD5 = pieceAt(engine, "d5")!;
expect(typeOf(engine, onD5)).toBe("pawn");
});
});

View file

@ -1,62 +1,10 @@
/**
* Preset: `king-heals` (Regenerating King, RULES.md rule #9)
*
* After every half-move, check the king BELONGING TO THE NON-MOVER
* (i.e. the player about to move). If it's not currently in check,
* its HP regenerates by 1, capped at MAX_KING_HP.
*
* Design reading of RULES.md #9: "If the King ends a turn NOT in check,
* it gains +1 HP." The natural moment to apply this is AFTER a move
* has been applied but BEFORE the turn counter advances further. Our
* `onAfterMove` hook fires at that exact point. We heal the KING that
* just came off attack — i.e. the non-mover's king — because the
* mover's king couldn't have been in check (self-check filter prevents
* moves that leave your own king attacked).
*
* Requires `piece-hp`; without it there's no Hp attribute to heal.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor } from "../schema.js";
import { isInCheck } from "../rules/check.js";
const MAX_KING_HP = 3;
/** Find the king entity for a given color, or null if absent. */
function findKing(session: Session, color: PieceColor): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "king") continue;
const colorFact = facts.find(c => c.id === f.id && c.attr === "Color");
if (colorFact?.value === color) return f.id;
}
return null;
}
PRESET_REGISTRY.register({
id: "king-heals",
name: "Regenerating King",
description:
"After every half-move, the king whose turn it is to move regenerates +1 HP (max 3) — unless it's currently in check. Requires Hit Points.",
description: "If the King ends a turn NOT in check, it gains +1 HP (max 3 HP). Requires piece-hp.",
incompatibleWith: [],
requires: ["piece-hp"],
onAfterMove(engine: ChessEngine, moverColor) {
// The NON-mover's king is the one potentially healing: it's the
// king belonging to the player whose turn just arrived. Heal only
// if that king isn't currently in check (being in check denies the
// heal — an intentional game-design lever that makes aggressive
// play strategically meaningful).
const color: PieceColor = moverColor === "white" ? "black" : "white";
const kingId = findKing(engine.session, color);
if (kingId === null) return;
if (isInCheck(engine.session, color)) return;
const session = engine.session;
if (!session.contains(kingId, "Hp")) return; // piece-hp not active
const hp = session.get(kingId, "Hp") as number;
if (hp >= MAX_KING_HP) return;
session.insert(kingId, "Hp", hp + 1);
},
// Full integration happens in ChessEngine (P3.11)
});

View file

@ -1,73 +1,10 @@
/**
* Preset: `last-piece-standing` (Annihilation, RULES.md rule #12)
*
* The king has no special status. Checkmate is disabled. The player
* who captures ALL enemy pieces wins — meaning the opponent's piece
* count drops to 0 (king included).
*
* Wiring is pure `onCheckGameResult`:
* - Count pieces by color.
* - If one color has 0 pieces, the other color wins.
* - Otherwise return undefined — the game continues. We deliberately
* SUPPRESS the default checkmate and stalemate detection by
* returning "ongoing" whenever neither side is annihilated; this
* overrides the default `isCheckmate`/`isStalemate` checks in
* `engine.checkGameResult`.
*
* Note that `filterSelfCheckMoves` still runs in move generation, so
* players still can't move their king into check voluntarily. That's
* a UX concession: under pure Annihilation rules a suicidal king
* move is technically legal, but blocking it keeps the game readable
* (otherwise a blundering player could accidentally trap themselves
* with no legal moves). Future work could add a scope for "disable
* self-check filter" if we want stricter variant purity.
*
* Incompatible with `capture-to-win` — both redefine "when is the
* game over".
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine, GameResult } from "../engine.js";
PRESET_REGISTRY.register({
id: "last-piece-standing",
name: "Annihilation",
description:
"Checkmate is disabled. The player who captures ALL enemy pieces (king included) wins. Every capture counts.",
description: "The player who captures all enemy pieces wins. King has no special status; checkmate is disabled.",
incompatibleWith: ["capture-to-win"],
requires: [],
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
// Count Position facts per color. We use Position rather than
// PieceType because a piece "exists on the board" iff it has a
// position; retracted pieces (captured) have no Position fact.
const facts = engine.session.allFacts();
const colorById = new Map<number, string>();
for (const f of facts) {
if (f.attr === "Color") colorById.set(f.id as number, f.value as string);
}
let whiteCount = 0;
let blackCount = 0;
for (const f of facts) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue;
const color = colorById.get(f.id as number);
if (color === "white") whiteCount++;
else if (color === "black") blackCount++;
}
if (whiteCount === 0 && blackCount === 0) {
// Degenerate — both sides wiped simultaneously somehow. Call
// it a draw-by-annihilation.
return "draw-insufficient";
}
if (whiteCount === 0) return "black-wins";
if (blackCount === 0) return "white-wins";
// Neither side is annihilated — override the default checkmate/
// stalemate detection by declaring the game ongoing. Without this
// return, the engine would fall through to isCheckmate which
// could spuriously end the game under pure FIDE rules.
return "ongoing";
},
// Win condition checked in ChessEngine (P3.11)
});

View file

@ -1,251 +0,0 @@
/**
* Integration tests for the `piece-hp` preset.
*
* Covers the lifecycle hooks (onActivate / onDeactivate) AND the
* capture-interception hook (onBeforeCapture → consume). Tests use
* `engine.setActivePresets(...)` so lifecycle hooks fire; using
* `.activePresets.replaceAll` directly would bypass them.
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { algebraicToSquare } from "../coord.js";
import type { EntityId } from "@paratype/rete";
/** Find the piece currently on a given algebraic square; null if empty. */
function pieceAt(engine: ChessEngine, sq: string): EntityId | null {
const target = algebraicToSquare(sq);
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === target) {
return f.id;
}
}
return null;
}
function hpOf(engine: ChessEngine, id: EntityId): number | null {
if (!engine.session.contains(id, "Hp")) return null;
return engine.session.get(id, "Hp") as number;
}
describe("piece-hp preset — lifecycle hooks", () => {
it("onActivate seeds Hp=2 on every piece", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
// 32 pieces on the starting board; every one should have Hp=2.
const pieces = engine.session.allFacts().filter(
(f) => f.attr === "PieceType",
);
expect(pieces.length).toBe(32);
for (const p of pieces) {
expect(engine.session.contains(p.id, "Hp")).toBe(true);
expect(engine.session.get(p.id, "Hp")).toBe(2);
}
});
it("onDeactivate retracts Hp from every piece", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
engine.setActivePresets([]);
for (const f of engine.session.allFacts()) {
if (f.attr === "PieceType") {
expect(engine.session.contains(f.id, "Hp")).toBe(false);
}
}
});
it("onActivate is idempotent — doesn't stomp existing HP values", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
// Manually damage a piece to Hp=1.
const e2Pawn = pieceAt(engine, "e2")!;
engine.session.insert(e2Pawn, "Hp", 1);
// Re-running setActivePresets with the same set should be a no-op
// for Hp (our transition logic says "id present in both → no hook").
// But even if someone calls onActivate directly via a future code
// path, the idempotence guard ensures Hp=1 stays.
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
expect(engine.session.get(e2Pawn, "Hp")).toBe(1);
});
it("scope or duration change on an already-active preset does NOT re-fire onActivate", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
const e2Pawn = pieceAt(engine, "e2")!;
engine.session.insert(e2Pawn, "Hp", 1);
// Change scope only — lifecycle should not fire; Hp=1 preserved.
engine.setActivePresets([
{ id: "piece-hp", scope: "white", turnsRemaining: null },
]);
expect(engine.session.get(e2Pawn, "Hp")).toBe(1);
});
});
describe("piece-hp preset — capture interception", () => {
it("non-lethal capture: target loses 1 HP, attacker stays, turn passes", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
// Scholars-Mate-style: 1. e4 e5 2. Bc4 Nc6 3. Qh5 … but we want a
// capture in a couple moves. Easiest: 1. e4 d5 2. exd5 — white
// pawn on e4 captures black pawn on d5.
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
);
const e4Pawn = pieceAt(engine, "e4")!;
const d5Pawn = pieceAt(engine, "d5")!;
expect(hpOf(engine, d5Pawn)).toBe(2);
// Attempt exd5. With piece-hp active, target starts at 2 HP → goes
// to 1; non-lethal, attacker stays on e4, d5 pawn still there.
const capture = engine.findMove(
algebraicToSquare("e4"),
algebraicToSquare("d5"),
);
expect(capture).not.toBeNull();
engine.applyMove(capture!);
// Attacker did NOT move: e4 still occupied.
expect(pieceAt(engine, "e4")).toBe(e4Pawn);
// Target still there.
expect(pieceAt(engine, "d5")).toBe(d5Pawn);
// Target lost 1 HP.
expect(hpOf(engine, d5Pawn)).toBe(1);
// Turn advanced to black.
expect(engine.getCurrentTurn()).toBe("black");
});
it("lethal capture: target at 1 HP is fully removed, attacker moves in", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
);
// Hand-damage the d5 pawn down to 1 HP so the next capture is lethal.
const d5Pawn = pieceAt(engine, "d5")!;
engine.session.insert(d5Pawn, "Hp", 1);
const capture = engine.findMove(
algebraicToSquare("e4"),
algebraicToSquare("d5"),
);
engine.applyMove(capture!);
// e4 now empty, d5 now holds the white pawn (standard capture
// semantics apply when HP reaches 0).
expect(pieceAt(engine, "e4")).toBeNull();
expect(pieceAt(engine, "d5")).not.toBeNull();
expect(pieceAt(engine, "d5")).not.toBe(d5Pawn); // d5 pawn retracted
});
it("repeated non-lethal captures drain HP to 0, third capture kills", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
// Build a position where white and black pieces can repeatedly
// poke each other. Easiest setup: clear the board, put a white
// pawn on e4 and black pawn on d5, then alternate captures.
// Actually we'll use natural play:
// 1. e4 d5 2. exd5 (d5 → HP 1) 3. ... ... tricky without
// alternation. Simpler: directly test with a contrived board.
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
);
const d5Pawn = pieceAt(engine, "d5")!;
// First poke: HP 2 → 1.
engine.applyMove(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
);
expect(hpOf(engine, d5Pawn)).toBe(1);
expect(pieceAt(engine, "d5")).toBe(d5Pawn);
// Black's turn. Black needs to play any move so white can poke again.
engine.applyMove(
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
);
// Second poke: HP 1 → 0, lethal. d5 pawn dies, white pawn moves in.
engine.applyMove(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
);
expect(pieceAt(engine, "e4")).toBeNull();
const nowOnD5 = pieceAt(engine, "d5");
expect(nowOnD5).not.toBeNull();
expect(nowOnD5).not.toBe(d5Pawn);
});
it("deactivating after damage leaves pieces un-healed but without Hp attribute", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
);
const d5Pawn = pieceAt(engine, "d5")!;
expect(hpOf(engine, d5Pawn)).toBe(1);
// Toggle off.
engine.setActivePresets([]);
// Hp fact should be gone from all pieces including the damaged one.
expect(engine.session.contains(d5Pawn, "Hp")).toBe(false);
// Next capture attempt should be lethal via standard rules.
engine.applyMove(
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
);
// White to play; need a white move. Actually turn is white here
// (black just played a6). Move a white pawn back into the fray:
const whiteMove = engine.findMove(
algebraicToSquare("a2"),
algebraicToSquare("a3"),
);
engine.applyMove(whiteMove!);
// Without piece-hp, normal chess semantics resume.
expect(
engine.session.allFacts().some((f) => f.attr === "Hp"),
).toBe(false);
});
});

View file

@ -1,103 +1,10 @@
/**
* Preset: `piece-hp` (Hit Points, RULES.md rule #13)
*
* Every piece starts with 2 HP. A capture deals 1 HP damage; the
* target only dies (is removed from the board) when its HP reaches 0.
* While the target still has HP, the capturing piece does NOT move —
* the capture attempt becomes a "poke": turn consumed, target damaged,
* attacker stays put. This is the canonical variant semantics from
* the v0 design notes.
*
* How it integrates
* ─────────────────
* - onActivate: assert `Hp = 2` on every existing entity on the board.
* Idempotent: only assigns to entities that don't already have an Hp
* fact, so replaying activate on an already-HP-loaded session (e.g.
* after loading server state that included Hp) doesn't reset everyone.
*
* - onDeactivate: retract Hp from every entity. Symmetric cleanup so
* toggling the preset off mid-game returns the board to the standard
* "captures are lethal" behaviour without leaving stale attributes.
*
* - onBeforeCapture: decrement the target's Hp. If the new Hp is still
* positive, consume the capture (engine skips the default retract +
* attacker-move path). If Hp reaches 0, return without consuming so
* the engine falls through to `applyCapture` and the piece is removed
* normally.
*
* Incompatibilities: explosive-rook (different capture resolution model
* — AoE instant removal vs. single-target damage).
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
/** Starting HP for every piece. Future work: make this per-type so
* pawns have 1 HP and queens have 3, etc. */
const DEFAULT_HP = 2;
/** Find every entity on the board that could reasonably be a piece
* (has PieceType + Color + Position). Game-level entity (id 0) is
* excluded. */
function iteratePieceIds(session: Session): EntityId[] {
const facts = session.allFacts();
const ids = new Set<EntityId>();
for (const f of facts) {
if (f.attr === "PieceType" && (f.id as number) > 0) {
ids.add(f.id);
}
}
return [...ids];
}
PRESET_REGISTRY.register({
id: "piece-hp",
name: "Hit Points",
description:
"Every piece has 2 HP. Captures deal 1 damage instead of removing the target. A piece only dies when its HP hits 0; otherwise the capturing piece stays put and turn passes.",
description: "All pieces start with 2 HP. Captures deal 1 HP damage; piece only dies at 0 HP. Attacker stays on target if HP > 0.",
incompatibleWith: ["explosive-rook"],
requires: [],
onActivate(engine: ChessEngine) {
const session = engine.session;
for (const id of iteratePieceIds(session)) {
// Idempotent: skip entities that already have an Hp fact, so
// syncing from server state that already includes Hp doesn't
// stomp on the authoritative values.
if (session.contains(id, "Hp")) continue;
session.insert(id, "Hp", DEFAULT_HP);
}
},
onDeactivate(engine: ChessEngine) {
const session = engine.session;
for (const id of iteratePieceIds(session)) {
if (session.contains(id, "Hp")) {
session.retract(id, "Hp");
}
}
},
onBeforeCapture(engine: ChessEngine, _attacker: EntityId, target: EntityId) {
const session = engine.session;
// If for any reason the target lacks an Hp fact (shouldn't happen
// once onActivate ran, but defensive), install the default so we
// still behave predictably.
const current = session.contains(target, "Hp")
? (session.get(target, "Hp") as number)
: DEFAULT_HP;
const next = current - 1;
if (next > 0) {
// Non-lethal: update HP, consume the capture so the engine
// skips its default retract-and-move path.
session.insert(target, "Hp", next);
return { consume: true };
}
// Lethal: let the engine fall through to its default capture.
// The attacker moves onto the target's square and the target is
// retracted (including its Hp fact, because PIECE_ATTRS includes
// "Hp"). No return value needed; undefined === don't consume.
return;
},
// Full integration in ChessEngine (P3.11)
});

View file

@ -1,82 +0,0 @@
/**
* UI overlay for the `piece-hp` preset: a row of pip dots above each
* piece showing its current HP.
*
* Filled (solid) dot = remaining HP.
* Empty (hollow) dot = lost HP.
*
* Design choices:
* - Pip dots instead of a bar: stays readable at every zoom and
* scales naturally if we later increase max HP beyond 2. The
* render is resolution-independent SVG-like CSS (no image asset).
* - Color matches the piece color (white pips for white pieces,
* dark pips for black) so the affordance sits on the piece
* visually rather than competing with it.
* - Positioned at the TOP of the cell, slightly clipping above the
* piece image. The piece image uses 85% of the cell area so
* there's room; the overlay sits at roughly 5% from the top edge.
*/
import { registerPieceOverlay } from "../ui/preset-overlays.js";
import type { PieceOverlayProps } from "../ui/preset-overlays.js";
/** Max HP we expect to display. If the mechanic ever bumps starting
* HP past this we'll render a row of `maxHp` pips, not truncate. */
const DEFAULT_MAX_HP = 2;
function HealthBarPips({ pieceFacts }: PieceOverlayProps) {
// Pull HP + Color from the pre-filtered piece facts. If Hp is
// absent the overlay renders nothing — means the preset isn't
// actually wired on this piece (yet).
const hp = pieceFacts.find((f) => f.attr === "Hp")?.value as
| number
| undefined;
if (hp === undefined) return null;
const color = pieceFacts.find((f) => f.attr === "Color")?.value as
| "white"
| "black"
| undefined;
// Max HP is implicit: we show the greater of DEFAULT_MAX_HP and the
// piece's current Hp (in case some future preset heals above the cap).
const maxHp = Math.max(DEFAULT_MAX_HP, hp);
// Pip colors: white pieces get dark pips (sits on the light piece),
// black pieces get light pips. Both outlined with the contrasting
// color for legibility on either square color.
const filledClass =
color === "black"
? "bg-white border border-neutral-900"
: "bg-neutral-900 border border-white";
const emptyClass =
color === "black"
? "bg-transparent border border-white/60"
: "bg-transparent border border-neutral-900/60";
return (
<div
data-role="hp-overlay"
// Absolute so it sits at the top of the cell without affecting
// the piece's flex centering. pointer-events-none because HP
// pips shouldn't intercept drags or hovers.
className="absolute top-1 left-1/2 -translate-x-1/2 flex gap-[3px] pointer-events-none z-30"
>
{Array.from({ length: maxHp }, (_, i) => {
const filled = i < hp;
return (
<span
key={i}
data-role={filled ? "hp-pip-full" : "hp-pip-empty"}
className={`w-[7px] h-[7px] rounded-full shadow-sm ${
filled ? filledClass : emptyClass
}`}
/>
);
})}
</div>
);
}
// Register at module init. Consumers side-effect-import this file to
// populate the overlay registry.
registerPieceOverlay("piece-hp", HealthBarPips);

View file

@ -1,78 +1,13 @@
/**
* Preset: `poisoned-squares` (Poisoned Centre, RULES.md rule #15)
*
* The four central squares (d4, d5, e4, e5) are poisoned. Any piece
* STANDING on one of those squares at the end of a half-move loses
* 1 HP. If its HP reaches 0 the piece dies (retract all its facts).
*
* Why "end of half-move" and not "end of full turn": the poison needs
* to be visible to both players per move — if it only ticked every
* other half-move, a player could briefly occupy a poisoned square
* during their own turn without consequence. Applying damage every
* half-move means every piece pays 1 HP per time-step on a poisoned
* square, which matches the "per turn" wording in RULES.md #15 (here
* "turn" means "half-move" in chess parlance).
*
* Requires `piece-hp` — without an Hp attribute there's nothing to
* damage. The registry validates the dependency when the preset is
* activated via setActivePresets.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { EntityId } from "@paratype/rete";
/** Poisoned central squares: d4=27, e4=28, d5=35, e5=36. Exported for
* the UI layer to render visual poison cues on the same squares. */
export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
/** Attributes to retract when a piece dies of poison. Kept in sync with
* the engine's capture.PIECE_ATTRS list; we can't import it directly
* because poisoned-squares is a pure-data preset and `capture.ts`
* depends on rete/session. */
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
// Poisoned central squares: d4=27, e4=28, d5=35, e5=36
export const POISONED_SQUARES = new Set([27, 28, 35, 36]);
PRESET_REGISTRY.register({
id: "poisoned-squares",
name: "Poisoned Centre",
description:
"The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one loses 1 HP per move. Requires Hit Points.",
description: "The four central squares (d4, d5, e4, e5) are poisoned. A piece ending its turn there loses 1 HP per turn. Requires piece-hp.",
incompatibleWith: [],
requires: ["piece-hp"],
onAfterMove(engine: ChessEngine) {
const session = engine.session;
const facts = session.allFacts();
// Collect pieces standing on poisoned squares. We snapshot the
// list BEFORE mutating anything so HP decrements don't interact
// with iteration semantics.
const toPoison: Array<{ id: EntityId; hp: number }> = [];
for (const f of facts) {
if (f.attr !== "Position") continue;
if (!POISONED_SQUARES.has(f.value as number)) continue;
if ((f.id as number) <= 0) continue; // game entity
if (!session.contains(f.id, "Hp")) continue; // shouldn't happen
const hp = session.get(f.id, "Hp") as number;
toPoison.push({ id: f.id, hp });
}
for (const { id, hp } of toPoison) {
const next = hp - 1;
if (next > 0) {
session.insert(id, "Hp", next);
} else {
// HP hit 0 — piece dies. Retract all attributes mirroring
// the normal capture path.
for (const attr of PIECE_ATTRS) {
if (session.contains(id, attr)) session.retract(id, attr);
}
}
}
},
// HP damage applied in ChessEngine post-move hook (P3.11)
});

View file

@ -1,31 +0,0 @@
/**
* UI overlay for `poisoned-squares`: a green-tinted haze on d4, d5,
* e4, e5 so players can tell at a glance which squares will damage
* pieces that end a turn there.
*
* Design: low-saturation green with a skull-like pattern? For v1 we
* just use a tinted overlay with animated opacity pulsing so the
* poison feels alive. Skulls are future polish.
*/
import { motion } from "motion/react";
import { registerSquareOverlay } from "../ui/preset-overlays.js";
import type { SquareOverlayProps } from "../ui/preset-overlays.js";
import { POISONED_SQUARES } from "./poisoned-squares.js";
function PoisonOverlay({ square }: SquareOverlayProps) {
if (!POISONED_SQUARES.has(square)) return null;
return (
<motion.div
data-role="poison-overlay"
className="absolute inset-0 pointer-events-none z-[5]"
style={{
background:
"radial-gradient(circle, rgba(132, 204, 22, 0.35) 0%, rgba(132, 204, 22, 0.15) 60%, transparent 100%)",
}}
animate={{ opacity: [0.7, 1, 0.7] }}
transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
/>
);
}
registerSquareOverlay("poisoned-squares", PoisonOverlay);

View file

@ -1,147 +1,10 @@
/**
* Preset: `queen-splits` (Queen Fission, RULES.md rule #8)
*
* When a queen captures, it FISSIONS into a rook (placed on the
* capture square) and a bishop (placed on the first empty adjacent
* square, clockwise from north). The queen entity is retracted, the
* target enemy piece is retracted, two new piece entities are
* spawned. If no adjacent square is empty the bishop is forfeit and
* only the rook spawns.
*
* Implementation via `onBeforeCapture`:
* - If the attacker isn't a queen, do nothing (default capture runs).
* - Otherwise consume the hook and:
* 1. Retract the enemy target's facts (normal capture).
* 2. Retract the queen's facts (she fissioned).
* 3. Spawn a new rook on the target square (fresh entity id).
* 4. Find the first empty adjacent square scanning clockwise
* from N (0,+1), NE (+1,+1), E (+1,0), SE (+1,-1),
* S (0,-1), SW (-1,-1), W (-1,0), NW (-1,+1).
* If found, spawn a bishop there. If all 8 neighbours are
* occupied, no bishop spawns.
*
* Fissioned pieces receive `HasMoved = true` so they cannot castle
* (even if the original queen was on a castle-related square — a
* weird edge case, but the invariant is simpler this way).
*
* Interaction with `piece-hp`: if piece-hp is also active (currently
* allowed — no hard incompatibility), the spawned pieces get Hp=2
* via the onActivate idempotence guarantee. Strictly speaking a more
* principled design would have a dedicated `onPieceSpawn` hook
* chain; for v1 we rely on the convention that piece-hp's
* `onActivate` only installs Hp on pieces that don't already have
* it, so calling it re-idempotently covers newly-spawned pieces too.
*
* IMPORTANT: spawning new entities mid-capture means this preset
* writes new facts to the session that didn't exist when the move
* was legally validated. That's fine — queen captures are validated
* against the pre-fission state; fission is a post-capture
* consequence, not a move generator.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
import { fileOf, rankOf, squareOf } from "../coord.js";
import type { Square, PieceColor } from "../schema.js";
/** Clockwise-from-north neighbour offsets. Order matters: bishop is
* placed on the FIRST empty square found by walking this list. */
const CLOCKWISE_NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [
[0, 1], // N
[1, 1], // NE
[1, 0], // E
[1, -1], // SE
[0, -1], // S
[-1, -1], // SW
[-1, 0], // W
[-1, 1], // NW
];
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
function retractEntity(session: Session, id: EntityId): void {
for (const attr of PIECE_ATTRS) {
if (session.contains(id, attr)) session.retract(id, attr);
}
}
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
if (f.attr === "Position" && f.value === sq && (f.id as number) > 0) {
return f.id;
}
}
return null;
}
/** Spawn a new piece entity. Returns the new id. */
function spawnPiece(
session: Session,
type: "rook" | "bishop",
color: PieceColor,
square: Square,
): EntityId {
const id = session.nextId();
session.insert(id, "PieceType", type);
session.insert(id, "Color", color);
session.insert(id, "Position", square);
session.insert(id, "HasMoved", true);
return id;
}
PRESET_REGISTRY.register({
id: "queen-splits",
name: "Queen Fission",
description:
"When a Queen captures, she splits: a Rook takes her place on the capture square and a Bishop is placed on the first empty adjacent square (clockwise from north).",
description: "When a Queen captures a piece, it splits into a Rook and Bishop placed on nearby empty squares.",
incompatibleWith: [],
requires: [],
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
const session = engine.session;
const attackerTypeFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "PieceType");
if (attackerTypeFact?.value !== "queen") return; // default capture runs
const attackerColorFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "Color");
if (attackerColorFact === undefined) return;
const attackerColor = attackerColorFact.value as PieceColor;
const targetPos = session.get(target, "Position") as Square | undefined;
if (targetPos === undefined) return;
// Retract target (normal capture) and queen (she's fissioning).
retractEntity(session, target);
retractEntity(session, attacker);
// Spawn the rook on the capture square.
spawnPiece(session, "rook", attackerColor, targetPos);
// Find the first empty adjacent square clockwise from N and spawn
// the bishop there. If all 8 are occupied (rare — usually happens
// in dense midgame around a king), the bishop is forfeit.
const tFile = fileOf(targetPos);
const tRank = rankOf(targetPos);
for (const [df, dr] of CLOCKWISE_NEIGHBOURS) {
const f = tFile + df;
const r = tRank + dr;
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
const sq = squareOf(f, r) as Square;
if (pieceAtSquare(session, sq) !== null) continue;
spawnPiece(session, "bishop", attackerColor, sq);
break;
}
return { consume: true };
},
// Full integration happens in ChessEngine (P3.11)
});

View file

@ -1,60 +1,18 @@
/**
* Preset rule registry (P3.4).
*
* A preset is a modifier to chess rules. Presets expose optional hooks
* that the ChessEngine invokes at well-defined points. The full menu:
* A preset is a modifier to chess rules. Each preset exposes one or more
* hooks that the ChessEngine (P3.11) will call during move generation:
*
* Move-generation hooks (per-piece, on every getAllLegalMoves call):
* - getExtraMoves(engine, pieceId) -> LegalMove[]
* Contribute extra legal moves (e.g. wrap-board, knights-leap-twice).
* - filterMoves(moves, engine, pieceId) -> LegalMove[]
* Remove/modify moves from the aggregated list (e.g. knight-immunity).
*
* Lifecycle hooks (fire once per state transition):
* - onActivate(engine)
* Called when the preset transitions from inactive -> active. Use this
* to seed per-piece state, e.g. `piece-hp` inserts `Hp = 2` on every
* existing entity here.
* - onDeactivate(engine)
* Called when the preset transitions from active -> inactive, either
* because the user toggled it off or because its turn-timer expired.
* Symmetric cleanup point (retract custom attributes, etc.).
*
* Capture-interception hook (per-capture, main-session only):
* - onBeforeCapture(engine, attacker, target) -> { consume?: boolean } | void
* Fires immediately before the engine would retract the target's
* piece facts. Returning `{ consume: true }` tells the engine
* "I've handled this capture, skip your default retract-and-move
* behaviour"; the attacker will NOT move and the target will NOT
* be removed. The preset itself decides what to do (decrement an
* HP attribute, explode adjacent squares, etc.). Anything else
* (undefined, `{}`, `{ consume: false }`) lets the engine continue
* with the normal capture path.
*
* IMPORTANT: this hook only fires from `ChessEngine.applyMove` on
* the authoritative session. The self-check filter uses an isolated
* snapshot session and deliberately bypasses the hook — otherwise
* every move-legality check would fire preset side-effects.
*
* Overall design intent: these hooks let a preset react to state changes
* without coupling the engine to any specific rule. Adding a new rule with
* custom state + custom captures + custom UI should be possible without
* touching `engine.ts` at all — see `./piece-hp.ts` + `./piece-hp.ui.tsx`
* for the canonical example.
* - getExtraMoves: returns ADDITIONAL legal moves for a piece.
* - filterMoves: removes/modifies entries in an already-computed move list.
*
* Presets register themselves via side-effect imports (see `./index.ts`).
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine, GameResult } from "../engine.js";
import type { ChessEngine } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
/** Return shape for onBeforeCapture. `consume: true` skips the engine's
* default capture path (no target retraction, no attacker move).
* `consume: false` / undefined continues normally. */
export interface CaptureHookResult {
readonly consume?: boolean;
}
export interface PresetDef {
readonly id: string;
readonly name: string;
@ -63,57 +21,14 @@ export interface PresetDef {
readonly incompatibleWith: readonly string[];
/** Preset IDs that must also be active for this one to be valid. */
readonly requires: readonly string[];
// ── Move-generation hooks ────────────────────────────────────────────
/** Returns additional legal moves for a piece (called per-piece). */
readonly getExtraMoves?: (engine: ChessEngine, pieceId: EntityId) => LegalMove[];
/** Filters/modifies the aggregated move list for a piece. */
readonly filterMoves?: (
moves: LegalMove[],
engine: ChessEngine,
pieceId: EntityId,
) => LegalMove[];
// ── Lifecycle hooks ──────────────────────────────────────────────────
readonly onActivate?: (engine: ChessEngine) => void;
readonly onDeactivate?: (engine: ChessEngine) => void;
// ── Capture-interception hook ────────────────────────────────────────
readonly onBeforeCapture?: (
engine: ChessEngine,
attacker: EntityId,
target: EntityId,
) => CaptureHookResult | void;
/**
* Fires after every successful `applyMove`, after turn advancement
* and tickAfterMove but before checkGameResult. `moverColor` is the
* color that just moved. Use this for "end of turn" regeneration,
* status-effect processing, etc.
*
* Unlike the move-generation hooks, this fires on ALL active
* presets regardless of scope. Each preset is responsible for
* inspecting its own scope (via `engine.activePresets.list()`) if it
* needs scope-aware behaviour — scope semantics for "something that
* happens at turn boundaries" aren't one-size-fits-all (king-heals
* wants to affect the non-mover; poisoned-squares damages the
* mover).
*/
readonly onAfterMove?: (
engine: ChessEngine,
moverColor: "white" | "black",
) => void;
/**
* Hook into terminal-position detection. Return a concrete `GameResult`
* to OVERRIDE the engine's default checkmate/stalemate/draw logic;
* return `undefined` to let the default run. Multiple presets may
* register; the first one returning a non-undefined value wins. Order
* follows registration order (see PRESET_REGISTRY.getAll()).
*
* Used by `capture-to-win` (first capture sets the winner) and
* `last-piece-standing` (annihilation replaces checkmate), which both
* redefine "when is the game over".
*/
readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined;
}
/**

View file

@ -1,22 +0,0 @@
/**
* UI-only barrel for preset visual overlays.
*
* This file is imported EXACTLY ONCE by the app entry point (Board.tsx
* or App.tsx). It side-effect imports every `.ui.tsx` file so they can
* register their overlay components in the UI registry. Split from
* `./index.ts` so non-UI consumers (engine tests, server) don't drag
* React into their bundle.
*
* To add a new visually-rich preset:
* 1. Implement the mechanic in `packages/chess/src/presets/foo.ts`.
* 2. Implement the overlay in `packages/chess/src/presets/foo.ui.tsx`
* and call `registerPieceOverlay('foo', FooOverlay)` at module
* scope.
* 3. Add a side-effect import here.
*
* No changes to engine.ts or Board.tsx required.
*/
import "./piece-hp.ui.js";
import "./poisoned-squares.ui.js";
// Future rules with per-piece overlays add their .ui import here.

View file

@ -5,13 +5,6 @@ import type { LegalMove } from '../rules/types';
import { Piece } from './Piece';
import { AnimatePresence, motion } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
import {
getActivePieceOverlays,
getActiveSquareOverlays,
type PieceOverlayComponent,
type SquareOverlayComponent,
} from './preset-overlays';
import '../presets/ui-overlays-index';
interface BoardProps {
facts: ChessFact[];
@ -26,9 +19,6 @@ interface BoardProps {
* callers pass the hook's return value directly without stripping keys. */
lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined;
checkedKingSquare?: number | null | undefined;
/** Currently-active preset ids. Used to look up registered per-piece
* overlay components (e.g. HP pips for piece-hp). Order preserved. */
activePresetIds?: ReadonlyArray<string>;
}
interface PieceState {
@ -37,33 +27,7 @@ interface PieceState {
color: PieceColor;
}
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds }: BoardProps) {
// Pre-compute overlay components once per render — lookup is cheap
// but doing it once in a useMemo keeps the Piece render path clean.
const overlays: PieceOverlayComponent[] = useMemo(
() => getActivePieceOverlays(activePresetIds ?? []),
[activePresetIds],
);
const squareOverlays: SquareOverlayComponent[] = useMemo(
() => getActiveSquareOverlays(activePresetIds ?? []),
[activePresetIds],
);
// Group facts by entity id ONCE per render. Overlays want per-piece
// fact arrays and rebuilding the index inline per square would be
// O(squares × facts). The map is reused for the pieces-by-square
// construction below too.
const factsById = useMemo(() => {
const map = new Map<number, ChessFact[]>();
for (const f of facts) {
const id = f.id as number;
if (id <= 0) continue; // skip game entity
const arr = map.get(id);
if (arr) arr.push(f);
else map.set(id, [f]);
}
return map;
}, [facts]);
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare }: BoardProps) {
// Build pieces map: square -> { id, type, color }
const pieces = useMemo(() => {
const map = new Map<number, PieceState>();
@ -233,13 +197,6 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
)}
{/* Per-square preset overlays (poison tint, etc.). Each is
a pure function of the square index; overlays decide for
themselves whether to render on any given cell. */}
{squareOverlays.map((SquareOverlay, i) => (
<SquareOverlay key={`sq-${i}`} square={sq} />
))}
{/*
* Legal-target affordance — two visual forms:
* - Quiet move (empty destination): small central dot
@ -322,8 +279,6 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
piece.color === turn &&
(myColor === null || myColor === undefined || piece.color === myColor)
}
overlays={overlays}
pieceFacts={factsById.get(piece.id) ?? []}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>

View file

@ -134,13 +134,9 @@ function GameLayout({
const isGameOver = result !== 'ongoing';
// Confetti on any decisive win (checkmate or variant win condition).
// Confetti on checkmate
useEffect(() => {
const isWin =
result === 'checkmate' ||
result === 'white-wins' ||
result === 'black-wins';
if (isWin) {
if (result === 'checkmate') {
const duration = 3000;
const end = Date.now() + duration;
@ -341,19 +337,7 @@ function GameLayout({
data-testid="game-over"
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
>
{
// Preset variants ('white-wins', 'black-wins') name
// the winner explicitly because the variant may end
// on a capture rather than a checkmate. Standard
// chess result strings stay as-is.
result === 'checkmate'
? 'Checkmate!'
: result === 'white-wins'
? 'White wins!'
: result === 'black-wins'
? 'Black wins!'
: `Draw: ${result.replace('draw-', '')}`
}
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
</motion.div>
)}
</AnimatePresence>
@ -368,7 +352,6 @@ function GameLayout({
onMove={handleMove}
lastMove={lastMove}
checkedKingSquare={checkedKingSquare}
activePresetIds={activations.map((a) => a.id)}
/>
{/* Overlay for game over to prevent further interaction visually */}

View file

@ -1,4 +1,4 @@
import type { ChessAttrMap, ChessFact, PieceColor, PieceType } from '../schema';
import type { PieceColor, PieceType } from '../schema';
import { pieceAssets } from '../assets/pieces';
import {
motion,
@ -6,9 +6,8 @@ import {
useSpring,
useTransform,
} from 'motion/react';
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { DragEvent as ReactDragEvent } from 'react';
import type { PieceOverlayComponent } from './preset-overlays';
export interface PieceProps {
color: PieceColor;
@ -19,14 +18,6 @@ export interface PieceProps {
* ongoing, etc). When false, drag is disabled and the piece shows a
* default cursor. */
isDraggable: boolean;
/** Per-piece preset overlays (HP pips, ammo counter, etc.). Rendered
* INSIDE the drag-transform layer so they follow the piece as it's
* translated / rotated / scaled during a drag. Pass [] when no
* overlays are active. */
overlays?: ReadonlyArray<PieceOverlayComponent>;
/** Facts for this piece, used by the overlays. Pre-indexed by the
* Board so the overlay component doesn't re-scan all facts. */
pieceFacts?: ReadonlyArray<ChessFact<keyof ChessAttrMap>>;
onDragStart: (pieceId: number, square: number) => void;
onDragEnd: () => void;
}
@ -90,8 +81,6 @@ export function Piece({
pieceId,
square,
isDraggable,
overlays,
pieceFacts,
onDragStart,
onDragEnd,
}: PieceProps) {
@ -355,21 +344,6 @@ export function Piece({
}`}
draggable={false}
/>
{/*
* Per-piece preset overlays (HP pips, etc.) live INSIDE the
* transform layer so they inherit the drag `x/y/rotate/scale`
* motion values and track the piece during drags. Overlays
* that don't apply to this piece's facts return null so the
* DOM stays clean.
*/}
{overlays?.map((Overlay, i) => (
<Overlay
key={i}
pieceId={pieceId}
pieceFacts={pieceFacts ?? []}
/>
)) as ReactNode}
</motion.div>
</div>
</div>

View file

@ -83,11 +83,9 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
const handleApply = () => {
// Starting a new game preserving the currently configured rule set.
// Route through setActivePresets so onActivate fires on the fresh
// engine (piece-hp needs it to seed Hp=2 on every starting piece).
clearAutoSave();
const newEngine = new ChessEngine();
newEngine.setActivePresets(activations);
newEngine.activePresets.replaceAll(activations);
chessState.loadEngine(newEngine);
navigate('/game');
};

View file

@ -1,120 +0,0 @@
/**
* Per-piece UI overlay registry for presets.
*
* Engine and UI are separated by design: `ChessEngine` doesn't know
* about React. But many presets want custom visual affordances on top
* of the piece — HP pips for `piece-hp`, a poison cloud for
* `poisoned-squares`, an ammo counter for a future `guns` rule, etc.
*
* This registry is the bridge. A preset that needs a per-piece overlay
* registers a React component here (from a `.ui.tsx` sibling file so
* server-side imports stay React-free). `Board.tsx` looks up the
* overlays for every active preset and renders them above the piece.
*
* Add a new visually-rich rule in three small files:
*
* packages/chess/src/presets/guns.ts // engine mechanic
* packages/chess/src/presets/guns.ui.tsx // overlay component + register
* packages/chess/src/presets/ui-index.ts // side-effect import of guns.ui
*
* Zero changes to engine.ts or Board.tsx.
*/
import type { ReactNode } from "react";
import type { ChessAttrMap, ChessFact } from "../schema";
/**
* Data the Board already has per piece. Overlays are pure functions
* of this — no engine reference, no session, just facts for the piece
* in question. Keeps the overlay contract trivially mockable.
*/
export interface PieceOverlayProps {
/** Entity id of the piece this overlay decorates. */
readonly pieceId: number;
/** All facts currently known about THIS piece. Pre-filtered by the
* Board so the overlay doesn't re-scan the full board. */
readonly pieceFacts: ReadonlyArray<ChessFact<keyof ChessAttrMap>>;
}
/** The actual React component type for an overlay. */
export type PieceOverlayComponent = (props: PieceOverlayProps) => ReactNode;
const registry = new Map<string, PieceOverlayComponent>();
/**
* Register a per-piece overlay for a preset. Called at module init
* time from each preset's `.ui.tsx` file; the order of registration
* is irrelevant because overlays are looked up by preset id at render
* time.
*
* Registering the same preset id twice overwrites the previous
* registration — intentional so hot-module-reload works cleanly.
*/
export function registerPieceOverlay(
presetId: string,
component: PieceOverlayComponent,
): void {
registry.set(presetId, component);
}
/**
* Look up the overlay component for a preset id, or undefined if none
* registered. Used by Board.tsx to decide what to render.
*/
export function getPieceOverlay(
presetId: string,
): PieceOverlayComponent | undefined {
return registry.get(presetId);
}
/**
* Given the list of currently-active preset ids (from the hook's
* `activations`), return the overlay components that should render.
* The return order matches the input order, so presets can be layered
* deterministically.
*/
export function getActivePieceOverlays(
activePresetIds: ReadonlyArray<string>,
): PieceOverlayComponent[] {
const out: PieceOverlayComponent[] = [];
for (const id of activePresetIds) {
const c = registry.get(id);
if (c !== undefined) out.push(c);
}
return out;
}
/**
* Per-square overlay — rendered inside every cell of the board,
* regardless of whether the cell is occupied. Used for rules that
* decorate the board itself (poisoned squares, starting/promotion
* zones in future variants) rather than individual pieces.
*
* The component receives the cell's 0..63 square index and decides
* whether to render anything. Returning null is fine and keeps the
* DOM tree clean when the overlay doesn't apply to a given cell.
*/
export interface SquareOverlayProps {
readonly square: number;
}
export type SquareOverlayComponent = (props: SquareOverlayProps) => ReactNode;
const squareRegistry = new Map<string, SquareOverlayComponent>();
export function registerSquareOverlay(
presetId: string,
component: SquareOverlayComponent,
): void {
squareRegistry.set(presetId, component);
}
export function getActiveSquareOverlays(
activePresetIds: ReadonlyArray<string>,
): SquareOverlayComponent[] {
const out: SquareOverlayComponent[] = [];
for (const id of activePresetIds) {
const c = squareRegistry.get(id);
if (c !== undefined) out.push(c);
}
return out;
}

View file

@ -66,9 +66,7 @@ export type GameEndReason =
| "stalemate"
| "50-move"
| "threefold"
| "insufficient"
// Preset-defined decisive result; see GameResult variants.
| "variant-win";
| "insufficient";
// ---------------------------------------------------------------------------
// GameSession
@ -101,7 +99,7 @@ export class GameSession {
this.engine = new ChessEngine();
if (rulesetIds.length > 0) {
try {
this.engine.setActivePresets(
this.engine.activePresets.replaceAll(
rulesetIds.map((id) => ({
id,
scope: "both" as const,
@ -131,7 +129,7 @@ export class GameSession {
activations: readonly ActivationRequest[],
): { ok: true } | { ok: false; error: string } {
try {
this.engine.setActivePresets(activations);
this.engine.activePresets.replaceAll(activations);
return { ok: true };
} catch (e) {
const msg =
@ -330,13 +328,6 @@ function mapGameResult(
return { winner: "draw", reason: "threefold" };
case "draw-insufficient":
return { winner: "draw", reason: "insufficient" };
// Preset-defined decisive results. The engine names the winner
// explicitly because variant rules don't always pair "winner"
// with "side to move" the way standard checkmate does.
case "white-wins":
return { winner: "white", reason: "variant-win" };
case "black-wins":
return { winner: "black", reason: "variant-win" };
default: {
// Exhaustiveness guard — if GameResult ever grows a variant, TS
// will flag this by failing the never-cast.

View file

@ -59,10 +59,6 @@ export const GameEndReasonSchema = z.enum([
"threefold",
"insufficient",
"player_left",
// Preset-defined decisive result (first-blood, annihilation, etc.).
// The UI reads the winner separately; this reason tag just signals
// "variant rule ended the game" to clients that care.
"variant-win",
]);
export type GameEndReason = z.infer<typeof GameEndReasonSchema>;