Compare commits
4 commits
af9973adfa
...
6883539426
| Author | SHA1 | Date | |
|---|---|---|---|
|
6883539426 |
|||
|
1e292d6c00 |
|||
|
2739cc2689 |
|||
|
f4e030b8e5 |
25 changed files with 1868 additions and 46 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";
|
||||
|
|
@ -69,6 +73,12 @@ export type GameResult =
|
|||
| "draw-50"
|
||||
| "draw-3fold"
|
||||
| "draw-insufficient"
|
||||
// Preset-defined terminal states. The regular checkmate result always
|
||||
// implicitly means "the side to move loses" (standard chess), but
|
||||
// variants like capture-to-win or last-piece-standing need to name
|
||||
// the winner explicitly because the side to move may be the WINNER.
|
||||
| "white-wins"
|
||||
| "black-wins"
|
||||
| "ongoing";
|
||||
|
||||
export class ChessEngine {
|
||||
|
|
@ -93,6 +103,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 +265,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,13 +341,38 @@ 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);
|
||||
}
|
||||
|
||||
// Post-move preset hooks: fire against EVERY still-active preset
|
||||
// (regardless of scope). Scope-aware behaviour is the preset's
|
||||
// responsibility — king-heals affects the non-mover, poisoned-squares
|
||||
// affects the mover, so a single engine-level scope filter can't
|
||||
// serve both.
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
def?.onAfterMove?.(this, color);
|
||||
}
|
||||
|
||||
return this.checkGameResult();
|
||||
}
|
||||
|
||||
checkGameResult(): GameResult {
|
||||
// Preset override path: run onCheckGameResult on every active preset
|
||||
// in registration order. First non-undefined return wins. This lets
|
||||
// capture-to-win and last-piece-standing redefine "game over"
|
||||
// without touching engine internals.
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
const override = def?.onCheckGameResult?.(this);
|
||||
if (override !== undefined) return override;
|
||||
}
|
||||
|
||||
const nextColor = this.getCurrentTurn();
|
||||
if (isCheckmate(this.session, nextColor)) return "checkmate";
|
||||
if (isStalemate(this.session, nextColor)) return "stalemate";
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ export function useChessEngine() {
|
|||
// over the session (not a stored `InCheck` fact), so we call the
|
||||
// helper directly against the side that just received the move.
|
||||
const opponentColor = engine.getCurrentTurn();
|
||||
if (result === 'checkmate') {
|
||||
const decisive =
|
||||
result === 'checkmate' ||
|
||||
result === 'white-wins' ||
|
||||
result === 'black-wins';
|
||||
if (decisive) {
|
||||
audio.play('checkmate');
|
||||
} else if (isInCheck(engine.session, opponentColor)) {
|
||||
audio.play('check');
|
||||
|
|
@ -109,7 +113,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]);
|
||||
|
|
|
|||
|
|
@ -197,7 +197,13 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
const moveResult = eng.checkGameResult();
|
||||
// Local sound playback for the moving player. The opponent's client
|
||||
// plays its own sound off the `game.delta` event.
|
||||
if (moveResult === 'checkmate') audio.play('checkmate');
|
||||
// Decisive results (checkmate AND variant wins) play the
|
||||
// checkmate sound; ongoing / draws play the normal move sound.
|
||||
const decisive =
|
||||
moveResult === 'checkmate' ||
|
||||
moveResult === 'white-wins' ||
|
||||
moveResult === 'black-wins';
|
||||
if (decisive) audio.play('checkmate');
|
||||
else audio.play('move');
|
||||
return moveResult;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -130,10 +130,23 @@ export class PredictionManager {
|
|||
* previously-legal optimistic moves, and re-validating them here would
|
||||
* duplicate server logic. Simpler to let the next user action
|
||||
* re-predict against the freshly-synced base.
|
||||
*
|
||||
* We route through `setActivePresets` (not bare `activePresets.replaceAll`)
|
||||
* so preset `onActivate` / `onDeactivate` lifecycle hooks fire on the
|
||||
* client's base engine. That's essential for rules like `piece-hp`
|
||||
* which install per-piece state (Hp facts) from onActivate — without
|
||||
* this the client would know the preset is active but have no Hp
|
||||
* facts to render, because `game.presets` carries only the
|
||||
* activation list, not the resulting facts.
|
||||
*
|
||||
* Idempotence guarantee: preset hooks are expected to be idempotent
|
||||
* (piece-hp.onActivate only inserts Hp when absent). Server and
|
||||
* client run the same hook logic, so both arrive at the same state.
|
||||
* The next `game.state` or `game.delta` reconciles any drift.
|
||||
*/
|
||||
private applyPresets(payload: GamePresetsPayload): void {
|
||||
try {
|
||||
this.baseEngine.activePresets.replaceAll(payload.activations);
|
||||
this.baseEngine.setActivePresets(payload.activations);
|
||||
} catch {
|
||||
// A bad set from the server shouldn't crash the client; the server
|
||||
// already validated, so this branch is defensive only. We clear
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -1,10 +1,72 @@
|
|||
/**
|
||||
* Preset: `capture-to-win` (First Blood, RULES.md rule #11)
|
||||
*
|
||||
* The first player to capture ANY enemy piece wins the game
|
||||
* immediately. Checkmate is never reached in practice because any
|
||||
* capture ends the game first.
|
||||
*
|
||||
* Wiring:
|
||||
* - `onBeforeCapture` — record the capturer's color on the game
|
||||
* entity via a `Winner` fact. Does NOT consume the capture, so the
|
||||
* engine's default retract-and-move still runs (the target piece
|
||||
* dies normally, the attacker advances onto its square). This
|
||||
* keeps the final board state intuitive for players to inspect
|
||||
* ("why is white's pawn on d5?") rather than freezing the board
|
||||
* mid-capture.
|
||||
* - `onCheckGameResult` — if a Winner fact was recorded, convert
|
||||
* it into the corresponding GameResult. Otherwise return undefined
|
||||
* and let the default checkmate/stalemate logic run (nothing
|
||||
* to do until the first capture happens).
|
||||
*
|
||||
* Incompatible with `last-piece-standing` — both redefine "when is
|
||||
* the game over" and would compete for the onCheckGameResult hook.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import { GAME_ENTITY, type PieceColor } from "../schema.js";
|
||||
import type { ChessEngine, GameResult } from "../engine.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "capture-to-win",
|
||||
name: "First Blood",
|
||||
description: "A player wins immediately upon capturing any enemy piece (first capture wins).",
|
||||
description:
|
||||
"The first player to capture any enemy piece wins the game immediately. Makes every piece precious.",
|
||||
incompatibleWith: ["last-piece-standing"],
|
||||
requires: [],
|
||||
// Win condition checked in ChessEngine (P3.11)
|
||||
|
||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, _target: EntityId) {
|
||||
const session = engine.session;
|
||||
// Only record the first capture — don't stomp an earlier winner
|
||||
// if multiple captures somehow occur in the same applyMove
|
||||
// (shouldn't happen, but defensive).
|
||||
if (session.contains(GAME_ENTITY, "Winner")) {
|
||||
const existing = session.get(GAME_ENTITY, "Winner");
|
||||
if (existing !== null) return; // already set, leave it
|
||||
}
|
||||
const colorFact = session
|
||||
.allFacts()
|
||||
.find(f => f.id === attacker && f.attr === "Color");
|
||||
if (!colorFact) return;
|
||||
session.insert(GAME_ENTITY, "Winner", colorFact.value as PieceColor);
|
||||
// Don't consume — let the capture resolve normally so the board
|
||||
// visibly reflects the killing blow.
|
||||
},
|
||||
|
||||
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
|
||||
const session = engine.session;
|
||||
if (!session.contains(GAME_ENTITY, "Winner")) return undefined;
|
||||
const winner = session.get(GAME_ENTITY, "Winner");
|
||||
if (winner === "white") return "white-wins";
|
||||
if (winner === "black") return "black-wins";
|
||||
return undefined; // "draw" or null — let default logic run
|
||||
},
|
||||
|
||||
onDeactivate(engine: ChessEngine) {
|
||||
// Clear any stored winner when the preset is turned off so the
|
||||
// game doesn't stay in a won state after the rule is lifted.
|
||||
const session = engine.session;
|
||||
if (session.contains(GAME_ENTITY, "Winner")) {
|
||||
session.retract(GAME_ENTITY, "Winner");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,112 @@
|
|||
/**
|
||||
* Preset: `explosive-rook` (Detonating Rook, RULES.md rule #10)
|
||||
*
|
||||
* When a rook captures, it detonates on the target square, removing
|
||||
* every piece (friendly or enemy, excluding the capturing rook itself)
|
||||
* within Chebyshev distance <= 2 on the same rank or file. Diagonal
|
||||
* neighbours are NOT affected — detonation propagates along orthogonal
|
||||
* rays only, matching the rook's own movement.
|
||||
*
|
||||
* Implementation via `onBeforeCapture`:
|
||||
* - If the attacker isn't a rook, do nothing (default capture runs).
|
||||
* - Otherwise consume the hook, then manually:
|
||||
* 1. Remove the target piece.
|
||||
* 2. Remove every piece within distance 2 on the target's rank or
|
||||
* file (friendly or enemy; rook itself excluded).
|
||||
* 3. Move the rook onto the target square (so the explosion
|
||||
* visually "lands" there).
|
||||
*
|
||||
* Explosion does NOT chain — if a captured piece happened to be a
|
||||
* second rook, its detonation does not re-trigger. Keeping the
|
||||
* mechanic finite.
|
||||
*
|
||||
* Incompatible with `piece-hp` — HP's "capture deals 1 damage" and
|
||||
* explosive-rook's "capture wipes AoE" are contradictory capture
|
||||
* resolutions.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { fileOf, rankOf, squareOf } from "../coord.js";
|
||||
import type { Square } from "../schema.js";
|
||||
|
||||
const DETONATION_RADIUS = 2;
|
||||
|
||||
const PIECE_ATTRS = [
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Hp",
|
||||
] as const;
|
||||
|
||||
function retractEntity(session: Session, id: EntityId): void {
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(id, attr)) session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
|
||||
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
||||
const facts = session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr === "Position" && f.value === sq && (f.id as number) > 0) {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "explosive-rook",
|
||||
name: "Detonating Rook",
|
||||
description: "When a Rook captures, it also removes all pieces within 2 squares on the same rank and file.",
|
||||
description:
|
||||
"When a Rook captures, it detonates: every piece within 2 squares on the same rank or file is removed (friend and foe alike). Diagonals are spared.",
|
||||
incompatibleWith: ["piece-hp"],
|
||||
requires: [],
|
||||
// Full integration happens in ChessEngine (P3.11)
|
||||
|
||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
||||
const session = engine.session;
|
||||
const attackerTypeFact = session
|
||||
.allFacts()
|
||||
.find(f => f.id === attacker && f.attr === "PieceType");
|
||||
if (attackerTypeFact?.value !== "rook") return; // default capture runs
|
||||
|
||||
const targetPos = session.get(target, "Position") as Square | undefined;
|
||||
if (targetPos === undefined) return;
|
||||
|
||||
const targetFile = fileOf(targetPos);
|
||||
const targetRank = rankOf(targetPos);
|
||||
|
||||
// Collect detonation victims (orthogonal neighbours within radius).
|
||||
// We include the target itself — it gets removed first. We skip
|
||||
// the attacker so the rook survives.
|
||||
const victims = new Set<EntityId>();
|
||||
victims.add(target);
|
||||
|
||||
for (let d = 1; d <= DETONATION_RADIUS; d++) {
|
||||
for (const [df, dr] of [
|
||||
[d, 0], [-d, 0], [0, d], [0, -d],
|
||||
] as const) {
|
||||
const f = targetFile + df;
|
||||
const r = targetRank + dr;
|
||||
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
|
||||
const sq = squareOf(f, r) as Square;
|
||||
const id = pieceAtSquare(session, sq);
|
||||
if (id === null) continue;
|
||||
if (id === attacker) continue;
|
||||
victims.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Retract every victim's facts — their pieces are gone.
|
||||
for (const v of victims) retractEntity(session, v);
|
||||
|
||||
// The rook still "captures" by moving onto the target square and
|
||||
// has HasMoved set. Default engine path is consumed, so we apply
|
||||
// these mutations ourselves.
|
||||
session.insert(attacker, "Position", targetPos);
|
||||
session.insert(attacker, "HasMoved", true);
|
||||
|
||||
return { consume: true };
|
||||
},
|
||||
});
|
||||
|
|
|
|||
398
packages/chess/src/presets/fleshed-presets.test.ts
Normal file
398
packages/chess/src/presets/fleshed-presets.test.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/**
|
||||
* Integration tests for presets that were previously stubs:
|
||||
* - king-heals (onAfterMove)
|
||||
* - poisoned-squares (onAfterMove)
|
||||
* - capture-to-win (onBeforeCapture + onCheckGameResult)
|
||||
* - last-piece-standing (onCheckGameResult)
|
||||
* - explosive-rook (onBeforeCapture with consume)
|
||||
* - queen-splits (onBeforeCapture with consume, spawning)
|
||||
*
|
||||
* Each block tests one preset's mechanic end-to-end through the
|
||||
* ChessEngine so we catch any hook-wiring regressions.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import "./index.js";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { algebraicToSquare, squareOf } from "../coord.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { Square } from "../schema.js";
|
||||
|
||||
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 typeOf(engine: ChessEngine, id: EntityId): string | null {
|
||||
if (!engine.session.contains(id, "PieceType")) return null;
|
||||
return engine.session.get(id, "PieceType") as string;
|
||||
}
|
||||
|
||||
function hpOf(engine: ChessEngine, id: EntityId): number | null {
|
||||
if (!engine.session.contains(id, "Hp")) return null;
|
||||
return engine.session.get(id, "Hp") as number;
|
||||
}
|
||||
|
||||
/** Clear a rank of all pieces. Used to sculpt board positions cheaply. */
|
||||
function clearRank(engine: ChessEngine, rank: number): void {
|
||||
const retractIds: EntityId[] = [];
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if (Math.floor((f.value as number) / 8) !== rank) continue;
|
||||
retractIds.push(f.id);
|
||||
}
|
||||
for (const id of retractIds) {
|
||||
for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
|
||||
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// king-heals
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("king-heals", () => {
|
||||
it("heals the non-mover's king by 1 HP (max 3) per half-move when not in check", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Damage black king to 1 HP so we can observe healing.
|
||||
const blackKing = pieceAt(engine, "e8")!;
|
||||
engine.session.insert(blackKing, "Hp", 1);
|
||||
|
||||
// White plays a neutral move. onAfterMove fires, sees black king
|
||||
// not in check, heals +1 → HP 2.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
expect(hpOf(engine, blackKing)).toBe(2);
|
||||
|
||||
// Black plays; white's king wasn't damaged so heal is a no-op
|
||||
// (already at full 2 — unless we also damage it, but leave it).
|
||||
// Then white plays again → black king heals to 3 (cap).
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
|
||||
);
|
||||
expect(hpOf(engine, blackKing)).toBe(3);
|
||||
|
||||
// Further heals stop at the cap.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("h2"), algebraicToSquare("h3"))!,
|
||||
);
|
||||
expect(hpOf(engine, blackKing)).toBe(3);
|
||||
});
|
||||
|
||||
it("does not heal a king currently in check", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Scholar's-Mate-setup to get black king in check after Qh5.
|
||||
// 1. e4 e5 2. Bc4 Nc6 3. Qh5 — threatens Qxf7# which is check.
|
||||
// Actually Qh5 doesn't check; we want something that checks. Use
|
||||
// 1. e4 d5 2. exd5 3. Qh5+ — now black king is in check.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
|
||||
// Damage both kings to 1 so we can observe differential healing.
|
||||
const whiteKing = pieceAt(engine, "e1")!;
|
||||
const blackKing = pieceAt(engine, "e8")!;
|
||||
engine.session.insert(whiteKing, "Hp", 1);
|
||||
engine.session.insert(blackKing, "Hp", 1);
|
||||
|
||||
// White's move triggers heal on BLACK king (non-mover). Black not
|
||||
// in check → heals to 2.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
|
||||
expect(hpOf(engine, blackKing)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// poisoned-squares
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("poisoned-squares", () => {
|
||||
it("damages a piece ending a half-move on d4/e4/d5/e5", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
{ id: "poisoned-squares", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// 1. e4 — white pawn lands on e4 which is poisoned. After white's
|
||||
// move onAfterMove fires, poison damage applies → pawn goes from
|
||||
// HP 2 to HP 1.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
|
||||
const e4Pawn = pieceAt(engine, "e4")!;
|
||||
expect(hpOf(engine, e4Pawn)).toBe(1);
|
||||
|
||||
// Black plays non-poisoned move — e4 pawn gets damaged AGAIN
|
||||
// because it's still on the poisoned square at end of half-move.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!);
|
||||
expect(pieceAt(engine, "e4")).toBeNull(); // pawn retracted at HP 0
|
||||
});
|
||||
|
||||
it("does not damage pieces NOT on a poisoned square", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
{ id: "poisoned-squares", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!);
|
||||
const a3Pawn = pieceAt(engine, "a3")!;
|
||||
expect(hpOf(engine, a3Pawn)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// capture-to-win
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("capture-to-win", () => {
|
||||
it("first capture ends the game with the capturer winning", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// 1. e4 d5 2. exd5 — white captures first.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
|
||||
const result = engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
expect(result).toBe("white-wins");
|
||||
});
|
||||
|
||||
it("game stays ongoing until any capture occurs", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
const r1 = engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
expect(r1).toBe("ongoing");
|
||||
const r2 = engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||
);
|
||||
expect(r2).toBe("ongoing");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// last-piece-standing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("last-piece-standing", () => {
|
||||
it("game stays ongoing until one color has 0 pieces", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
expect(engine.checkGameResult()).toBe("ongoing");
|
||||
});
|
||||
|
||||
it("white wins when black has no pieces left", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Scorched-earth: retract every black piece.
|
||||
const blackIds: EntityId[] = [];
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "Color" || f.value !== "black") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
blackIds.push(f.id);
|
||||
}
|
||||
for (const id of blackIds) {
|
||||
for (const attr of ["PieceType", "Color", "Position", "HasMoved"] as const) {
|
||||
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
expect(engine.checkGameResult()).toBe("white-wins");
|
||||
});
|
||||
|
||||
it("overrides default checkmate: cornered king is NOT game-over", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
// From the starting position the base engine isn't near checkmate
|
||||
// yet, but the key point is checkGameResult never returns
|
||||
// "checkmate" while last-piece-standing is active. Sanity check.
|
||||
expect(engine.checkGameResult()).toBe("ongoing");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// explosive-rook
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("explosive-rook", () => {
|
||||
it("rook capture detonates orthogonal neighbours within distance 2", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "explosive-rook", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Sculpt a controlled board: clear everything from rank 4 so we
|
||||
// can plant a rook + target + bystanders.
|
||||
clearRank(engine, 3);
|
||||
clearRank(engine, 4);
|
||||
clearRank(engine, 5);
|
||||
|
||||
// White rook on a4 (file 0, rank 3). Black pawn on d4 (target).
|
||||
// Black pawn on f4 (distance 2, survives? No — f4 is file 5, d4
|
||||
// is file 3, Chebyshev along file = 2, so f4 IS in range).
|
||||
// Black pawn on b5 (diagonal, should SURVIVE explosion).
|
||||
// White pawn on d2 (distance 2 on file, should be detonated).
|
||||
const whiteRook = pieceAt(engine, "a1")!;
|
||||
engine.session.insert(whiteRook, "Position", squareOf(0, 3)); // a4
|
||||
|
||||
// Find any black pawn and reposition; need 4 black pawns for the test.
|
||||
const blackPawns: EntityId[] = [];
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
|
||||
const cfact = engine.session.allFacts().find(x => x.id === f.id && x.attr === "Color");
|
||||
if (cfact?.value === "black") blackPawns.push(f.id);
|
||||
}
|
||||
engine.session.insert(blackPawns[0]!, "Position", squareOf(3, 3)); // d4 target
|
||||
engine.session.insert(blackPawns[1]!, "Position", squareOf(5, 3)); // f4 in-range
|
||||
engine.session.insert(blackPawns[2]!, "Position", squareOf(1, 4)); // b5 diagonal
|
||||
|
||||
// White rook captures on d4.
|
||||
const capture = engine.findMove(
|
||||
algebraicToSquare("a4"),
|
||||
algebraicToSquare("d4"),
|
||||
);
|
||||
expect(capture).not.toBeNull();
|
||||
engine.applyMove(capture!);
|
||||
|
||||
// d4 target: gone.
|
||||
// f4: gone (file 3 -> 5 is distance 2 on file, rank unchanged).
|
||||
// b5: survives (diagonal neighbour, not on d4's rank or file).
|
||||
expect(pieceAt(engine, "d4")).toBe(whiteRook); // rook moved here
|
||||
expect(pieceAt(engine, "f4")).toBeNull();
|
||||
expect(pieceAt(engine, "b5")).not.toBeNull(); // diagonal survives
|
||||
});
|
||||
|
||||
it("non-rook captures are unaffected (default capture runs)", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "explosive-rook", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// 1. e4 d5 2. exd5 — pawn capture, no detonation.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
|
||||
// d5 holds the white pawn. No adjacent pieces affected.
|
||||
expect(typeOf(engine, pieceAt(engine, "d5")!)).toBe("pawn");
|
||||
expect(pieceAt(engine, "e7")).not.toBeNull(); // black pawn intact
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// queen-splits
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("queen-splits", () => {
|
||||
it("queen capture spawns a rook on target and a bishop on an empty neighbour", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "queen-splits", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Sculpt: white queen on d4, black pawn on e5 (diagonal capture).
|
||||
// Neighbours of e5: d6, e6, f6, f5, f4, e4, d4 (WHITE QUEEN!), d5.
|
||||
// Clockwise from N: e6 → f6 → f5 → f4 → e4 → d4 (queen — gone
|
||||
// after fission, but at the moment of the clockwise scan it's
|
||||
// already retracted) → d5 → d6. So the first empty neighbour
|
||||
// after capture will be e6 (empty in starting position).
|
||||
clearRank(engine, 3);
|
||||
clearRank(engine, 4);
|
||||
const whiteQueen = pieceAt(engine, "d1")!;
|
||||
engine.session.insert(whiteQueen, "Position", squareOf(3, 3)); // d4
|
||||
|
||||
const blackPawns: EntityId[] = [];
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
|
||||
const cfact = engine.session.allFacts().find(x => x.id === f.id && x.attr === "Color");
|
||||
if (cfact?.value === "black") blackPawns.push(f.id);
|
||||
}
|
||||
engine.session.insert(blackPawns[0]!, "Position", squareOf(4, 4)); // e5
|
||||
|
||||
const capture = engine.findMove(
|
||||
algebraicToSquare("d4"),
|
||||
algebraicToSquare("e5"),
|
||||
);
|
||||
expect(capture).not.toBeNull();
|
||||
engine.applyMove(capture!);
|
||||
|
||||
// Queen is gone.
|
||||
expect(engine.session.contains(whiteQueen, "Position")).toBe(false);
|
||||
|
||||
// Target pawn is gone.
|
||||
expect(pieceAt(engine, "e5")).not.toBe(blackPawns[0]!);
|
||||
|
||||
// A white rook sits on e5 (target square).
|
||||
const onE5 = pieceAt(engine, "e5")!;
|
||||
expect(typeOf(engine, onE5)).toBe("rook");
|
||||
expect(engine.session.get(onE5, "Color")).toBe("white");
|
||||
|
||||
// A white bishop sits on one of e5's clockwise-N-first empty
|
||||
// neighbours. Starting position has black pawns on rank 7 which
|
||||
// is all occupied from e8 down to rank 7… wait, e5's N is e6
|
||||
// which is empty. Assert we find a bishop adjacent.
|
||||
const neighborFiles = [-1, 0, 1];
|
||||
const neighborRanks = [-1, 0, 1];
|
||||
let bishopFound = false;
|
||||
for (const df of neighborFiles) {
|
||||
for (const dr of neighborRanks) {
|
||||
if (df === 0 && dr === 0) continue;
|
||||
const sq = squareOf(4 + df, 4 + dr) as Square;
|
||||
if (sq < 0 || sq > 63) continue;
|
||||
const id = pieceAt(
|
||||
engine,
|
||||
String.fromCharCode(97 + 4 + df) + (5 + dr),
|
||||
);
|
||||
if (id === null) continue;
|
||||
if (typeOf(engine, id) === "bishop") {
|
||||
bishopFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bishopFound) break;
|
||||
}
|
||||
expect(bishopFound).toBe(true);
|
||||
});
|
||||
|
||||
it("non-queen captures are unaffected", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "queen-splits", 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"))!);
|
||||
// Pawn stays a pawn — no fission.
|
||||
const onD5 = pieceAt(engine, "d5")!;
|
||||
expect(typeOf(engine, onD5)).toBe("pawn");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,62 @@
|
|||
/**
|
||||
* Preset: `king-heals` (Regenerating King, RULES.md rule #9)
|
||||
*
|
||||
* After every half-move, check the king BELONGING TO THE NON-MOVER
|
||||
* (i.e. the player about to move). If it's not currently in check,
|
||||
* its HP regenerates by 1, capped at MAX_KING_HP.
|
||||
*
|
||||
* Design reading of RULES.md #9: "If the King ends a turn NOT in check,
|
||||
* it gains +1 HP." The natural moment to apply this is AFTER a move
|
||||
* has been applied but BEFORE the turn counter advances further. Our
|
||||
* `onAfterMove` hook fires at that exact point. We heal the KING that
|
||||
* just came off attack — i.e. the non-mover's king — because the
|
||||
* mover's king couldn't have been in check (self-check filter prevents
|
||||
* moves that leave your own king attacked).
|
||||
*
|
||||
* Requires `piece-hp`; without it there's no Hp attribute to heal.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { PieceColor } from "../schema.js";
|
||||
import { isInCheck } from "../rules/check.js";
|
||||
|
||||
const MAX_KING_HP = 3;
|
||||
|
||||
/** Find the king entity for a given color, or null if absent. */
|
||||
function findKing(session: Session, color: PieceColor): EntityId | null {
|
||||
const facts = session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== "king") continue;
|
||||
const colorFact = facts.find(c => c.id === f.id && c.attr === "Color");
|
||||
if (colorFact?.value === color) return f.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "king-heals",
|
||||
name: "Regenerating King",
|
||||
description: "If the King ends a turn NOT in check, it gains +1 HP (max 3 HP). Requires piece-hp.",
|
||||
description:
|
||||
"After every half-move, the king whose turn it is to move regenerates +1 HP (max 3) — unless it's currently in check. Requires Hit Points.",
|
||||
incompatibleWith: [],
|
||||
requires: ["piece-hp"],
|
||||
// Full integration happens in ChessEngine (P3.11)
|
||||
|
||||
onAfterMove(engine: ChessEngine, moverColor) {
|
||||
// The NON-mover's king is the one potentially healing: it's the
|
||||
// king belonging to the player whose turn just arrived. Heal only
|
||||
// if that king isn't currently in check (being in check denies the
|
||||
// heal — an intentional game-design lever that makes aggressive
|
||||
// play strategically meaningful).
|
||||
const color: PieceColor = moverColor === "white" ? "black" : "white";
|
||||
const kingId = findKing(engine.session, color);
|
||||
if (kingId === null) return;
|
||||
if (isInCheck(engine.session, color)) return;
|
||||
|
||||
const session = engine.session;
|
||||
if (!session.contains(kingId, "Hp")) return; // piece-hp not active
|
||||
const hp = session.get(kingId, "Hp") as number;
|
||||
if (hp >= MAX_KING_HP) return;
|
||||
session.insert(kingId, "Hp", hp + 1);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,73 @@
|
|||
/**
|
||||
* Preset: `last-piece-standing` (Annihilation, RULES.md rule #12)
|
||||
*
|
||||
* The king has no special status. Checkmate is disabled. The player
|
||||
* who captures ALL enemy pieces wins — meaning the opponent's piece
|
||||
* count drops to 0 (king included).
|
||||
*
|
||||
* Wiring is pure `onCheckGameResult`:
|
||||
* - Count pieces by color.
|
||||
* - If one color has 0 pieces, the other color wins.
|
||||
* - Otherwise return undefined — the game continues. We deliberately
|
||||
* SUPPRESS the default checkmate and stalemate detection by
|
||||
* returning "ongoing" whenever neither side is annihilated; this
|
||||
* overrides the default `isCheckmate`/`isStalemate` checks in
|
||||
* `engine.checkGameResult`.
|
||||
*
|
||||
* Note that `filterSelfCheckMoves` still runs in move generation, so
|
||||
* players still can't move their king into check voluntarily. That's
|
||||
* a UX concession: under pure Annihilation rules a suicidal king
|
||||
* move is technically legal, but blocking it keeps the game readable
|
||||
* (otherwise a blundering player could accidentally trap themselves
|
||||
* with no legal moves). Future work could add a scope for "disable
|
||||
* self-check filter" if we want stricter variant purity.
|
||||
*
|
||||
* Incompatible with `capture-to-win` — both redefine "when is the
|
||||
* game over".
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine, GameResult } from "../engine.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "last-piece-standing",
|
||||
name: "Annihilation",
|
||||
description: "The player who captures all enemy pieces wins. King has no special status; checkmate is disabled.",
|
||||
description:
|
||||
"Checkmate is disabled. The player who captures ALL enemy pieces (king included) wins. Every capture counts.",
|
||||
incompatibleWith: ["capture-to-win"],
|
||||
requires: [],
|
||||
// Win condition checked in ChessEngine (P3.11)
|
||||
|
||||
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
|
||||
// Count Position facts per color. We use Position rather than
|
||||
// PieceType because a piece "exists on the board" iff it has a
|
||||
// position; retracted pieces (captured) have no Position fact.
|
||||
const facts = engine.session.allFacts();
|
||||
const colorById = new Map<number, string>();
|
||||
for (const f of facts) {
|
||||
if (f.attr === "Color") colorById.set(f.id as number, f.value as string);
|
||||
}
|
||||
|
||||
let whiteCount = 0;
|
||||
let blackCount = 0;
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
const color = colorById.get(f.id as number);
|
||||
if (color === "white") whiteCount++;
|
||||
else if (color === "black") blackCount++;
|
||||
}
|
||||
|
||||
if (whiteCount === 0 && blackCount === 0) {
|
||||
// Degenerate — both sides wiped simultaneously somehow. Call
|
||||
// it a draw-by-annihilation.
|
||||
return "draw-insufficient";
|
||||
}
|
||||
if (whiteCount === 0) return "black-wins";
|
||||
if (blackCount === 0) return "white-wins";
|
||||
|
||||
// Neither side is annihilated — override the default checkmate/
|
||||
// stalemate detection by declaring the game ongoing. Without this
|
||||
// return, the engine would fall through to isCheckmate which
|
||||
// could spuriously end the game under pure FIDE rules.
|
||||
return "ongoing";
|
||||
},
|
||||
});
|
||||
|
|
|
|||
251
packages/chess/src/presets/piece-hp.test.ts
Normal file
251
packages/chess/src/presets/piece-hp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
82
packages/chess/src/presets/piece-hp.ui.tsx
Normal file
82
packages/chess/src/presets/piece-hp.ui.tsx
Normal 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);
|
||||
|
|
@ -1,13 +1,78 @@
|
|||
/**
|
||||
* Preset: `poisoned-squares` (Poisoned Centre, RULES.md rule #15)
|
||||
*
|
||||
* The four central squares (d4, d5, e4, e5) are poisoned. Any piece
|
||||
* STANDING on one of those squares at the end of a half-move loses
|
||||
* 1 HP. If its HP reaches 0 the piece dies (retract all its facts).
|
||||
*
|
||||
* Why "end of half-move" and not "end of full turn": the poison needs
|
||||
* to be visible to both players per move — if it only ticked every
|
||||
* other half-move, a player could briefly occupy a poisoned square
|
||||
* during their own turn without consequence. Applying damage every
|
||||
* half-move means every piece pays 1 HP per time-step on a poisoned
|
||||
* square, which matches the "per turn" wording in RULES.md #15 (here
|
||||
* "turn" means "half-move" in chess parlance).
|
||||
*
|
||||
* Requires `piece-hp` — without an Hp attribute there's nothing to
|
||||
* damage. The registry validates the dependency when the preset is
|
||||
* activated via setActivePresets.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
// Poisoned central squares: d4=27, e4=28, d5=35, e5=36
|
||||
export const POISONED_SQUARES = new Set([27, 28, 35, 36]);
|
||||
/** Poisoned central squares: d4=27, e4=28, d5=35, e5=36. Exported for
|
||||
* the UI layer to render visual poison cues on the same squares. */
|
||||
export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
|
||||
|
||||
/** Attributes to retract when a piece dies of poison. Kept in sync with
|
||||
* the engine's capture.PIECE_ATTRS list; we can't import it directly
|
||||
* because poisoned-squares is a pure-data preset and `capture.ts`
|
||||
* depends on rete/session. */
|
||||
const PIECE_ATTRS = [
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Hp",
|
||||
] as const;
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "poisoned-squares",
|
||||
name: "Poisoned Centre",
|
||||
description: "The four central squares (d4, d5, e4, e5) are poisoned. A piece ending its turn there loses 1 HP per turn. Requires piece-hp.",
|
||||
description:
|
||||
"The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one loses 1 HP per move. Requires Hit Points.",
|
||||
incompatibleWith: [],
|
||||
requires: ["piece-hp"],
|
||||
// HP damage applied in ChessEngine post-move hook (P3.11)
|
||||
|
||||
onAfterMove(engine: ChessEngine) {
|
||||
const session = engine.session;
|
||||
const facts = session.allFacts();
|
||||
|
||||
// Collect pieces standing on poisoned squares. We snapshot the
|
||||
// list BEFORE mutating anything so HP decrements don't interact
|
||||
// with iteration semantics.
|
||||
const toPoison: Array<{ id: EntityId; hp: number }> = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if (!POISONED_SQUARES.has(f.value as number)) continue;
|
||||
if ((f.id as number) <= 0) continue; // game entity
|
||||
if (!session.contains(f.id, "Hp")) continue; // shouldn't happen
|
||||
const hp = session.get(f.id, "Hp") as number;
|
||||
toPoison.push({ id: f.id, hp });
|
||||
}
|
||||
|
||||
for (const { id, hp } of toPoison) {
|
||||
const next = hp - 1;
|
||||
if (next > 0) {
|
||||
session.insert(id, "Hp", next);
|
||||
} else {
|
||||
// HP hit 0 — piece dies. Retract all attributes mirroring
|
||||
// the normal capture path.
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(id, attr)) session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
31
packages/chess/src/presets/poisoned-squares.ui.tsx
Normal file
31
packages/chess/src/presets/poisoned-squares.ui.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* UI overlay for `poisoned-squares`: a green-tinted haze on d4, d5,
|
||||
* e4, e5 so players can tell at a glance which squares will damage
|
||||
* pieces that end a turn there.
|
||||
*
|
||||
* Design: low-saturation green with a skull-like pattern? For v1 we
|
||||
* just use a tinted overlay with animated opacity pulsing so the
|
||||
* poison feels alive. Skulls are future polish.
|
||||
*/
|
||||
import { motion } from "motion/react";
|
||||
import { registerSquareOverlay } from "../ui/preset-overlays.js";
|
||||
import type { SquareOverlayProps } from "../ui/preset-overlays.js";
|
||||
import { POISONED_SQUARES } from "./poisoned-squares.js";
|
||||
|
||||
function PoisonOverlay({ square }: SquareOverlayProps) {
|
||||
if (!POISONED_SQUARES.has(square)) return null;
|
||||
return (
|
||||
<motion.div
|
||||
data-role="poison-overlay"
|
||||
className="absolute inset-0 pointer-events-none z-[5]"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(132, 204, 22, 0.35) 0%, rgba(132, 204, 22, 0.15) 60%, transparent 100%)",
|
||||
}}
|
||||
animate={{ opacity: [0.7, 1, 0.7] }}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
registerSquareOverlay("poisoned-squares", PoisonOverlay);
|
||||
|
|
@ -1,10 +1,147 @@
|
|||
/**
|
||||
* Preset: `queen-splits` (Queen Fission, RULES.md rule #8)
|
||||
*
|
||||
* When a queen captures, it FISSIONS into a rook (placed on the
|
||||
* capture square) and a bishop (placed on the first empty adjacent
|
||||
* square, clockwise from north). The queen entity is retracted, the
|
||||
* target enemy piece is retracted, two new piece entities are
|
||||
* spawned. If no adjacent square is empty the bishop is forfeit and
|
||||
* only the rook spawns.
|
||||
*
|
||||
* Implementation via `onBeforeCapture`:
|
||||
* - If the attacker isn't a queen, do nothing (default capture runs).
|
||||
* - Otherwise consume the hook and:
|
||||
* 1. Retract the enemy target's facts (normal capture).
|
||||
* 2. Retract the queen's facts (she fissioned).
|
||||
* 3. Spawn a new rook on the target square (fresh entity id).
|
||||
* 4. Find the first empty adjacent square scanning clockwise
|
||||
* from N (0,+1), NE (+1,+1), E (+1,0), SE (+1,-1),
|
||||
* S (0,-1), SW (-1,-1), W (-1,0), NW (-1,+1).
|
||||
* If found, spawn a bishop there. If all 8 neighbours are
|
||||
* occupied, no bishop spawns.
|
||||
*
|
||||
* Fissioned pieces receive `HasMoved = true` so they cannot castle
|
||||
* (even if the original queen was on a castle-related square — a
|
||||
* weird edge case, but the invariant is simpler this way).
|
||||
*
|
||||
* Interaction with `piece-hp`: if piece-hp is also active (currently
|
||||
* allowed — no hard incompatibility), the spawned pieces get Hp=2
|
||||
* via the onActivate idempotence guarantee. Strictly speaking a more
|
||||
* principled design would have a dedicated `onPieceSpawn` hook
|
||||
* chain; for v1 we rely on the convention that piece-hp's
|
||||
* `onActivate` only installs Hp on pieces that don't already have
|
||||
* it, so calling it re-idempotently covers newly-spawned pieces too.
|
||||
*
|
||||
* IMPORTANT: spawning new entities mid-capture means this preset
|
||||
* writes new facts to the session that didn't exist when the move
|
||||
* was legally validated. That's fine — queen captures are validated
|
||||
* against the pre-fission state; fission is a post-capture
|
||||
* consequence, not a move generator.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { fileOf, rankOf, squareOf } from "../coord.js";
|
||||
import type { Square, PieceColor } from "../schema.js";
|
||||
|
||||
/** Clockwise-from-north neighbour offsets. Order matters: bishop is
|
||||
* placed on the FIRST empty square found by walking this list. */
|
||||
const CLOCKWISE_NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [
|
||||
[0, 1], // N
|
||||
[1, 1], // NE
|
||||
[1, 0], // E
|
||||
[1, -1], // SE
|
||||
[0, -1], // S
|
||||
[-1, -1], // SW
|
||||
[-1, 0], // W
|
||||
[-1, 1], // NW
|
||||
];
|
||||
|
||||
const PIECE_ATTRS = [
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Hp",
|
||||
] as const;
|
||||
|
||||
function retractEntity(session: Session, id: EntityId): void {
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(id, attr)) session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
|
||||
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
||||
const facts = session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr === "Position" && f.value === sq && (f.id as number) > 0) {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Spawn a new piece entity. Returns the new id. */
|
||||
function spawnPiece(
|
||||
session: Session,
|
||||
type: "rook" | "bishop",
|
||||
color: PieceColor,
|
||||
square: Square,
|
||||
): EntityId {
|
||||
const id = session.nextId();
|
||||
session.insert(id, "PieceType", type);
|
||||
session.insert(id, "Color", color);
|
||||
session.insert(id, "Position", square);
|
||||
session.insert(id, "HasMoved", true);
|
||||
return id;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "queen-splits",
|
||||
name: "Queen Fission",
|
||||
description: "When a Queen captures a piece, it splits into a Rook and Bishop placed on nearby empty squares.",
|
||||
description:
|
||||
"When a Queen captures, she splits: a Rook takes her place on the capture square and a Bishop is placed on the first empty adjacent square (clockwise from north).",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
// Full integration happens in ChessEngine (P3.11)
|
||||
|
||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
||||
const session = engine.session;
|
||||
const attackerTypeFact = session
|
||||
.allFacts()
|
||||
.find(f => f.id === attacker && f.attr === "PieceType");
|
||||
if (attackerTypeFact?.value !== "queen") return; // default capture runs
|
||||
|
||||
const attackerColorFact = session
|
||||
.allFacts()
|
||||
.find(f => f.id === attacker && f.attr === "Color");
|
||||
if (attackerColorFact === undefined) return;
|
||||
const attackerColor = attackerColorFact.value as PieceColor;
|
||||
|
||||
const targetPos = session.get(target, "Position") as Square | undefined;
|
||||
if (targetPos === undefined) return;
|
||||
|
||||
// Retract target (normal capture) and queen (she's fissioning).
|
||||
retractEntity(session, target);
|
||||
retractEntity(session, attacker);
|
||||
|
||||
// Spawn the rook on the capture square.
|
||||
spawnPiece(session, "rook", attackerColor, targetPos);
|
||||
|
||||
// Find the first empty adjacent square clockwise from N and spawn
|
||||
// the bishop there. If all 8 are occupied (rare — usually happens
|
||||
// in dense midgame around a king), the bishop is forfeit.
|
||||
const tFile = fileOf(targetPos);
|
||||
const tRank = rankOf(targetPos);
|
||||
for (const [df, dr] of CLOCKWISE_NEIGHBOURS) {
|
||||
const f = tFile + df;
|
||||
const r = tRank + dr;
|
||||
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
|
||||
const sq = squareOf(f, r) as Square;
|
||||
if (pieceAtSquare(session, sq) !== null) continue;
|
||||
spawnPiece(session, "bishop", attackerColor, sq);
|
||||
break;
|
||||
}
|
||||
|
||||
return { consume: true };
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,60 @@
|
|||
/**
|
||||
* 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`).
|
||||
*/
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { ChessEngine, GameResult } 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,57 @@ 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;
|
||||
|
||||
/**
|
||||
* Fires after every successful `applyMove`, after turn advancement
|
||||
* and tickAfterMove but before checkGameResult. `moverColor` is the
|
||||
* color that just moved. Use this for "end of turn" regeneration,
|
||||
* status-effect processing, etc.
|
||||
*
|
||||
* Unlike the move-generation hooks, this fires on ALL active
|
||||
* presets regardless of scope. Each preset is responsible for
|
||||
* inspecting its own scope (via `engine.activePresets.list()`) if it
|
||||
* needs scope-aware behaviour — scope semantics for "something that
|
||||
* happens at turn boundaries" aren't one-size-fits-all (king-heals
|
||||
* wants to affect the non-mover; poisoned-squares damages the
|
||||
* mover).
|
||||
*/
|
||||
readonly onAfterMove?: (
|
||||
engine: ChessEngine,
|
||||
moverColor: "white" | "black",
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Hook into terminal-position detection. Return a concrete `GameResult`
|
||||
* to OVERRIDE the engine's default checkmate/stalemate/draw logic;
|
||||
* return `undefined` to let the default run. Multiple presets may
|
||||
* register; the first one returning a non-undefined value wins. Order
|
||||
* follows registration order (see PRESET_REGISTRY.getAll()).
|
||||
*
|
||||
* Used by `capture-to-win` (first capture sets the winner) and
|
||||
* `last-piece-standing` (annihilation replaces checkmate), which both
|
||||
* redefine "when is the game over".
|
||||
*/
|
||||
readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
22
packages/chess/src/presets/ui-overlays-index.ts
Normal file
22
packages/chess/src/presets/ui-overlays-index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 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";
|
||||
import "./poisoned-squares.ui.js";
|
||||
// Future rules with per-piece overlays add their .ui import here.
|
||||
|
|
@ -5,6 +5,13 @@ import type { LegalMove } from '../rules/types';
|
|||
import { Piece } from './Piece';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
import {
|
||||
getActivePieceOverlays,
|
||||
getActiveSquareOverlays,
|
||||
type PieceOverlayComponent,
|
||||
type SquareOverlayComponent,
|
||||
} from './preset-overlays';
|
||||
import '../presets/ui-overlays-index';
|
||||
|
||||
interface BoardProps {
|
||||
facts: ChessFact[];
|
||||
|
|
@ -19,6 +26,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 +37,33 @@ 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],
|
||||
);
|
||||
const squareOverlays: SquareOverlayComponent[] = useMemo(
|
||||
() => getActiveSquareOverlays(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>();
|
||||
|
|
@ -197,6 +233,13 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
|||
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
|
||||
)}
|
||||
|
||||
{/* Per-square preset overlays (poison tint, etc.). Each is
|
||||
a pure function of the square index; overlays decide for
|
||||
themselves whether to render on any given cell. */}
|
||||
{squareOverlays.map((SquareOverlay, i) => (
|
||||
<SquareOverlay key={`sq-${i}`} square={sq} />
|
||||
))}
|
||||
|
||||
{/*
|
||||
* Legal-target affordance — two visual forms:
|
||||
* - Quiet move (empty destination): small central dot
|
||||
|
|
@ -279,6 +322,8 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
|||
piece.color === turn &&
|
||||
(myColor === null || myColor === undefined || piece.color === myColor)
|
||||
}
|
||||
overlays={overlays}
|
||||
pieceFacts={factsById.get(piece.id) ?? []}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -134,9 +134,13 @@ function GameLayout({
|
|||
|
||||
const isGameOver = result !== 'ongoing';
|
||||
|
||||
// Confetti on checkmate
|
||||
// Confetti on any decisive win (checkmate or variant win condition).
|
||||
useEffect(() => {
|
||||
if (result === 'checkmate') {
|
||||
const isWin =
|
||||
result === 'checkmate' ||
|
||||
result === 'white-wins' ||
|
||||
result === 'black-wins';
|
||||
if (isWin) {
|
||||
const duration = 3000;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
|
|
@ -337,7 +341,19 @@ function GameLayout({
|
|||
data-testid="game-over"
|
||||
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
|
||||
{
|
||||
// Preset variants ('white-wins', 'black-wins') name
|
||||
// the winner explicitly because the variant may end
|
||||
// on a capture rather than a checkmate. Standard
|
||||
// chess result strings stay as-is.
|
||||
result === 'checkmate'
|
||||
? 'Checkmate!'
|
||||
: result === 'white-wins'
|
||||
? 'White wins!'
|
||||
: result === 'black-wins'
|
||||
? 'Black wins!'
|
||||
: `Draw: ${result.replace('draw-', '')}`
|
||||
}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -352,6 +368,7 @@ function GameLayout({
|
|||
onMove={handleMove}
|
||||
lastMove={lastMove}
|
||||
checkedKingSquare={checkedKingSquare}
|
||||
activePresetIds={activations.map((a) => a.id)}
|
||||
/>
|
||||
|
||||
{/* Overlay for game over to prevent further interaction visually */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PieceColor, PieceType } from '../schema';
|
||||
import type { ChessAttrMap, ChessFact, PieceColor, PieceType } from '../schema';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
import {
|
||||
motion,
|
||||
|
|
@ -6,8 +6,9 @@ import {
|
|||
useSpring,
|
||||
useTransform,
|
||||
} from 'motion/react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { DragEvent as ReactDragEvent } from 'react';
|
||||
import type { PieceOverlayComponent } from './preset-overlays';
|
||||
|
||||
export interface PieceProps {
|
||||
color: PieceColor;
|
||||
|
|
@ -18,6 +19,14 @@ export interface PieceProps {
|
|||
* ongoing, etc). When false, drag is disabled and the piece shows a
|
||||
* default cursor. */
|
||||
isDraggable: boolean;
|
||||
/** Per-piece preset overlays (HP pips, ammo counter, etc.). Rendered
|
||||
* INSIDE the drag-transform layer so they follow the piece as it's
|
||||
* translated / rotated / scaled during a drag. Pass [] when no
|
||||
* overlays are active. */
|
||||
overlays?: ReadonlyArray<PieceOverlayComponent>;
|
||||
/** Facts for this piece, used by the overlays. Pre-indexed by the
|
||||
* Board so the overlay component doesn't re-scan all facts. */
|
||||
pieceFacts?: ReadonlyArray<ChessFact<keyof ChessAttrMap>>;
|
||||
onDragStart: (pieceId: number, square: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
|
@ -81,6 +90,8 @@ export function Piece({
|
|||
pieceId,
|
||||
square,
|
||||
isDraggable,
|
||||
overlays,
|
||||
pieceFacts,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: PieceProps) {
|
||||
|
|
@ -344,6 +355,21 @@ export function Piece({
|
|||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/*
|
||||
* Per-piece preset overlays (HP pips, etc.) live INSIDE the
|
||||
* transform layer so they inherit the drag `x/y/rotate/scale`
|
||||
* motion values and track the piece during drags. Overlays
|
||||
* that don't apply to this piece's facts return null so the
|
||||
* DOM stays clean.
|
||||
*/}
|
||||
{overlays?.map((Overlay, i) => (
|
||||
<Overlay
|
||||
key={i}
|
||||
pieceId={pieceId}
|
||||
pieceFacts={pieceFacts ?? []}
|
||||
/>
|
||||
)) as ReactNode}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
120
packages/chess/src/ui/preset-overlays.tsx
Normal file
120
packages/chess/src/ui/preset-overlays.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-square overlay — rendered inside every cell of the board,
|
||||
* regardless of whether the cell is occupied. Used for rules that
|
||||
* decorate the board itself (poisoned squares, starting/promotion
|
||||
* zones in future variants) rather than individual pieces.
|
||||
*
|
||||
* The component receives the cell's 0..63 square index and decides
|
||||
* whether to render anything. Returning null is fine and keeps the
|
||||
* DOM tree clean when the overlay doesn't apply to a given cell.
|
||||
*/
|
||||
export interface SquareOverlayProps {
|
||||
readonly square: number;
|
||||
}
|
||||
export type SquareOverlayComponent = (props: SquareOverlayProps) => ReactNode;
|
||||
|
||||
const squareRegistry = new Map<string, SquareOverlayComponent>();
|
||||
|
||||
export function registerSquareOverlay(
|
||||
presetId: string,
|
||||
component: SquareOverlayComponent,
|
||||
): void {
|
||||
squareRegistry.set(presetId, component);
|
||||
}
|
||||
|
||||
export function getActiveSquareOverlays(
|
||||
activePresetIds: ReadonlyArray<string>,
|
||||
): SquareOverlayComponent[] {
|
||||
const out: SquareOverlayComponent[] = [];
|
||||
for (const id of activePresetIds) {
|
||||
const c = squareRegistry.get(id);
|
||||
if (c !== undefined) out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -66,7 +66,9 @@ export type GameEndReason =
|
|||
| "stalemate"
|
||||
| "50-move"
|
||||
| "threefold"
|
||||
| "insufficient";
|
||||
| "insufficient"
|
||||
// Preset-defined decisive result; see GameResult variants.
|
||||
| "variant-win";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GameSession
|
||||
|
|
@ -99,7 +101,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 +131,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 =
|
||||
|
|
@ -328,6 +330,13 @@ function mapGameResult(
|
|||
return { winner: "draw", reason: "threefold" };
|
||||
case "draw-insufficient":
|
||||
return { winner: "draw", reason: "insufficient" };
|
||||
// Preset-defined decisive results. The engine names the winner
|
||||
// explicitly because variant rules don't always pair "winner"
|
||||
// with "side to move" the way standard checkmate does.
|
||||
case "white-wins":
|
||||
return { winner: "white", reason: "variant-win" };
|
||||
case "black-wins":
|
||||
return { winner: "black", reason: "variant-win" };
|
||||
default: {
|
||||
// Exhaustiveness guard — if GameResult ever grows a variant, TS
|
||||
// will flag this by failing the never-cast.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ export const GameEndReasonSchema = z.enum([
|
|||
"threefold",
|
||||
"insufficient",
|
||||
"player_left",
|
||||
// Preset-defined decisive result (first-blood, annihilation, etc.).
|
||||
// The UI reads the winner separately; this reason tag just signals
|
||||
// "variant rule ended the game" to clients that care.
|
||||
"variant-win",
|
||||
]);
|
||||
export type GameEndReason = z.infer<typeof GameEndReasonSchema>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue