feat(chess): piece-hp mechanic + extensible preset-hook infrastructure

Implements the piece-hp (Hit Points) preset end-to-end and, more
importantly, sets up the infrastructure for rules that need state,
capture interception, or custom UI. Adding a new "guns" rule, a
"poison-cloud" visual, or similar now requires zero changes to
engine.ts, Board.tsx, or protocol.ts.

Engine / preset registry
~~~~~~~~~~~~~~~~~~~~~~~~
PresetDef gains three new optional hooks:

  - onActivate(engine)       \u2014 fires once on active-set transition
                               (inactive \u2192 active). Idempotent by
                               convention so sync paths that re-apply
                               from server state don\`t stomp values.
  - onDeactivate(engine)     \u2014 symmetric cleanup. Also fires when a
                               turn-limited preset expires via
                               tickAfterMove.
  - onBeforeCapture(engine, attacker, target)
      Fires immediately before the engine\`s default capture path.
      Returns `{ consume: true }` to short-circuit: engine skips
      target retraction AND attacker move. Used by piece-hp for
      non-lethal damage; future rules can override however they like.

New ChessEngine.setActivePresets(requests) is the single entry point
that diffs old vs new and fires lifecycle hooks in deterministic
order (deactivate-then-activate). replaceAll stays public for tests
that want to bypass hooks.

ActivePresetSet.tickAfterMove now returns the list of expired ids
so the engine can fire onDeactivate on them.

piece-hp preset
~~~~~~~~~~~~~~~
- onActivate seeds Hp=2 on every piece.
- onDeactivate retracts Hp from all pieces.
- onBeforeCapture decrements Hp; if > 0 consumes the capture (attacker
  stays, target survives, turn advances). At 0, returns without
  consuming so the engine\`s default retract-and-move fires normally.

All capture sites intercepted: regular captures (engine.ts:200) AND
en-passant captures (engine.ts:194). The check-simulation path in
check.ts does NOT fire the hook \u2014 it uses an isolated snapshot
session, so lifecycle side effects don\`t leak into legal-move
filtering.

UI overlay registry
~~~~~~~~~~~~~~~~~~~
New packages/chess/src/ui/preset-overlays.tsx: a module-level registry
mapping preset id \u2192 React component that renders above each piece.
Board.tsx loads the registry via side-effect import and renders any
registered overlays for every active preset.

New packages/chess/src/presets/piece-hp.ui.tsx registers HealthBarPips:
2 pip dots above each piece, filled = remaining HP, empty = lost HP.
Pip color contrasts piece color for readability on either square.

Adding a new visual rule now costs 3 files and zero engine changes:
  presets/foo.ts        (mechanic + lifecycle hooks)
  presets/foo.ui.tsx    (overlay component + registry call)
  presets/ui-overlays-index.ts  (single-line import)

Testing
~~~~~~~
New piece-hp.test.ts: 8 integration tests covering lifecycle (seed,
retract, idempotent re-apply, no-fire on scope-only change) and
capture resolution (non-lethal decrement, lethal retract at HP=0,
HP drain over multiple captures, deactivate mid-game leaves damage
but retracts Hp). Total 861 tests pass (+8), 3/3 E2E green.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 15:54:33 -06:00
commit f4e030b8e5
No known key found for this signature in database
13 changed files with 757 additions and 22 deletions

View file

@ -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();
}

View file

@ -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]);

View file

@ -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. */

View file

@ -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);
});
});

View file

@ -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<EntityId>();
for (const f of facts) {
if (f.attr === "PieceType" && (f.id as number) > 0) {
ids.add(f.id);
}
}
return [...ids];
}
PRESET_REGISTRY.register({
id: "piece-hp",
name: "Hit Points",
description: "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;
},
});

View file

@ -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 (
<div
data-role="hp-overlay"
// Absolute so it sits at the top of the cell without affecting
// the piece's flex centering. pointer-events-none because HP
// pips shouldn't intercept drags or hovers.
className="absolute top-1 left-1/2 -translate-x-1/2 flex gap-[3px] pointer-events-none z-30"
>
{Array.from({ length: maxHp }, (_, i) => {
const filled = i < hp;
return (
<span
key={i}
data-role={filled ? "hp-pip-full" : "hp-pip-empty"}
className={`w-[7px] h-[7px] rounded-full shadow-sm ${
filled ? filledClass : emptyClass
}`}
/>
);
})}
</div>
);
}
// Register at module init. Consumers side-effect-import this file to
// populate the overlay registry.
registerPieceOverlay("piece-hp", HealthBarPips);

View file

@ -1,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;
}
/**

View file

@ -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.

View file

@ -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<string>;
}
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<number, ChessFact[]>();
for (const f of facts) {
const id = f.id as number;
if (id <= 0) continue; // skip game entity
const arr = map.get(id);
if (arr) arr.push(f);
else map.set(id, [f]);
}
return map;
}, [facts]);
// Build pieces map: square -> { id, type, color }
const pieces = useMemo(() => {
const map = new Map<number, PieceState>();
@ -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) => (
<Overlay
key={i}
pieceId={piece.id}
pieceFacts={factsById.get(piece.id) ?? []}
/>
))}
</div>
)}

View file

@ -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 */}

View file

@ -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');
};

View file

@ -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<ChessFact<keyof ChessAttrMap>>;
}
/** The actual React component type for an overlay. */
export type PieceOverlayComponent = (props: PieceOverlayProps) => ReactNode;
const registry = new Map<string, PieceOverlayComponent>();
/**
* Register a per-piece overlay for a preset. Called at module init
* time from each preset's `.ui.tsx` file; the order of registration
* is irrelevant because overlays are looked up by preset id at render
* time.
*
* Registering the same preset id twice overwrites the previous
* registration intentional so hot-module-reload works cleanly.
*/
export function registerPieceOverlay(
presetId: string,
component: PieceOverlayComponent,
): void {
registry.set(presetId, component);
}
/**
* Look up the overlay component for a preset id, or undefined if none
* registered. Used by Board.tsx to decide what to render.
*/
export function getPieceOverlay(
presetId: string,
): PieceOverlayComponent | undefined {
return registry.get(presetId);
}
/**
* Given the list of currently-active preset ids (from the hook's
* `activations`), return the overlay components that should render.
* The return order matches the input order, so presets can be layered
* deterministically.
*/
export function getActivePieceOverlays(
activePresetIds: ReadonlyArray<string>,
): PieceOverlayComponent[] {
const out: PieceOverlayComponent[] = [];
for (const id of activePresetIds) {
const c = registry.get(id);
if (c !== undefined) out.push(c);
}
return out;
}

View file

@ -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 =