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