From cf1a8a3aab839c74158fa9e2eee4b078fb7ad29c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 15:30:14 -0600 Subject: [PATCH] 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. --- .../chess/src/presets/bishops-ignore-color.ts | 58 ++++++++ .../chess/src/presets/double-pawn-sprint.ts | 62 ++++++++ packages/chess/src/presets/index.ts | 15 ++ .../chess/src/presets/knights-leap-twice.ts | 58 ++++++++ .../src/presets/pawn-diagonal-no-capture.ts | 50 +++++++ .../src/presets/pawns-move-backward.test.ts | 125 ++++++++++++++++ .../chess/src/presets/pawns-move-backward.ts | 48 +++++++ packages/chess/src/presets/registry.ts | 85 +++++++++++ packages/chess/src/presets/rook-warp.ts | 135 ++++++++++++++++++ 9 files changed, 636 insertions(+) create mode 100644 packages/chess/src/presets/bishops-ignore-color.ts create mode 100644 packages/chess/src/presets/double-pawn-sprint.ts create mode 100644 packages/chess/src/presets/index.ts create mode 100644 packages/chess/src/presets/knights-leap-twice.ts create mode 100644 packages/chess/src/presets/pawn-diagonal-no-capture.ts create mode 100644 packages/chess/src/presets/pawns-move-backward.test.ts create mode 100644 packages/chess/src/presets/pawns-move-backward.ts create mode 100644 packages/chess/src/presets/registry.ts create mode 100644 packages/chess/src/presets/rook-warp.ts diff --git a/packages/chess/src/presets/bishops-ignore-color.ts b/packages/chess/src/presets/bishops-ignore-color.ts new file mode 100644 index 0000000..43f53e7 --- /dev/null +++ b/packages/chess/src/presets/bishops-ignore-color.ts @@ -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 = [ + [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; + }, +}); diff --git a/packages/chess/src/presets/double-pawn-sprint.ts b/packages/chess/src/presets/double-pawn-sprint.ts new file mode 100644 index 0000000..298db5b --- /dev/null +++ b/packages/chess/src/presets/double-pawn-sprint.ts @@ -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, +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts new file mode 100644 index 0000000..679325c --- /dev/null +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/knights-leap-twice.ts b/packages/chess/src/presets/knights-leap-twice.ts new file mode 100644 index 0000000..30968ca --- /dev/null +++ b/packages/chess/src/presets/knights-leap-twice.ts @@ -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(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(); + 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), + })); + }, +}); diff --git a/packages/chess/src/presets/pawn-diagonal-no-capture.ts b/packages/chess/src/presets/pawn-diagonal-no-capture.ts new file mode 100644 index 0000000..44d8993 --- /dev/null +++ b/packages/chess/src/presets/pawn-diagonal-no-capture.ts @@ -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, +}); diff --git a/packages/chess/src/presets/pawns-move-backward.test.ts b/packages/chess/src/presets/pawns-move-backward.test.ts new file mode 100644 index 0000000..30d7d41 --- /dev/null +++ b/packages/chess/src/presets/pawns-move-backward.test.ts @@ -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([]); + }); +}); diff --git a/packages/chess/src/presets/pawns-move-backward.ts b/packages/chess/src/presets/pawns-move-backward.ts new file mode 100644 index 0000000..4af4338 --- /dev/null +++ b/packages/chess/src/presets/pawns-move-backward.ts @@ -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, +}); diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts new file mode 100644 index 0000000..12db5ab --- /dev/null +++ b/packages/chess/src/presets/registry.ts @@ -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(); + private readonly active = new Set(); + + 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(); diff --git a/packages/chess/src/presets/rook-warp.ts b/packages/chess/src/presets/rook-warp.ts new file mode 100644 index 0000000..a3197b6 --- /dev/null +++ b/packages/chess/src/presets/rook-warp.ts @@ -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, + 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(); + + // 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), + })); + }, +});