diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index f9b0ba6..4c8242d 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -47,7 +47,11 @@ import { } from "./rules/draws.js"; import { applyCapture } from "./rules/capture.js"; import type { LegalMove } from "./rules/types.js"; -import { ActivePresetSet } from "./presets/active-set.js"; +import { + ActivePresetSet, + type ActivationRequest, +} from "./presets/active-set.js"; +import { PRESET_REGISTRY } from "./presets/registry.js"; // Importing from the barrel guarantees every preset module's // side-effect registration has run before the first engine is created. import "./presets/index.js"; @@ -93,6 +97,69 @@ 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"; } @@ -192,19 +259,45 @@ export class ChessEngine { const isCastling = (move as CastlingMove).isCastling === true; if (isEnPassant) { - applyEnPassantCapture(this.session, move, color); + // 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); + } } else if (isCastling) { applyCastlingMove(this.session, move as CastlingMove); } else { - // Normal move: handle capture, then update position + // 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; if (move.isCapture) { const capturedId = this.getPieceAt(move.to); if (capturedId !== null) { - applyCapture(this.session, capturedId); + consumed = this.tryInterceptCapture(move.pieceId, capturedId, color); + if (!consumed) { + applyCapture(this.session, capturedId); + } } } - this.session.insert(move.pieceId, "Position", move.to); - this.session.insert(move.pieceId, "HasMoved", true); + if (!consumed) { + this.session.insert(move.pieceId, "Position", move.to); + this.session.insert(move.pieceId, "HasMoved", true); + } } // Handle promotion (pawn reaching last rank) @@ -242,8 +335,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. - this.activePresets.tickAfterMove(color); + // 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); + } return this.checkGameResult(); } diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts index 4a175f5..636adf8 100644 --- a/packages/chess/src/hooks/useChessEngine.ts +++ b/packages/chess/src/hooks/useChessEngine.ts @@ -109,7 +109,10 @@ export function useChessEngine() { * the local UI updates immediately (no server round-trip). */ const setPresets = useCallback((activations: PresetActivation[]) => { - engine.activePresets.replaceAll(activations); + // 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); saveAutoSave(engine.session.allFacts()); setTick(t => t + 1); }, [engine]); diff --git a/packages/chess/src/presets/active-set.ts b/packages/chess/src/presets/active-set.ts index 66d4394..141d534 100644 --- a/packages/chess/src/presets/active-set.ts +++ b/packages/chess/src/presets/active-set.ts @@ -191,8 +191,13 @@ 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"): void { + tickAfterMove(moverColor: "white" | "black"): string[] { const toRemove: string[] = []; for (const entry of this.entries.values()) { if (entry.scope !== "both" && entry.scope !== moverColor) continue; @@ -205,6 +210,7 @@ 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/piece-hp.test.ts b/packages/chess/src/presets/piece-hp.test.ts new file mode 100644 index 0000000..04995a7 --- /dev/null +++ b/packages/chess/src/presets/piece-hp.test.ts @@ -0,0 +1,251 @@ +/** + * 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 f6fdcff..21dcb33 100644 --- a/packages/chess/src/presets/piece-hp.ts +++ b/packages/chess/src/presets/piece-hp.ts @@ -1,10 +1,103 @@ +/** + * 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: "All pieces start with 2 HP. Captures deal 1 HP damage; piece only dies at 0 HP. Attacker stays on target if HP > 0.", + 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.", incompatibleWith: ["explosive-rook"], requires: [], - // Full integration in ChessEngine (P3.11) + + 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; + }, }); diff --git a/packages/chess/src/presets/piece-hp.ui.tsx b/packages/chess/src/presets/piece-hp.ui.tsx new file mode 100644 index 0000000..b987cc5 --- /dev/null +++ b/packages/chess/src/presets/piece-hp.ui.tsx @@ -0,0 +1,82 @@ +/** + * 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/registry.ts b/packages/chess/src/presets/registry.ts index 49c28c1..f0c5d86 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -1,11 +1,46 @@ /** * Preset rule registry (P3.4). * - * 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: + * A preset is a modifier to chess rules. Presets expose optional hooks + * that the ChessEngine invokes at well-defined points. The full menu: * - * - getExtraMoves: returns ADDITIONAL legal moves for a piece. - * - filterMoves: removes/modifies entries in an already-computed move list. + * 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. * * Presets register themselves via side-effect imports (see `./index.ts`). */ @@ -13,6 +48,13 @@ import type { EntityId } from "@paratype/rete"; 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; @@ -21,14 +63,25 @@ export interface PresetDef { readonly incompatibleWith: readonly string[]; /** Preset IDs that must also be active for this one to be valid. */ readonly requires: readonly string[]; - /** Returns additional legal moves for a piece (called per-piece). */ + + // ── Move-generation hooks ──────────────────────────────────────────── 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; } /** diff --git a/packages/chess/src/presets/ui-overlays-index.ts b/packages/chess/src/presets/ui-overlays-index.ts new file mode 100644 index 0000000..2f5106e --- /dev/null +++ b/packages/chess/src/presets/ui-overlays-index.ts @@ -0,0 +1,21 @@ +/** + * 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"; +// 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 9913b70..5b079d5 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -5,6 +5,11 @@ import type { LegalMove } from '../rules/types'; import { Piece } from './Piece'; import { AnimatePresence, motion } from 'motion/react'; import { pieceAssets } from '../assets/pieces'; +import { + getActivePieceOverlays, + type PieceOverlayComponent, +} from './preset-overlays'; +import '../presets/ui-overlays-index'; interface BoardProps { facts: ChessFact[]; @@ -19,6 +24,9 @@ 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 { @@ -27,7 +35,29 @@ interface PieceState { color: PieceColor; } -export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare }: BoardProps) { +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], + ); + + // 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]); // Build pieces map: square -> { id, type, color } const pieces = useMemo(() => { const map = new Map(); @@ -282,6 +312,16 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec onDragStart={handleDragStart} onDragEnd={handleDragEnd} /> + {/* Per-piece preset overlays (HP pips, etc.). Each is a + pure function of the piece's facts; Board stays + agnostic to which presets exist. */} + {overlays.map((Overlay, i) => ( + + ))} )} diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 0a7fdff..4061799 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -352,6 +352,7 @@ 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/RulesView.tsx b/packages/chess/src/ui/RulesView.tsx index 360fc11..06124bd 100644 --- a/packages/chess/src/ui/RulesView.tsx +++ b/packages/chess/src/ui/RulesView.tsx @@ -83,9 +83,11 @@ 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.activePresets.replaceAll(activations); + newEngine.setActivePresets(activations); chessState.loadEngine(newEngine); navigate('/game'); }; diff --git a/packages/chess/src/ui/preset-overlays.tsx b/packages/chess/src/ui/preset-overlays.tsx new file mode 100644 index 0000000..bda685d --- /dev/null +++ b/packages/chess/src/ui/preset-overlays.tsx @@ -0,0 +1,85 @@ +/** + * 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; +} diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index ba551db..e5405d8 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -99,7 +99,7 @@ export class GameSession { this.engine = new ChessEngine(); if (rulesetIds.length > 0) { try { - this.engine.activePresets.replaceAll( + this.engine.setActivePresets( rulesetIds.map((id) => ({ id, scope: "both" as const, @@ -129,7 +129,7 @@ export class GameSession { activations: readonly ActivationRequest[], ): { ok: true } | { ok: false; error: string } { try { - this.engine.activePresets.replaceAll(activations); + this.engine.setActivePresets(activations); return { ok: true }; } catch (e) { const msg =