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:
parent
35e270e3b5
commit
cf1a8a3aab
9 changed files with 636 additions and 0 deletions
58
packages/chess/src/presets/bishops-ignore-color.ts
Normal file
58
packages/chess/src/presets/bishops-ignore-color.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
62
packages/chess/src/presets/double-pawn-sprint.ts
Normal file
62
packages/chess/src/presets/double-pawn-sprint.ts
Normal 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,
|
||||
});
|
||||
15
packages/chess/src/presets/index.ts
Normal file
15
packages/chess/src/presets/index.ts
Normal 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";
|
||||
58
packages/chess/src/presets/knights-leap-twice.ts
Normal file
58
packages/chess/src/presets/knights-leap-twice.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
50
packages/chess/src/presets/pawn-diagonal-no-capture.ts
Normal file
50
packages/chess/src/presets/pawn-diagonal-no-capture.ts
Normal 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,
|
||||
});
|
||||
125
packages/chess/src/presets/pawns-move-backward.test.ts
Normal file
125
packages/chess/src/presets/pawns-move-backward.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
48
packages/chess/src/presets/pawns-move-backward.ts
Normal file
48
packages/chess/src/presets/pawns-move-backward.ts
Normal 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,
|
||||
});
|
||||
85
packages/chess/src/presets/registry.ts
Normal file
85
packages/chess/src/presets/registry.ts
Normal 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();
|
||||
135
packages/chess/src/presets/rook-warp.ts
Normal file
135
packages/chess/src/presets/rook-warp.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue