Preset refactors for HP

This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 18:59:11 -06:00
commit 7c4c942938
No known key found for this signature in database
19 changed files with 1497 additions and 288 deletions

View file

@ -25,7 +25,6 @@ import {
getEnPassantMoves,
setEnPassantTarget,
clearEnPassantTarget,
applyEnPassantCapture,
} from "./rules/enpassant.js";
import {
isPromotionMove,
@ -45,7 +44,8 @@ import {
recordPosition,
isThreefoldRepetition,
} from "./rules/draws.js";
import { applyCapture } from "./rules/capture.js";
import { PIECE_ATTRS } from "./rules/capture.js";
import type { DamageContext } from "./presets/registry.js";
import type { LegalMove } from "./rules/types.js";
import {
ActivePresetSet,
@ -166,6 +166,61 @@ export class ChessEngine {
return consumed;
}
/**
* Deal `amount` damage to `target`, running it through the preset
* damage pipeline before applying the default kill-on-damage rule.
*
* This is the primitive that lets presets compose cleanly:
* - Without any damage-interceptor preset (standard chess,
* explosive-rook alone, etc.), damage ≥ 1 retracts the target.
* - With `piece-hp` active, the hook decrements the target's Hp
* fact and only retracts when Hp would go ≤ 0.
*
* Callers should use this instead of retracting piece facts directly
* whenever they want to compose with HP/armor/shield-like presets.
* See `explosive-rook` (AoE calls dealDamage on every victim) and
* `poisoned-squares` (tick calls dealDamage on every occupant of a
* poisoned square) for the canonical call sites.
*
* The first preset whose `onDamage` returns `consume: true` wins —
* further presets are not consulted for that event. This is
* deliberate: HP is the ONE authority on damage→death; two damage
* interceptors would race and produce zombie state (the bug this
* pipeline was designed to eliminate).
*
* Returns `{ died: true }` if the target was retracted (either by a
* hook that killed or by the default path), `{ died: false }` if it
* survived (HP absorbed it, or amount was 0).
*/
dealDamage(
target: EntityId,
amount: number,
ctx: DamageContext,
): { died: boolean } {
if (amount <= 0) return { died: false };
// Consult damage interceptors in active-preset list order. First
// consumer wins. Scope is not considered here because damage is a
// board-level event that can strike any piece regardless of whose
// turn it is (e.g. explosive rook hitting friendly pieces).
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const result = def?.onDamage?.(this, target, amount, ctx);
if (result?.consume === true) {
return { died: result.died === true };
}
}
// Default: any damage is lethal. Retract all piece attributes so
// downstream queries see the piece as truly gone.
for (const attr of PIECE_ATTRS) {
if (this.session.contains(target, attr)) {
this.session.retract(target, attr);
}
}
return { died: true };
}
getCurrentTurn(): PieceColor {
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
}
@ -244,8 +299,18 @@ export class ChessEngine {
moves.push(...pieceMoves);
}
// Filter self-check moves
return filterSelfCheckMoves(this.session, moves, color);
// Self-check filter: skip it iff any active preset (for this color)
// explicitly opts out via shouldFilterSelfCheck returning false.
// This is how `piece-hp` allows the king to stay on an attacked
// square — a "hit" costs HP rather than losing the game.
let applyFilter = true;
for (const preset of this.activePresets.getForColor(color)) {
if (preset.shouldFilterSelfCheck?.(this, color) === false) {
applyFilter = false;
break;
}
}
return applyFilter ? filterSelfCheckMoves(this.session, moves, color) : moves;
}
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
@ -266,41 +331,73 @@ export class ChessEngine {
if (isEnPassant) {
// En passant captures the pawn on the SKIPPED square, not on
// `move.to`. Dispatch the preset hook against that off-square
// target so e.g. piece-hp can decrement HP on the captured pawn.
// `move.to`. We still fire `onBeforeCapture` first so presets
// like queen-splits / explosive-rook get a chance to transform
// the capture wholesale. If none consume, we route the target
// through `dealDamage` so the HP pipeline gets to absorb it.
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);
let consumed = false;
let targetDied = true; // default: no captured pawn found (degenerate), treat as "died"
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
const result = this.dealDamage(capturedId, 1, {
kind: "capture",
attacker: move.pieceId,
});
targetDied = result.died;
}
}
// Attacker advances diagonally unless a preset consumed the
// capture (it owns positioning) or the captured pawn survived
// HP damage (poke — attacker stays).
if (!consumed && targetDied) {
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
} else if (isCastling) {
applyCastlingMove(this.session, move as CastlingMove);
} else {
// Normal move: handle capture, then update position. The preset
// capture hook is our chance to short-circuit the default
// retract-and-move behaviour (used by piece-hp for non-lethal
// damage). If any preset consumes the capture we skip BOTH the
// retraction AND the attacker's move: the preset turned the
// capture into a "poke" that just ends the turn.
// Normal move. Two intercept layers:
// 1. onBeforeCapture — lets a preset replace the capture
// mechanic entirely (queen-splits fission, explosive-rook
// AoE, capture-to-win winner-recording). If consumed the
// engine does nothing else for this capture.
// 2. dealDamage — runs after, only if nothing consumed. This
// is the pipeline HP hooks, and is what makes "rook
// captures pawn" decrement the pawn's Hp by 1 rather than
// instantly killing. On default (no interceptor) the target
// is retracted and `died=true` is returned.
//
// The attacker advances onto the target square iff the target
// ACTUALLY DIED. Non-lethal captures leave the attacker in
// place; turn still advances so the move counts as a "poke".
let consumed = false;
let captureResolved = true; // default: no capture happened, treat as "died"
if (move.isCapture) {
const capturedId = this.getPieceAt(move.to);
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
applyCapture(this.session, capturedId);
const { died } = this.dealDamage(capturedId, 1, {
kind: "capture",
attacker: move.pieceId,
});
captureResolved = died;
} else {
// A consuming preset owns ALL post-capture behaviour,
// including whether the attacker moved. We don't run the
// standard advance below.
captureResolved = false;
}
}
}
if (!consumed) {
// Advance attacker unless:
// - a preset consumed the capture (it handled positioning), OR
// - the capture was non-lethal (attacker stays, target stands)
if (!consumed && captureResolved) {
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}