feat(chess): add preset rules 1-3 (P3.4)

Introduces PresetRegistry + three pawn-focused preset rules from
RULES.md (pawns-move-backward, double-pawn-sprint,
pawn-diagonal-no-capture). Presets register themselves via
side-effect imports and expose getExtraMoves/filterMoves hooks for
the ChessEngine to invoke during move generation (engine wiring is
P3.11). Registry enforces incompatibility and requires invariants.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:30:14 -06:00
commit cf1a8a3aab
No known key found for this signature in database
9 changed files with 636 additions and 0 deletions

View file

@ -0,0 +1,58 @@
/**
* Preset: `bishops-ignore-color` (RULES.md rule #5)
*
* In addition to their normal diagonal slides, bishops may make a single
* one-square orthogonal (N/S/E/W) step to an empty or enemy-occupied square.
* This allows a bishop to eventually reach squares of either color.
*/
import type { PieceColor } from "../schema.js";
import { fileOf, rankOf, squareOf, isOnBoard } from "../coord.js";
import { isAllyAt, isPieceAt } from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
const ORTHOGONAL_DELTAS: ReadonlyArray<readonly [number, number]> = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
];
PRESET_REGISTRY.register({
id: "bishops-ignore-color",
name: "Colour-Blind Bishops",
description: "Bishops may additionally step one square orthogonally to an empty or enemy-occupied square.",
incompatibleWith: [],
requires: [],
getExtraMoves: (engine, pieceId) => {
const session = engine.session;
const facts = session.allFacts();
const typeFact = facts.find(f => f.id === pieceId && f.attr === "PieceType");
if (!typeFact || typeFact.value !== "bishop") return [];
const colorFact = facts.find(f => f.id === pieceId && f.attr === "Color");
const posFact = facts.find(f => f.id === pieceId && f.attr === "Position");
if (!colorFact || !posFact) return [];
const color = colorFact.value as PieceColor;
const from = posFact.value as number;
const file = fileOf(from);
const rank = rankOf(from);
const moves = [];
for (const [df, dr] of ORTHOGONAL_DELTAS) {
const nf = file + df;
const nr = rank + dr;
if (!isOnBoard(nf, nr)) continue;
const to = squareOf(nf, nr);
if (isAllyAt(session, to, color)) continue;
moves.push({
pieceId,
from,
to,
isCapture: isPieceAt(session, to),
});
}
return moves;
},
});

View file

@ -0,0 +1,62 @@
/**
* Preset: Perpetual Sprint / Double-Pawn Sprint (RULES.md rule 2).
*
* A pawn may advance two squares straight forward from ANY rank (not only
* its home rank), provided both intervening squares are empty.
*
* Normal pawn rules already generate the home-rank double advance, so this
* preset only adds the double advance when the pawn is OFF its home rank.
*
* Mode: override (conceptually removes the `HasMoved = false` guard).
* Incompatible with `pawns-move-backward`.
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
import type { PieceColor, Square } from "../schema.js";
import { rankOf } from "../coord.js";
import {
getPiecePosition,
getPieceColor,
isPieceAt,
} from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
function getDoubleSprintMove(engine: ChessEngine, pieceId: EntityId): LegalMove[] {
const pieceType = engine.session.get(pieceId, "PieceType");
if (pieceType !== "pawn") return [];
const from = getPiecePosition(engine.session, pieceId);
const color = getPieceColor(engine.session, pieceId) as PieceColor | null;
if (from === null || color === null) return [];
// Skip if on home rank — the normal double-push rule already generates it.
const rank = rankOf(from);
const homeRank = color === "white" ? 1 : 6;
if (rank === homeRank) return [];
const step = color === "white" ? 8 : -8;
const single = from + step;
const dbl = from + step * 2;
// Stay on-board (cannot wrap past rank 7 / 0).
const singleRank = color === "white" ? rank + 1 : rank - 1;
const dblRank = color === "white" ? rank + 2 : rank - 2;
if (singleRank < 0 || singleRank > 7) return [];
if (dblRank < 0 || dblRank > 7) return [];
if (isPieceAt(engine.session, single as Square)) return [];
if (isPieceAt(engine.session, dbl as Square)) return [];
return [{ pieceId, from, to: dbl as Square, isCapture: false }];
}
PRESET_REGISTRY.register({
id: "double-pawn-sprint",
name: "Perpetual Sprint",
description:
"Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).",
incompatibleWith: ["pawns-move-backward"],
requires: [],
getExtraMoves: getDoubleSprintMove,
});

View file

@ -0,0 +1,15 @@
/**
* Preset rule barrel + registration side-effects (P3.4+).
*
* Importing this module guarantees all preset rules are registered in
* PRESET_REGISTRY. Consumers should import `./index.js` (never the registry
* directly) to ensure preset side-effect registration has run.
*/
import "./pawns-move-backward.js";
import "./double-pawn-sprint.js";
import "./pawn-diagonal-no-capture.js";
import "./knights-leap-twice.js";
import "./bishops-ignore-color.js";
import "./rook-warp.js";
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";

View file

@ -0,0 +1,58 @@
/**
* Preset: `knights-leap-twice` (RULES.md rule #4)
*
* Knights make two consecutive L-shaped leaps in a single turn. The intermediate
* square must be empty. Single-leap destinations remain legal (handled by the
* base FIDE knight rule); this preset only contributes the *additional*
* two-leap destinations.
*/
import type { PieceColor } from "../schema.js";
import { knightCandidates } from "../rules/primitives.js";
import { isAllyAt, isPieceAt } from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
id: "knights-leap-twice",
name: "Double-Leap Knights",
description: "Knights move in two consecutive L-shapes in a single turn; the intermediate square must be empty.",
incompatibleWith: [],
requires: [],
getExtraMoves: (engine, pieceId) => {
const session = engine.session;
const facts = session.allFacts();
const typeFact = facts.find(f => f.id === pieceId && f.attr === "PieceType");
if (!typeFact || typeFact.value !== "knight") return [];
const colorFact = facts.find(f => f.id === pieceId && f.attr === "Color");
const posFact = facts.find(f => f.id === pieceId && f.attr === "Position");
if (!colorFact || !posFact) return [];
const color = colorFact.value as PieceColor;
const from = posFact.value as number;
// Single-leap targets (to exclude from extras — base rule already emits these).
const singleLeaps = new Set<number>(knightCandidates(from));
// Two-leap targets: for each intermediate empty square reachable by one leap,
// collect all knight-leap targets from there.
const twoLeapDests = new Set<number>();
for (const mid of knightCandidates(from)) {
// Intermediate square must be empty (per RULES.md §Double-Leap Knights).
if (isPieceAt(session, mid)) continue;
for (const dest of knightCandidates(mid)) {
if (dest === from) continue; // null-move prune
if (singleLeaps.has(dest)) continue; // base rule covers it
if (isAllyAt(session, dest, color)) continue; // cannot land on ally
twoLeapDests.add(dest);
}
}
return [...twoLeapDests].map(to => ({
pieceId,
from,
to,
isCapture: isPieceAt(session, to),
}));
},
});

View file

@ -0,0 +1,50 @@
/**
* Preset: Slanting Pawns (RULES.md rule 3).
*
* Pawns may move one square diagonally forward to an EMPTY square without
* capturing, in addition to all normal pawn moves. Diagonal moves onto
* occupied enemy squares still capture exactly as in FIDE (unchanged).
*
* Mode: additive. Compatible with every other preset.
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
import type { PieceColor, Square } from "../schema.js";
import { pawnCaptureSqares } from "../rules/primitives.js";
import {
getPiecePosition,
getPieceColor,
isPieceAt,
} from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
function getDiagonalQuietMoves(engine: ChessEngine, pieceId: EntityId): LegalMove[] {
const pieceType = engine.session.get(pieceId, "PieceType");
if (pieceType !== "pawn") return [];
const from = getPiecePosition(engine.session, pieceId);
const color = getPieceColor(engine.session, pieceId) as PieceColor | null;
if (from === null || color === null) return [];
const moves: LegalMove[] = [];
for (const sq of pawnCaptureSqares(from, color)) {
// Only add the diagonal square if it is EMPTY.
// (If it is enemy-occupied, the normal capture rule handles it.
// If it is friend-occupied, the move is illegal either way.)
if (!isPieceAt(engine.session, sq as Square)) {
moves.push({ pieceId, from, to: sq as Square, isCapture: false });
}
}
return moves;
}
PRESET_REGISTRY.register({
id: "pawn-diagonal-no-capture",
name: "Slanting Pawns",
description:
"Pawns may move one square diagonally forward to an empty square without capturing.",
incompatibleWith: [],
requires: [],
getExtraMoves: getDiagonalQuietMoves,
});

View file

@ -0,0 +1,125 @@
/**
* Tests for preset rule 1 (pawns-move-backward) and registry invariants
* required by P3.4.
*/
import { describe, it, expect, beforeEach } from "vitest";
import type { EntityId } from "@paratype/rete";
import { ChessEngine } from "../engine.js";
import { PRESET_REGISTRY } from "./index.js";
import type { Square } from "../schema.js";
describe("Preset registry (P3.4)", () => {
beforeEach(() => {
PRESET_REGISTRY.clear();
});
it("pawns-move-backward preset is registered", () => {
const all = PRESET_REGISTRY.getAll();
expect(all.some((p) => p.id === "pawns-move-backward")).toBe(true);
});
it("registry has at least 3 presets registered after P3.4", () => {
const ids = PRESET_REGISTRY.getAll().map((p) => p.id);
expect(ids).toContain("pawns-move-backward");
expect(ids).toContain("double-pawn-sprint");
expect(ids).toContain("pawn-diagonal-no-capture");
expect(PRESET_REGISTRY.getAll().length).toBeGreaterThanOrEqual(3);
});
it("activate / isActive / deactivate round-trip", () => {
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
PRESET_REGISTRY.activate("pawns-move-backward");
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(true);
expect(PRESET_REGISTRY.getActive().map((p) => p.id)).toContain(
"pawns-move-backward",
);
PRESET_REGISTRY.deactivate("pawns-move-backward");
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
});
it("activating an unknown preset throws", () => {
expect(() => PRESET_REGISTRY.activate("nonexistent-rule")).toThrow(
/Unknown preset/,
);
});
it("cannot activate two mutually-incompatible presets", () => {
PRESET_REGISTRY.activate("pawns-move-backward");
expect(() => PRESET_REGISTRY.activate("double-pawn-sprint")).toThrow(
/incompatible/,
);
});
});
describe("Preset: pawns-move-backward", () => {
beforeEach(() => {
PRESET_REGISTRY.clear();
});
const preset = () =>
PRESET_REGISTRY.getAll().find((p) => p.id === "pawns-move-backward")!;
/** Find the white a-pawn (starts on a2 = square 8). */
function findPawnAt(engine: ChessEngine, square: Square): EntityId | null {
const facts = engine.session.allFacts();
const pos = facts.find(
(f) => f.attr === "Position" && f.value === square,
);
return pos ? (pos.id as EntityId) : null;
}
it("declares incompatibility with double-pawn-sprint", () => {
expect(preset().incompatibleWith).toContain("double-pawn-sprint");
});
it("has no other hard requirements", () => {
expect(preset().requires).toEqual([]);
});
it("adds a backward move when the square behind the pawn is empty", () => {
const engine = new ChessEngine();
// Move the white a-pawn forward from a2 (8) to a4 (24), then move the
// white rook from a1 (0) out of the way so a3 (16) — the "backward"
// target from a4 — is an empty square.
const whitePawn = findPawnAt(engine, 8)!;
expect(whitePawn).not.toBeNull();
engine.session.insert(whitePawn, "Position", 24 as Square); // a2 -> a4
const extra = preset().getExtraMoves!(engine, whitePawn);
expect(extra).toHaveLength(1);
expect(extra[0]!.to).toBe(16); // a3
expect(extra[0]!.isCapture).toBe(false);
expect(extra[0]!.from).toBe(24);
});
it("returns no backward move when the destination is occupied", () => {
const engine = new ChessEngine();
// White a-pawn is on a2 (8). One square backward = a1 (0), occupied by
// the white rook in the starting position → preset must return [].
const whitePawn = findPawnAt(engine, 8)!;
const extra = preset().getExtraMoves!(engine, whitePawn);
expect(extra).toEqual([]);
});
it("black pawn moves backward toward rank 8 (higher index)", () => {
const engine = new ChessEngine();
// Black a-pawn starts on a7 = square 48. Move it forward to a5 (32);
// then the square "behind" it from Black's POV is a6 (40).
const blackPawn = findPawnAt(engine, 48)!;
expect(blackPawn).not.toBeNull();
engine.session.insert(blackPawn, "Position", 32 as Square);
const extra = preset().getExtraMoves!(engine, blackPawn);
expect(extra).toHaveLength(1);
expect(extra[0]!.to).toBe(40); // a6
expect(extra[0]!.isCapture).toBe(false);
});
it("returns no moves when given a non-pawn piece", () => {
const engine = new ChessEngine();
// The white rook on a1 (square 0).
const rook = findPawnAt(engine, 0)!;
const extra = preset().getExtraMoves!(engine, rook);
expect(extra).toEqual([]);
});
});

View file

@ -0,0 +1,48 @@
/**
* Preset: Backward-Marching Pawns (RULES.md rule 1).
*
* Pawns may additionally move exactly one square straight backward to an
* empty square. Backward moves may NOT capture and do NOT enable en passant.
*
* Mode: additive. Incompatible with `double-pawn-sprint`.
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
import type { PieceColor, Square } from "../schema.js";
import { rankOf } from "../coord.js";
import {
getPiecePosition,
getPieceColor,
isPieceAt,
} from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
function getPawnBackwardMove(engine: ChessEngine, pieceId: EntityId): LegalMove[] {
const pieceType = engine.session.get(pieceId, "PieceType");
if (pieceType !== "pawn") return [];
const from = getPiecePosition(engine.session, pieceId);
const color = getPieceColor(engine.session, pieceId);
if (from === null || color === null) return [];
// Backward = opposite of normal advance direction.
const rank = rankOf(from);
const targetRank = (color as PieceColor) === "white" ? rank - 1 : rank + 1;
if (targetRank < 0 || targetRank > 7) return [];
const to = (from + ((color as PieceColor) === "white" ? -8 : 8)) as Square;
if (isPieceAt(engine.session, to)) return [];
return [{ pieceId, from, to, isCapture: false }];
}
PRESET_REGISTRY.register({
id: "pawns-move-backward",
name: "Backward-Marching Pawns",
description:
"Pawns may also move 1 square straight backward to an empty square (no capture).",
incompatibleWith: ["double-pawn-sprint"],
requires: [],
getExtraMoves: getPawnBackwardMove,
});

View file

@ -0,0 +1,85 @@
/**
* 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:
*
* - getExtraMoves: returns ADDITIONAL legal moves for a piece.
* - filterMoves: removes/modifies entries in an already-computed move list.
*
* Presets register themselves via side-effect imports (see `./index.ts`).
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
export interface PresetDef {
readonly id: string;
readonly name: string;
readonly description: string;
/** Preset IDs whose activation must NOT overlap with this one. */
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). */
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[];
}
class PresetRegistryClass {
private readonly presets = new Map<string, PresetDef>();
private readonly active = new Set<string>();
register(preset: PresetDef): void {
this.presets.set(preset.id, preset);
}
activate(id: string): void {
const preset = this.presets.get(id);
if (!preset) throw new Error(`Unknown preset: ${id}`);
for (const other of this.active) {
const otherDef = this.presets.get(other);
if (otherDef?.incompatibleWith.includes(id)) {
throw new Error(`Preset ${id} is incompatible with already-active ${other}`);
}
if (preset.incompatibleWith.includes(other)) {
throw new Error(`Preset ${id} is incompatible with already-active ${other}`);
}
}
for (const dep of preset.requires) {
if (!this.active.has(dep)) {
throw new Error(`Preset ${id} requires ${dep} to be active first`);
}
}
this.active.add(id);
}
deactivate(id: string): void {
this.active.delete(id);
}
isActive(id: string): boolean {
return this.active.has(id);
}
getActive(): PresetDef[] {
return [...this.active]
.map((id) => this.presets.get(id))
.filter((p): p is PresetDef => p !== undefined);
}
getAll(): PresetDef[] {
return [...this.presets.values()];
}
clear(): void {
this.active.clear();
}
}
export const PRESET_REGISTRY = new PresetRegistryClass();

View file

@ -0,0 +1,135 @@
/**
* Preset: `rook-warp` (RULES.md rule #6)
*
* After the rook's normal slide is exhausted, it may "warp" continuing the
* slide as if the far edge wrapped to the near edge landing on the far end
* of the same rank or file. Warp is interrupted by friendly pieces and by the
* first enemy piece encountered (capturing it).
*
* This preset contributes the warp destinations as additional legal moves
* beyond the base FIDE rook rule. Destinations on the rook's current square
* or that coincide with base-rule destinations are omitted.
*/
import type { PieceColor, Square } from "../schema.js";
import { fileOf, rankOf, squareOf } from "../coord.js";
import { isAllyAt, isEnemyAt, isPieceAt } from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
import type { Session } from "@paratype/rete";
/**
* Walk a straight path collecting the warp destination, stopping at the first
* ally (no destination) or enemy (capture destination).
*
* @param path squares in the order they are "entered" after the wrap.
*/
function warpDest(
session: Session,
path: ReadonlyArray<Square>,
color: PieceColor,
): Square | null {
for (const sq of path) {
if (isAllyAt(session, sq, color)) return null;
if (isEnemyAt(session, sq, color)) return sq; // capture and stop
}
// Entire path empty: the last square on the path is the warp destination.
return path.length > 0 ? (path[path.length - 1] ?? null) : null;
}
PRESET_REGISTRY.register({
id: "rook-warp",
name: "Rook Warp",
description: "After a rook's normal slide, it may warp around the board edge to the opposite end of its rank or file.",
incompatibleWith: ["wrap-board"],
requires: [],
getExtraMoves: (engine, pieceId) => {
const session = engine.session;
const facts = session.allFacts();
const typeFact = facts.find(f => f.id === pieceId && f.attr === "PieceType");
if (!typeFact || typeFact.value !== "rook") return [];
const colorFact = facts.find(f => f.id === pieceId && f.attr === "Color");
const posFact = facts.find(f => f.id === pieceId && f.attr === "Position");
if (!colorFact || !posFact) return [];
const color = colorFact.value as PieceColor;
const from = posFact.value as number;
const file = fileOf(from);
const rank = rankOf(from);
// Four warp directions. For each, we must establish that the rook can
// reach the edge in that direction (base slide reaches the edge), then
// the warp path starts at the opposite edge and proceeds toward `from`,
// stopping just before `from`.
//
// To keep this preset simple and aligned with the base slide, we require
// the full "forward" path to the edge to be empty — otherwise the rook
// could not have wrapped around. The warp candidate is then the first
// reachable square on the opposite side (per warpDest semantics).
const destinations = new Set<Square>();
// Right → wraps from file 0.
if (file < 7) {
let clearToEdge = true;
for (let f = file + 1; f <= 7; f++) {
if (isPieceAt(session, squareOf(f, rank))) { clearToEdge = false; break; }
}
if (clearToEdge) {
const path: Square[] = [];
for (let f = 0; f < file; f++) path.push(squareOf(f, rank));
const dest = warpDest(session, path, color);
if (dest !== null && dest !== from) destinations.add(dest);
}
}
// Left → wraps from file 7.
if (file > 0) {
let clearToEdge = true;
for (let f = file - 1; f >= 0; f--) {
if (isPieceAt(session, squareOf(f, rank))) { clearToEdge = false; break; }
}
if (clearToEdge) {
const path: Square[] = [];
for (let f = 7; f > file; f--) path.push(squareOf(f, rank));
const dest = warpDest(session, path, color);
if (dest !== null && dest !== from) destinations.add(dest);
}
}
// Up → wraps from rank 0.
if (rank < 7) {
let clearToEdge = true;
for (let r = rank + 1; r <= 7; r++) {
if (isPieceAt(session, squareOf(file, r))) { clearToEdge = false; break; }
}
if (clearToEdge) {
const path: Square[] = [];
for (let r = 0; r < rank; r++) path.push(squareOf(file, r));
const dest = warpDest(session, path, color);
if (dest !== null && dest !== from) destinations.add(dest);
}
}
// Down → wraps from rank 7.
if (rank > 0) {
let clearToEdge = true;
for (let r = rank - 1; r >= 0; r--) {
if (isPieceAt(session, squareOf(file, r))) { clearToEdge = false; break; }
}
if (clearToEdge) {
const path: Square[] = [];
for (let r = 7; r > rank; r--) path.push(squareOf(file, r));
const dest = warpDest(session, path, color);
if (dest !== null && dest !== from) destinations.add(dest);
}
}
return [...destinations].map(to => ({
pieceId,
from,
to,
isCapture: isPieceAt(session, to),
}));
},
});