Preset refactors for HP
This commit is contained in:
parent
6883539426
commit
7c4c942938
19 changed files with 1497 additions and 288 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue