feat(chess): flesh out all six remaining stub presets
Implements king-heals, poisoned-squares, capture-to-win,
last-piece-standing, explosive-rook, and queen-splits via the new
preset hook infrastructure. All six were registered but no-op stubs
with "// Full integration in ChessEngine (P3.11)" comments that
never got followed up.
New hooks on PresetDef
~~~~~~~~~~~~~~~~~~~~~~~
- onAfterMove(engine, moverColor)
Fires after applyMove`s turn switch but before game-result check.
Scope filtering is preset-side because rules have different
notions of "who does the hook target" (king-heals targets the
non-mover; poisoned-squares targets everyone on a poisoned
square).
- onCheckGameResult(engine) -> GameResult | undefined
Lets a preset override the engine`s default terminal-position
logic. First non-undefined return wins in registration order.
GameResult type extended with white-wins / black-wins so variant
rules can name the winner explicitly (checkmate implicitly means
"side to move loses" — insufficient for capture-to-win which can
end mid-move with either side winning).
Preset implementations
~~~~~~~~~~~~~~~~~~~~~~~
- king-heals: onAfterMove heals non-mover`s king +1 HP (max 3) when
not in check. Requires piece-hp.
- poisoned-squares: onAfterMove damages every piece standing on
d4/e4/d5/e5. Retracts pieces hitting 0 HP. Requires piece-hp.
Exports POISONED_SQUARES set for the UI overlay.
- capture-to-win: onBeforeCapture records the capturer`s color on
the game entity via Winner fact. onCheckGameResult converts it
into white-wins/black-wins. onDeactivate clears the Winner fact.
- last-piece-standing: onCheckGameResult counts pieces by color.
Zero on one side -> the other wins. Override returns "ongoing"
otherwise, suppressing default checkmate.
- explosive-rook: onBeforeCapture intercepts rook captures,
detonates all pieces within Chebyshev distance 2 on the rank
and file of the target (diagonals spared), moves the rook onto
the target square. Does not chain.
- queen-splits: onBeforeCapture intercepts queen captures,
retracts target and queen, spawns rook on target square and
bishop on first empty clockwise-from-N neighbour. Bishop is
forfeit if all 8 neighbours are occupied.
Board.tsx gains a per-SQUARE overlay registry (separate from per-
piece) so poisoned-squares can render a pulsing green tint on the
four central squares. The poisoned-squares.ui.tsx module registers
its overlay at load time via ui-overlays-index.ts.
GameView shows "White wins!"/"Black wins!" for the new result
variants; confetti fires on any decisive result; useChessEngine
and useMultiplayerGame play the checkmate sound on decisive results.
Server-side: GameEndReason extended with "variant-win" so the wire
protocol can signal decisive variant results without conflating
them with checkmate. MoveResult.gameOver exposes the correct
winner (not the mover) for white-wins/black-wins.
Testing
~~~~~~~
New fleshed-presets.test.ts: 13 integration tests covering each
preset`s canonical behaviour (healing cap, denial on check, poison
decrement + kill, first-capture-wins, annihilation override,
detonation AoE, queen fission spawn). 874 tests pass (+13); all 3
E2E specs green.
This commit is contained in:
parent
2739cc2689
commit
1e292d6c00
18 changed files with 1077 additions and 21 deletions
|
|
@ -73,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 {
|
||||
|
|
@ -343,10 +349,30 @@ export class ChessEngine {
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
* 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
|
||||
|
|
@ -82,6 +82,38 @@ export interface PresetDef {
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,4 +18,5 @@
|
|||
*/
|
||||
|
||||
import "./piece-hp.ui.js";
|
||||
import "./poisoned-squares.ui.js";
|
||||
// Future rules with per-piece overlays add their .ui import here.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ 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';
|
||||
|
||||
|
|
@ -42,6 +44,10 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
|||
() => 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
|
||||
|
|
@ -227,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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -83,3 +83,38 @@ export function getActivePieceOverlays(
|
|||
}
|
||||
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
|
||||
|
|
@ -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