diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index c18e2bf..f9b0ba6 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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"; diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts index e5ea385..4a175f5 100644 --- a/packages/chess/src/hooks/useChessEngine.ts +++ b/packages/chess/src/hooks/useChessEngine.ts @@ -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]); diff --git a/packages/chess/src/hooks/useMultiplayerGame.ts b/packages/chess/src/hooks/useMultiplayerGame.ts index 696661a..590813a 100644 --- a/packages/chess/src/hooks/useMultiplayerGame.ts +++ b/packages/chess/src/hooks/useMultiplayerGame.ts @@ -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; }, diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts index 66bc95a..b91ee20 100644 --- a/packages/chess/src/net/prediction.ts +++ b/packages/chess/src/net/prediction.ts @@ -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 diff --git a/packages/chess/src/presets/active-set.ts b/packages/chess/src/presets/active-set.ts index 141d534..66d4394 100644 --- a/packages/chess/src/presets/active-set.ts +++ b/packages/chess/src/presets/active-set.ts @@ -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. */ diff --git a/packages/chess/src/presets/capture-to-win.ts b/packages/chess/src/presets/capture-to-win.ts index 1458df2..a9677d9 100644 --- a/packages/chess/src/presets/capture-to-win.ts +++ b/packages/chess/src/presets/capture-to-win.ts @@ -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) }); diff --git a/packages/chess/src/presets/explosive-rook.ts b/packages/chess/src/presets/explosive-rook.ts index 247c8f9..b16070e 100644 --- a/packages/chess/src/presets/explosive-rook.ts +++ b/packages/chess/src/presets/explosive-rook.ts @@ -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(); - 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) }); diff --git a/packages/chess/src/presets/fleshed-presets.test.ts b/packages/chess/src/presets/fleshed-presets.test.ts deleted file mode 100644 index 6fa5613..0000000 --- a/packages/chess/src/presets/fleshed-presets.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/chess/src/presets/king-heals.ts b/packages/chess/src/presets/king-heals.ts index 005753d..c18f8f6 100644 --- a/packages/chess/src/presets/king-heals.ts +++ b/packages/chess/src/presets/king-heals.ts @@ -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) }); diff --git a/packages/chess/src/presets/last-piece-standing.ts b/packages/chess/src/presets/last-piece-standing.ts index f924ef5..871fd94 100644 --- a/packages/chess/src/presets/last-piece-standing.ts +++ b/packages/chess/src/presets/last-piece-standing.ts @@ -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(); - 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) }); diff --git a/packages/chess/src/presets/piece-hp.test.ts b/packages/chess/src/presets/piece-hp.test.ts deleted file mode 100644 index 04995a7..0000000 --- a/packages/chess/src/presets/piece-hp.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/chess/src/presets/piece-hp.ts b/packages/chess/src/presets/piece-hp.ts index 21dcb33..f6fdcff 100644 --- a/packages/chess/src/presets/piece-hp.ts +++ b/packages/chess/src/presets/piece-hp.ts @@ -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(); - 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) }); diff --git a/packages/chess/src/presets/piece-hp.ui.tsx b/packages/chess/src/presets/piece-hp.ui.tsx deleted file mode 100644 index b987cc5..0000000 --- a/packages/chess/src/presets/piece-hp.ui.tsx +++ /dev/null @@ -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 ( -
- {Array.from({ length: maxHp }, (_, i) => { - const filled = i < hp; - return ( - - ); - })} -
- ); -} - -// Register at module init. Consumers side-effect-import this file to -// populate the overlay registry. -registerPieceOverlay("piece-hp", HealthBarPips); diff --git a/packages/chess/src/presets/poisoned-squares.ts b/packages/chess/src/presets/poisoned-squares.ts index a1be7f2..178d91d 100644 --- a/packages/chess/src/presets/poisoned-squares.ts +++ b/packages/chess/src/presets/poisoned-squares.ts @@ -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 = 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) }); diff --git a/packages/chess/src/presets/poisoned-squares.ui.tsx b/packages/chess/src/presets/poisoned-squares.ui.tsx deleted file mode 100644 index a3ca455..0000000 --- a/packages/chess/src/presets/poisoned-squares.ui.tsx +++ /dev/null @@ -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 ( - - ); -} - -registerSquareOverlay("poisoned-squares", PoisonOverlay); diff --git a/packages/chess/src/presets/queen-splits.ts b/packages/chess/src/presets/queen-splits.ts index 377035b..f7444df 100644 --- a/packages/chess/src/presets/queen-splits.ts +++ b/packages/chess/src/presets/queen-splits.ts @@ -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 = [ - [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) }); diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 2f4ba0c..49c28c1 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -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; } /** diff --git a/packages/chess/src/presets/ui-overlays-index.ts b/packages/chess/src/presets/ui-overlays-index.ts deleted file mode 100644 index 0fd278b..0000000 --- a/packages/chess/src/presets/ui-overlays-index.ts +++ /dev/null @@ -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. diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index b15f82a..9913b70 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -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; } 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(); - 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(); @@ -233,13 +197,6 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
)} - {/* 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) => ( - - ))} - {/* * 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} /> diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 1d404c5..0a7fdff 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -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-', '')}`} )} @@ -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 */} diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx index 82cfbd4..272bd70 100644 --- a/packages/chess/src/ui/Piece.tsx +++ b/packages/chess/src/ui/Piece.tsx @@ -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; - /** 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>; 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) => ( - - )) as ReactNode}
diff --git a/packages/chess/src/ui/RulesView.tsx b/packages/chess/src/ui/RulesView.tsx index 06124bd..360fc11 100644 --- a/packages/chess/src/ui/RulesView.tsx +++ b/packages/chess/src/ui/RulesView.tsx @@ -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'); }; diff --git a/packages/chess/src/ui/preset-overlays.tsx b/packages/chess/src/ui/preset-overlays.tsx deleted file mode 100644 index c3a24c8..0000000 --- a/packages/chess/src/ui/preset-overlays.tsx +++ /dev/null @@ -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>; -} - -/** The actual React component type for an overlay. */ -export type PieceOverlayComponent = (props: PieceOverlayProps) => ReactNode; - -const registry = new Map(); - -/** - * 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, -): 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(); - -export function registerSquareOverlay( - presetId: string, - component: SquareOverlayComponent, -): void { - squareRegistry.set(presetId, component); -} - -export function getActiveSquareOverlays( - activePresetIds: ReadonlyArray, -): SquareOverlayComponent[] { - const out: SquareOverlayComponent[] = []; - for (const id of activePresetIds) { - const c = squareRegistry.get(id); - if (c !== undefined) out.push(c); - } - return out; -} diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 724a81c..ba551db 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -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. diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 1e218cc..3954750 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -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;