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:
parent
af9973adfa
commit
f4e030b8e5
13 changed files with 757 additions and 22 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue