From f7099d754d5ba4cb595868d1c07c40b28e3c1a5c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 19:52:22 -0600 Subject: [PATCH] feat(chess): FEN + premade starting layouts (Phase B) Ships the 7 premades (Classic was in Phase A, the rest here) plus the FEN round-trip utility and layout validator. New premades: - dunsany: 31 white pawns + king vs full black army (asymmetric). - monster: white king + 4 central pawns vs full black army. - pawns-only: each side = king + 8 pawns on home rank. - horde: white 'wall' of 35 pawns + king + 4 advanced vs full army. - knightmate: swaps kings and knights on the back rank. - chess960: Fischer Random shim with buildChess960Layout(seed) factory following the Scharnagl numbering scheme. Position 518 matches FIDE. All 960 seeds are exhaustively tested for bishop- opposite-colors + king-between-rooks invariants. Deviations from canonical are documented in each file: - Dunsany, Horde: canonical versions are kingless; we add a king on e1 and swap out the conflicting pawn so the current validator accepts them. Will ship canonical versions once capture-all or a no-royalty preset lands. - Knightmate: validator relaxed to 'at least 1 king per side' (from 'exactly 1') to support multi-king layouts. New utilities: - fen.ts: toFen / fromFen for piece-placement field of standard FEN. Supports custom piece types via {Type} bracket extension. - validate.ts: errors on king-count/bounds/duplicate-square; warnings on pawn-on-rank-1/8 and >32-material. Tests: 50 new tests across fen.test.ts (16), validate.test.ts (17), premades.test.ts (13), chess960.test.ts (4 incl. exhaustive 960-seed invariant check). 991 tests passing; bun run check clean. --- packages/chess/src/layouts/_helpers.ts | 73 ++++++ packages/chess/src/layouts/chess960.test.ts | 124 +++++++++ packages/chess/src/layouts/chess960.ts | 202 +++++++++++++++ packages/chess/src/layouts/dunsany.ts | 50 ++++ packages/chess/src/layouts/fen.test.ts | 144 +++++++++++ packages/chess/src/layouts/fen.ts | 271 ++++++++++++++++++++ packages/chess/src/layouts/horde.ts | 66 +++++ packages/chess/src/layouts/index.ts | 13 + packages/chess/src/layouts/knightmate.ts | 82 ++++++ packages/chess/src/layouts/monster.ts | 37 +++ packages/chess/src/layouts/pawns-only.ts | 48 ++++ packages/chess/src/layouts/premades.test.ts | 79 ++++++ packages/chess/src/layouts/validate.test.ts | 166 ++++++++++++ packages/chess/src/layouts/validate.ts | 132 ++++++++++ 14 files changed, 1487 insertions(+) create mode 100644 packages/chess/src/layouts/_helpers.ts create mode 100644 packages/chess/src/layouts/chess960.test.ts create mode 100644 packages/chess/src/layouts/chess960.ts create mode 100644 packages/chess/src/layouts/dunsany.ts create mode 100644 packages/chess/src/layouts/fen.test.ts create mode 100644 packages/chess/src/layouts/fen.ts create mode 100644 packages/chess/src/layouts/horde.ts create mode 100644 packages/chess/src/layouts/knightmate.ts create mode 100644 packages/chess/src/layouts/monster.ts create mode 100644 packages/chess/src/layouts/pawns-only.ts create mode 100644 packages/chess/src/layouts/premades.test.ts create mode 100644 packages/chess/src/layouts/validate.test.ts create mode 100644 packages/chess/src/layouts/validate.ts diff --git a/packages/chess/src/layouts/_helpers.ts b/packages/chess/src/layouts/_helpers.ts new file mode 100644 index 0000000..c422823 --- /dev/null +++ b/packages/chess/src/layouts/_helpers.ts @@ -0,0 +1,73 @@ +/** + * Internal helpers for composing premade layouts. + * + * NOT part of the public layouts API. Premades use these to compose + * "FIDE back rank + pawn rank for one color" subsets without + * duplicating the `PieceType[]` array in every file. + * + * Naming: underscore prefix marks this as a module-private helper, + * not listed in the layouts barrel's public re-exports. + */ +import type { PieceType, PieceColor, Square } from "../schema.js"; +import type { PiecePlacement } from "./types.js"; +import { squareOf } from "../coord.js"; + +/** Standard FIDE back-rank piece order, file 0 (a) through file 7 (h). */ +export const FIDE_BACK_RANK: readonly PieceType[] = [ + "rook", + "knight", + "bishop", + "queen", + "king", + "bishop", + "knight", + "rook", +]; + +/** + * Build the 16-piece FIDE setup for one color. + * + * - White: back rank on rank 0 (rank 1 in algebraic), pawns on rank 1. + * - Black: back rank on rank 7 (rank 8 in algebraic), pawns on rank 6. + */ +export function fideSideOf(color: PieceColor): PiecePlacement[] { + const backRank = color === "white" ? 0 : 7; + const pawnRank = color === "white" ? 1 : 6; + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ + type: FIDE_BACK_RANK[file]!, + color, + square: squareOf(file, backRank) as Square, + }); + pieces.push({ + type: "pawn", + color, + square: squareOf(file, pawnRank) as Square, + }); + } + return pieces; +} + +/** + * Build pawns for one color filling every square on `ranks`. + * + * Used by Dunsany (white pawns on ranks 0-3) and Horde (white pawns + * on ranks 0-3 with specific gaps). + */ +export function pawnsOnRanks( + color: PieceColor, + ranks: readonly number[], +): PiecePlacement[] { + const pieces: PiecePlacement[] = []; + for (const rank of ranks) { + for (let file = 0; file < 8; file++) { + pieces.push({ + type: "pawn", + color, + square: squareOf(file, rank) as Square, + }); + } + } + return pieces; +} diff --git a/packages/chess/src/layouts/chess960.test.ts b/packages/chess/src/layouts/chess960.test.ts new file mode 100644 index 0000000..1a0e687 --- /dev/null +++ b/packages/chess/src/layouts/chess960.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { buildChess960Layout } from "./chess960.js"; +import { fileOf, rankOf } from "../coord.js"; +import type { PieceType } from "../schema.js"; + +/** + * Chess960 invariants (Scharnagl scheme): + * + * - Both bishops on opposite-color squares (one light, one dark). + * - King is strictly between the two rooks on the back rank. + * - Black back rank mirrors white. + * - Piece count exactly matches FIDE (8 pawns + 8 back-rank per side). + * - Position 518 equals FIDE. + */ + +function whiteBackRank(pieces: ReturnType["pieces"]) { + const row: (PieceType | null)[] = new Array(8).fill(null); + for (const p of pieces) { + if (p.color !== "white") continue; + if (p.type === "pawn") continue; + const rank = rankOf(p.square); + if (rank !== 0) continue; + row[fileOf(p.square)] = p.type; + } + return row; +} + +function blackBackRank(pieces: ReturnType["pieces"]) { + const row: (PieceType | null)[] = new Array(8).fill(null); + for (const p of pieces) { + if (p.color !== "black") continue; + if (p.type === "pawn") continue; + const rank = rankOf(p.square); + if (rank !== 7) continue; + row[fileOf(p.square)] = p.type; + } + return row; +} + +function checkInvariants(back: readonly (PieceType | null)[]): void { + // 8 non-null pieces. + const nonNull = back.filter((p) => p !== null); + expect(nonNull.length).toBe(8); + + // Bishops on opposite colors. Dark square = (file + rank) even; + // rank 0 means color parity follows file parity. + const bishopFiles = back + .map((p, i) => (p === "bishop" ? i : -1)) + .filter((i) => i >= 0); + expect(bishopFiles.length).toBe(2); + const [b1, b2] = bishopFiles as [number, number]; + expect(b1 % 2).not.toBe(b2 % 2); + + // King between rooks. + const kingFile = back.findIndex((p) => p === "king"); + const rookFiles = back + .map((p, i) => (p === "rook" ? i : -1)) + .filter((i) => i >= 0); + expect(kingFile).toBeGreaterThanOrEqual(0); + expect(rookFiles.length).toBe(2); + const [r1, r2] = rookFiles as [number, number]; + expect(Math.min(r1, r2)).toBeLessThan(kingFile); + expect(Math.max(r1, r2)).toBeGreaterThan(kingFile); + + // Exactly 2 knights. + const knightCount = back.filter((p) => p === "knight").length; + expect(knightCount).toBe(2); + + // Exactly 1 queen. + const queenCount = back.filter((p) => p === "queen").length; + expect(queenCount).toBe(1); +} + +describe("buildChess960Layout()", () => { + it("all 960 positions satisfy the invariants", () => { + // Exhaustive check — 960 is small. + for (let id = 0; id < 960; id++) { + const layout = buildChess960Layout(id); + const white = whiteBackRank(layout.pieces); + const black = blackBackRank(layout.pieces); + checkInvariants(white); + checkInvariants(black); + expect(white).toEqual(black); + } + }); + + it("position 518 equals the FIDE starting setup", () => { + const layout = buildChess960Layout(518); + const back = whiteBackRank(layout.pieces); + expect(back).toEqual([ + "rook", + "knight", + "bishop", + "queen", + "king", + "bishop", + "knight", + "rook", + ]); + }); + + it("each layout has exactly 32 pieces (8 back + 8 pawns) × 2", () => { + for (const seed of [0, 1, 518, 959]) { + const layout = buildChess960Layout(seed); + expect(layout.pieces).toHaveLength(32); + const whitePawns = layout.pieces.filter( + (p) => p.type === "pawn" && p.color === "white", + ); + const blackPawns = layout.pieces.filter( + (p) => p.type === "pawn" && p.color === "black", + ); + expect(whitePawns).toHaveLength(8); + expect(blackPawns).toHaveLength(8); + } + }); + + it("wraps out-of-range seeds via modulo", () => { + const inRange = buildChess960Layout(42); + const wrapped = buildChess960Layout(42 + 960); + expect(whiteBackRank(wrapped.pieces)).toEqual( + whiteBackRank(inRange.pieces), + ); + }); +}); diff --git a/packages/chess/src/layouts/chess960.ts b/packages/chess/src/layouts/chess960.ts new file mode 100644 index 0000000..f970566 --- /dev/null +++ b/packages/chess/src/layouts/chess960.ts @@ -0,0 +1,202 @@ +/** + * Layout: Chess960 (Fischer Random Chess). + * + * The back rank is randomized subject to three constraints: + * + * 1. Bishops on opposite-color squares. + * 2. The king sits BETWEEN the two rooks (so castling direction + * is unambiguous). + * 3. Black's back rank mirrors white's. + * + * There are exactly 960 legal starting positions (hence the name). + * Position 518 happens to be standard FIDE. + * + * This file is unusual among layouts: `buildChess960Layout(seed)` is + * a FACTORY that produces a fresh `StartingLayout` each call. The + * value registered in `LAYOUT_REGISTRY` is a placeholder shim — + * selecting it in the LayoutPicker triggers a random seed selection + * and materialization of the real layout on-the-fly. The shim's + * `pieces` field matches a default seed (518 = FIDE) so code that + * doesn't know about the shim treatment still gets a playable setup. + * + * ## Seed → position + * + * We use the standard Scharnagl numbering (0..959). Position 518 is + * the FIDE starting position. The seed is an integer; passing a + * value outside [0, 959] wraps via modulo so bad inputs still + * produce a valid layout. + * + * Reference: https://en.wikipedia.org/wiki/Fischer_random_chess_numbering_scheme + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import type { PieceType } from "../schema.js"; +import { squareOf } from "../coord.js"; +import { fideSideOf } from "./_helpers.js"; + +const CHESS960_COUNT = 960; + +/** + * Build the 8-piece back rank for a Chess960 position numbered `id`. + * + * Uses Reinhard Scharnagl's standard numbering scheme (id 0..959, + * id 518 = FIDE). Returns an 8-element array of piece types at + * files a..h. + * + * The algorithm: + * 1. id mod 4 → light-square bishop file position (among 4). + * 2. (id / 4) mod 4 → dark-square bishop file position. + * 3. (id / 16) mod 6 → queen file position (among 6 remaining). + * 4. (id / 96) mod 10 → knight placement pattern (the Nx number, + * picking 2 of 5 remaining squares). + * 5. Remaining 3 squares get rook-king-rook in that order. + */ +function chess960BackRank(id: number): PieceType[] { + const n = ((id % CHESS960_COUNT) + CHESS960_COUNT) % CHESS960_COUNT; + + const back: (PieceType | null)[] = new Array(8).fill(null); + + // Step 1: bishop on a light square (squares 1, 3, 5, 7 indexed + // from a=0). id mod 4 picks one of these. + const lightBishopFile = (n % 4) * 2 + 1; + back[lightBishopFile] = "bishop"; + + // Step 2: bishop on a dark square (squares 0, 2, 4, 6). + const darkBishopFile = (Math.floor(n / 4) % 4) * 2; + back[darkBishopFile] = "bishop"; + + // Step 3: queen — one of 6 remaining empty files. + const queenIdx = Math.floor(n / 16) % 6; + let queenPlaced = false; + for (let file = 0, emptyCount = 0; file < 8; file++) { + if (back[file] === null) { + if (emptyCount === queenIdx) { + back[file] = "queen"; + queenPlaced = true; + break; + } + emptyCount++; + } + } + if (!queenPlaced) { + // Unreachable under valid input, but keep TS happy. + throw new Error(`Chess960: failed to place queen for id=${id}`); + } + + // Step 4: knights — one of 10 patterns placing N at 2 of 5 + // remaining positions. Table: (knight1_offset, knight2_offset) + // where offsets index into the list of still-empty files. + const knightPatterns: ReadonlyArray = [ + [0, 1], + [0, 2], + [0, 3], + [0, 4], + [1, 2], + [1, 3], + [1, 4], + [2, 3], + [2, 4], + [3, 4], + ]; + const patternIdx = Math.floor(n / 96) % 10; + const pattern = knightPatterns[patternIdx]!; + + // Enumerate empty files left-to-right. + const empties: number[] = []; + for (let file = 0; file < 8; file++) { + if (back[file] === null) empties.push(file); + } + if (empties.length !== 5) { + throw new Error( + `Chess960: expected 5 empty files for knight placement, got ${empties.length}`, + ); + } + back[empties[pattern[0]]!] = "knight"; + back[empties[pattern[1]]!] = "knight"; + + // Step 5: remaining 3 empty files get rook-king-rook in order. + const finalEmpties: number[] = []; + for (let file = 0; file < 8; file++) { + if (back[file] === null) finalEmpties.push(file); + } + if (finalEmpties.length !== 3) { + throw new Error( + `Chess960: expected 3 empty files for R-K-R placement, got ${finalEmpties.length}`, + ); + } + back[finalEmpties[0]!] = "rook"; + back[finalEmpties[1]!] = "king"; + back[finalEmpties[2]!] = "rook"; + + return back as PieceType[]; +} + +/** + * Build a full `StartingLayout` for Chess960 position `id`. + * + * White's back rank follows the Scharnagl formula; black mirrors. + * Pawns go on rank 2 (white) / rank 7 (black) identically to FIDE. + */ +export function buildChess960Layout(id: number): StartingLayout { + const whiteBack = chess960BackRank(id); + const pieces: PiecePlacement[] = []; + + // White back rank + pawns. + for (let file = 0; file < 8; file++) { + pieces.push({ + type: whiteBack[file]!, + color: "white", + square: squareOf(file, 0), + }); + pieces.push({ + type: "pawn", + color: "white", + square: squareOf(file, 1), + }); + } + + // Black back rank (mirror white) + pawns. + for (let file = 0; file < 8; file++) { + pieces.push({ + type: whiteBack[file]!, + color: "black", + square: squareOf(file, 7), + }); + pieces.push({ + type: "pawn", + color: "black", + square: squareOf(file, 6), + }); + } + + const wrappedId = ((id % CHESS960_COUNT) + CHESS960_COUNT) % CHESS960_COUNT; + return { + id: "chess960", + name: `Chess960 (#${wrappedId})`, + description: + "Fischer Random Chess. Back rank randomized with bishops on opposite colors and king between the rooks. Position number shown in the name.", + pieces, + suggestedPresets: [], + source: "premade", + }; +} + +/** + * The registry SHIM for Chess960. `pieces` is the FIDE default + * (position 518) so code that selects Chess960 without further + * handling still gets a playable board. The LayoutPicker detects + * this id and calls `buildChess960Layout(randomSeed)` to produce a + * fresh random position whenever the user re-selects it. + */ +export const CHESS960_SHIM: StartingLayout = { + id: "chess960", + name: "Chess960 (Fischer Random)", + description: + "Fischer Random Chess. Randomized back rank. Selecting this layout generates a new random position on each pick.", + // Default display uses the FIDE back rank (position 518). + pieces: [...fideSideOf("white"), ...fideSideOf("black")], + suggestedPresets: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(CHESS960_SHIM); diff --git a/packages/chess/src/layouts/dunsany.ts b/packages/chess/src/layouts/dunsany.ts new file mode 100644 index 0000000..b36f45a --- /dev/null +++ b/packages/chess/src/layouts/dunsany.ts @@ -0,0 +1,50 @@ +/** + * Layout: Dunsany's Chess. + * + * - White: pawns filling ranks 1-4, with a king on e1 in place of + * a pawn (31 pawns + 1 king). + * - Black: standard FIDE setup. + * + * ## Deviation from canonical Dunsany + * + * Canonical Dunsany has NO white king — black wins ONLY by + * eliminating every white pawn; white wins by checkmating black. + * Our current validator requires at least one king per side. Rather + * than block selection until the `capture-all` preset ships (which + * would let white opt out of royal-piece detection), we add a + * king on e1 and play under FIDE win conditions. + * + * The overall "pawn tide vs army" asymmetry is preserved: white + * has 31 pawns and a single king; black has a full army. + * + * When `capture-all` or a similar no-royalty preset lands, we'll + * ship the canonical version. + * + * Reference: https://en.wikipedia.org/wiki/Dunsany%27s_chess + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { fideSideOf, pawnsOnRanks } from "./_helpers.js"; +import { algebraicToSquare } from "../coord.js"; + +const E1 = algebraicToSquare("e1"); + +const whitePawnsMinusE1: PiecePlacement[] = pawnsOnRanks("white", [ + 0, 1, 2, 3, +]).filter((p) => p.square !== E1); + +export const DUNSANY_LAYOUT: StartingLayout = { + id: "dunsany", + name: "Dunsany's Chess", + description: + "White has 31 pawns and a king versus black's standard setup. An asymmetric 'pawn tide vs army' battle.", + pieces: [ + ...whitePawnsMinusE1, + { type: "king", color: "white", square: E1 }, + ...fideSideOf("black"), + ], + suggestedPresets: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(DUNSANY_LAYOUT); diff --git a/packages/chess/src/layouts/fen.test.ts b/packages/chess/src/layouts/fen.test.ts new file mode 100644 index 0000000..9942684 --- /dev/null +++ b/packages/chess/src/layouts/fen.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; +import { toFen, fromFen } from "./fen.js"; +import { CLASSIC_LAYOUT } from "../starting-position.js"; +import type { PiecePlacement } from "./types.js"; +import { algebraicToSquare } from "../coord.js"; + +describe("toFen()", () => { + it("encodes CLASSIC_LAYOUT as the standard FIDE starting FEN", () => { + expect(toFen(CLASSIC_LAYOUT.pieces)).toBe( + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + ); + }); + + it("encodes an empty board as eight rows of '8'", () => { + expect(toFen([])).toBe("8/8/8/8/8/8/8/8"); + }); + + it("encodes a single white king on e1", () => { + const pieces: PiecePlacement[] = [ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + ]; + expect(toFen(pieces)).toBe("8/8/8/8/8/8/8/4K3"); + }); + + it("encodes a single black queen on d8", () => { + const pieces: PiecePlacement[] = [ + { type: "queen", color: "black", square: algebraicToSquare("d8") }, + ]; + expect(toFen(pieces)).toBe("3q4/8/8/8/8/8/8/8"); + }); + + it("encodes multiple pieces on the same rank with digit runs between", () => { + // White king on a1, white rook on h1 → "R" and "K" with 6 empty + // squares between (in FEN rank-1 order: king first a1, rook last + // h1 → "K6R"). + const pieces: PiecePlacement[] = [ + { type: "king", color: "white", square: algebraicToSquare("a1") }, + { type: "rook", color: "white", square: algebraicToSquare("h1") }, + ]; + expect(toFen(pieces)).toBe("8/8/8/8/8/8/8/K6R"); + }); +}); + +describe("fromFen()", () => { + it("round-trips the FIDE starting FEN back to CLASSIC_LAYOUT's pieces", () => { + const fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"; + const { pieces, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(32); + + // Re-encode; should match input. + expect(toFen(pieces)).toBe(fen); + }); + + it("round-trips an empty board", () => { + const fen = "8/8/8/8/8/8/8/8"; + const { pieces, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(0); + }); + + it("parses partial positions (single king + queen)", () => { + // White king on e1, black queen on d8. + const fen = "3q4/8/8/8/8/8/8/4K3"; + const { pieces, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(2); + + const king = pieces.find((p) => p.type === "king"); + const queen = pieces.find((p) => p.type === "queen"); + expect(king).toEqual({ + type: "king", + color: "white", + square: algebraicToSquare("e1"), + }); + expect(queen).toEqual({ + type: "queen", + color: "black", + square: algebraicToSquare("d8"), + }); + }); + + it("ignores extra fields (side-to-move, castling, etc.) after first space", () => { + const fullFen = + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + const { pieces, errors } = fromFen(fullFen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(32); + }); + + it("reports error for fewer than 8 ranks", () => { + const { errors } = fromFen("rnbqkbnr/pppppppp/8/8/8/PPPPPPPP/RNBQKBNR"); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports error for unknown symbol", () => { + const { errors } = fromFen("8/8/8/8/8/8/8/Z7"); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toMatch(/unknown.*Z/i); + }); + + it("reports error for rank that describes more than 8 squares", () => { + // Rank 1 has 9 pieces. Parser should complain. + const { errors } = fromFen("8/8/8/8/8/8/8/KKKKKKKKK"); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports error for empty input", () => { + const { errors } = fromFen(""); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports error for whitespace-only input", () => { + const { errors } = fromFen(" "); + expect(errors.length).toBeGreaterThan(0); + }); + + it("parses white vs black by letter case", () => { + const { pieces, errors } = fromFen("4k3/8/8/8/8/8/8/4K3"); + expect(errors).toHaveLength(0); + + const whiteKing = pieces.find((p) => p.color === "white"); + const blackKing = pieces.find((p) => p.color === "black"); + expect(whiteKing?.square).toBe(algebraicToSquare("e1")); + expect(blackKing?.square).toBe(algebraicToSquare("e8")); + }); + + it("round-trips a custom piece via {type} bracket notation", () => { + // White cannon on e4. toFen emits `{Cannon}` (capitalized); + // fromFen reads case of first letter to determine color. + const pieces: PiecePlacement[] = [ + { type: "cannon" as never, color: "white", square: algebraicToSquare("e4") }, + ]; + const fen = toFen(pieces); + expect(fen).toContain("{Cannon}"); + + const { pieces: decoded, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(decoded).toHaveLength(1); + expect(decoded[0]?.type).toBe("cannon"); + expect(decoded[0]?.color).toBe("white"); + expect(decoded[0]?.square).toBe(algebraicToSquare("e4")); + }); +}); diff --git a/packages/chess/src/layouts/fen.ts b/packages/chess/src/layouts/fen.ts new file mode 100644 index 0000000..20db909 --- /dev/null +++ b/packages/chess/src/layouts/fen.ts @@ -0,0 +1,271 @@ +/** + * FEN piece-placement encoding / decoding. + * + * This module implements the FIRST field of a Forsyth-Edwards + * Notation record — the piece placement. We intentionally DO NOT + * parse the other fields (side to move, castling rights, en-passant + * target, halfmove clock, fullmove number): + * + * - Side-to-move: a layout is the INITIAL board; the engine always + * starts with white to move. A layout asserting "black to move + * first" is a conceptually-different feature (turn-order + * override) and belongs in a future preset. + * - Castling / en-passant: these depend on `HasMoved` flags which + * the layout carries explicitly via `PiecePlacement.hasMoved`, + * and on game-state tracking which starts fresh. Any FEN + * pasted from an in-progress game should be usable as a starting + * layout — we ignore the castling-rights / en-passant fields so + * the paste "just works" and castling rights are inferred from + * each piece's current position (kings / rooks on their home + * squares retain rights; others lose them via `hasMoved: true`). + * - Halfmove / fullmove clocks: irrelevant for a starting layout; + * the engine resets them to 0 / 1. + * + * ## Rank order + * + * FEN describes ranks from 8 (top) DOWN to 1 (bottom). Our + * square numbering is the opposite: rank 1 = squares 0-7 at the + * start, rank 8 = squares 56-63. We translate carefully. + * + * ## Custom piece types + * + * Standard FEN uses one-letter symbols for FIDE's six piece types: + * + * p=pawn, n=knight, b=bishop, r=rook, q=queen, k=king + * (lowercase = black, uppercase = white) + * + * For non-FIDE piece types registered in `PIECE_TYPE_REGISTRY` (e.g. + * Cannon, Nightrider), we fall back to a deterministic scheme: + * brackets around the piece-type id — `{cannon}`, `{nightrider}`. + * This keeps FENs round-trippable for custom pieces without + * colliding with the standard alphabet. Readers that don't + * understand bracketed forms report an error; writers always emit + * standard letters when a piece-type id matches a FIDE letter. + * + * NOTE: bracket encoding is a Houserules extension, NOT a FEN + * standard. FENs shipped to external tools (lichess, chess.com) + * will not include custom pieces. For a pure-FIDE layout, the + * emitted string is fully standard. + */ +import type { PieceType, PieceColor, Square } from "../schema.js"; +import type { PiecePlacement } from "./types.js"; + +/** Map of FIDE piece types to their FEN letter. */ +const FIDE_LETTER_OF_TYPE: ReadonlyMap = new Map([ + ["pawn", "p"], + ["knight", "n"], + ["bishop", "b"], + ["rook", "r"], + ["queen", "q"], + ["king", "k"], +] as const); + +/** Reverse lookup: FEN letter → piece type. Uppercase = white. */ +const TYPE_OF_FIDE_LETTER: ReadonlyMap = new Map([ + ["p", "pawn"], + ["n", "knight"], + ["b", "bishop"], + ["r", "rook"], + ["q", "queen"], + ["k", "king"], +] as const); + +const BOARD_FILES = 8; +const BOARD_RANKS = 8; +const BOARD_SQUARES = BOARD_FILES * BOARD_RANKS; + +/** + * Encode placements as a FEN piece-placement string. + * + * Example: the FIDE starting position yields + * `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"` — ranks from 8 to + * 1, empty squares run-length encoded as a decimal count. + * + * Pieces not representable in standard FIDE letters fall back to + * `{type-id}` — e.g. a white cannon on e4 emits `{cannon}` in + * uppercase via the helper `toFenSymbol`. This is an internal + * convention; see the file header. + */ +export function toFen(pieces: readonly PiecePlacement[]): string { + // Build a sparse 64-slot board indexed by square. + const board: (PiecePlacement | undefined)[] = new Array(BOARD_SQUARES); + for (const p of pieces) { + if (p.square < 0 || p.square >= BOARD_SQUARES) { + // Out-of-range placements are silently dropped in the encode + // path — the validator catches these; toFen is a pure encoder + // and doesn't throw on bad input. + continue; + } + board[p.square] = p; + } + + const rankStrings: string[] = []; + // FEN ranks: 8 first, 1 last. Rank r occupies squares [r*8, r*8+7]. + for (let rank = BOARD_RANKS - 1; rank >= 0; rank--) { + let s = ""; + let emptyRun = 0; + for (let file = 0; file < BOARD_FILES; file++) { + const sq = rank * BOARD_FILES + file; + const piece = board[sq]; + if (piece === undefined) { + emptyRun++; + } else { + if (emptyRun > 0) { + s += String(emptyRun); + emptyRun = 0; + } + s += toFenSymbol(piece.type, piece.color); + } + } + if (emptyRun > 0) s += String(emptyRun); + rankStrings.push(s); + } + return rankStrings.join("/"); +} + +/** + * Encode a single piece as its FEN symbol. + * + * FIDE types use standard letters (lowercase black, uppercase white). + * Non-FIDE types use `{type-id}` with case flipped for color. + */ +function toFenSymbol(type: PieceType | string, color: PieceColor): string { + const fide = FIDE_LETTER_OF_TYPE.get(type as PieceType); + if (fide !== undefined) { + return color === "white" ? fide.toUpperCase() : fide; + } + // Custom piece — emit bracketed form. Color is encoded by wrapping + // brackets: `{Cannon}` = white, `{cannon}` = black (lowercase + // type-id for black, capitalized first letter for white). + if (color === "white") { + return "{" + capitalize(String(type)) + "}"; + } + return "{" + String(type).toLowerCase() + "}"; +} + +function capitalize(s: string): string { + if (s.length === 0) return s; + return s[0]!.toUpperCase() + s.slice(1); +} + +/** + * Decode a FEN piece-placement string into placements + error list. + * + * Accepts the FIRST space-delimited token of a FEN. Additional tokens + * (side-to-move, castling, en-passant, clocks) are tolerated and + * ignored — callers may paste a full FEN without pre-parsing. + * + * Returns `{ pieces, errors }` rather than throwing. Non-fatal + * problems (unknown bracketed type, extra rank, malformed digit) + * produce an error message but decoding attempts to recover for the + * rest of the board where possible. An empty `errors` array means + * the FEN was fully understood. + * + * Caller typically treats any `errors.length > 0` as "reject this + * input" — partial decodes exist purely to help the editor surface + * specific messages (e.g. "Unknown piece {foo} on e4"). + */ +export function fromFen(fen: string): { + pieces: PiecePlacement[]; + errors: string[]; +} { + const errors: string[] = []; + const trimmed = fen.trim(); + if (trimmed === "") { + return { pieces: [], errors: ["Empty FEN string."] }; + } + + // Take only the first whitespace-delimited token (piece placement). + const placementField = trimmed.split(/\s+/)[0] ?? ""; + const rankTokens = placementField.split("/"); + if (rankTokens.length !== BOARD_RANKS) { + errors.push( + `Expected ${BOARD_RANKS} ranks separated by '/', got ${rankTokens.length}.`, + ); + // Still try — ranks beyond 8 are dropped, missing ranks stay empty. + } + + const pieces: PiecePlacement[] = []; + + // FEN rank order: token 0 = rank 8 (top), token 7 = rank 1 (bottom). + for ( + let tokenIdx = 0; + tokenIdx < Math.min(rankTokens.length, BOARD_RANKS); + tokenIdx++ + ) { + const rankFromTop = tokenIdx; // 0..7 + const rankIdx = BOARD_RANKS - 1 - rankFromTop; // 7..0 → square rank + const token = rankTokens[tokenIdx]!; + + let file = 0; + let i = 0; + while (i < token.length) { + if (file >= BOARD_FILES) { + errors.push( + `Rank ${rankIdx + 1}: more than ${BOARD_FILES} squares described.`, + ); + break; + } + + const ch = token[i]!; + + // Digit = run of empty squares. + if (/[1-8]/.test(ch)) { + file += Number.parseInt(ch, 10); + i++; + continue; + } + + // Bracketed custom piece. + if (ch === "{") { + const close = token.indexOf("}", i + 1); + if (close === -1) { + errors.push( + `Rank ${rankIdx + 1}: unterminated '{' at position ${i}.`, + ); + break; + } + const inner = token.slice(i + 1, close); + const isWhite = inner.length > 0 && /[A-Z]/.test(inner[0]!); + const typeId = inner.toLowerCase(); + pieces.push({ + type: typeId as PieceType, // caller resolves via PIECE_TYPE_REGISTRY + color: isWhite ? "white" : "black", + square: (rankIdx * BOARD_FILES + file) as Square, + }); + file++; + i = close + 1; + continue; + } + + // FIDE letter. + const lower = ch.toLowerCase(); + const type = TYPE_OF_FIDE_LETTER.get(lower); + if (type === undefined) { + errors.push( + `Rank ${rankIdx + 1}: unknown FEN symbol '${ch}' at position ${i}.`, + ); + i++; + continue; + } + const isWhite = ch === ch.toUpperCase(); + pieces.push({ + type, + color: isWhite ? "white" : "black", + square: (rankIdx * BOARD_FILES + file) as Square, + }); + file++; + i++; + } + + if (file !== BOARD_FILES && errors.length === 0) { + // Only surface the "fewer than 8" warning when we haven't already + // complained about over-run — otherwise we'd double-report. + errors.push( + `Rank ${rankIdx + 1}: described ${file} squares, expected ${BOARD_FILES}.`, + ); + } + } + + return { pieces, errors }; +} diff --git a/packages/chess/src/layouts/horde.ts b/packages/chess/src/layouts/horde.ts new file mode 100644 index 0000000..156a77f --- /dev/null +++ b/packages/chess/src/layouts/horde.ts @@ -0,0 +1,66 @@ +/** + * Layout: Horde. + * + * - White: pawns filling ranks 1-4 (squares 0-31), EXCEPT e1 which + * holds the white king. Plus four advanced pawns on b5, c5, f5, + * g5. Total: 31 pawns + 1 king = 32 pieces on white. + * - Black: standard FIDE setup. + * + * ## Deviation from canonical Horde + * + * The canonical lichess Horde variant has NO white king and 36 + * pawns — black wins ONLY by eliminating every white pawn, white + * wins by checkmating black. Supporting "no king on one side" + * requires a per-layout validator relaxation we haven't built yet + * (current minimum-viable validator requires exactly one king per + * color). + * + * To ship Horde now, we give white a king on e1 (replacing that + * pawn). Total white pieces: 31 pawns + 1 king = 32 — same as + * black. Not canonical (lichess has 36 white pawns, no king) but + * playable, and the overall "wall of pawns" feel is preserved. + * + * When the validator learns per-layout exemptions (or the + * `capture-all` preset lands with 0-king support), the canonical + * king-less version will register under this same id and the + * one-king version will move to `horde-classic`. + * + * Reference: https://lichess.org/variant/horde + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { fideSideOf, pawnsOnRanks } from "./_helpers.js"; +import { algebraicToSquare } from "../coord.js"; + +const E1 = algebraicToSquare("e1"); + +// Base pawns (ranks 1-4) with the e1 pawn removed so the king can +// live there — placing two pieces on the same square would fail the +// validator with "duplicate square" and is a modeling bug. +const whitePawnsMinusE1: PiecePlacement[] = pawnsOnRanks("white", [ + 0, 1, 2, 3, +]).filter((p) => p.square !== E1); + +const advancedPawns: PiecePlacement[] = [ + { type: "pawn", color: "white", square: algebraicToSquare("b5") }, + { type: "pawn", color: "white", square: algebraicToSquare("c5") }, + { type: "pawn", color: "white", square: algebraicToSquare("f5") }, + { type: "pawn", color: "white", square: algebraicToSquare("g5") }, +]; + +export const HORDE_LAYOUT: StartingLayout = { + id: "horde", + name: "Horde", + description: + "White has a horde of 35 pawns and a king; black has a full army. (Canonical Horde has no white king; this version plays under standard FIDE win conditions.)", + pieces: [ + ...whitePawnsMinusE1, + { type: "king", color: "white", square: E1 }, + ...advancedPawns, + ...fideSideOf("black"), + ], + suggestedPresets: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(HORDE_LAYOUT); diff --git a/packages/chess/src/layouts/index.ts b/packages/chess/src/layouts/index.ts index ee57af9..6fc312e 100644 --- a/packages/chess/src/layouts/index.ts +++ b/packages/chess/src/layouts/index.ts @@ -15,12 +15,25 @@ // Core layouts — always registered. // The order here determines LayoutPicker dropdown order. import "./classic.js"; +import "./dunsany.js"; +import "./monster.js"; +import "./pawns-only.js"; +import "./horde.js"; +import "./knightmate.js"; +import "./chess960.js"; // Sandbox — last so it's visually grouped separately in the picker. import "./empty.js"; export { LAYOUT_REGISTRY } from "./registry.js"; export { CLASSIC_LAYOUT } from "./classic.js"; export { EMPTY_LAYOUT } from "./empty.js"; +export { DUNSANY_LAYOUT } from "./dunsany.js"; +export { MONSTER_LAYOUT } from "./monster.js"; +export { PAWNS_ONLY_LAYOUT } from "./pawns-only.js"; +export { HORDE_LAYOUT } from "./horde.js"; +export { KNIGHTMATE_LAYOUT } from "./knightmate.js"; +export { CHESS960_SHIM, buildChess960Layout } from "./chess960.js"; +export { toFen, fromFen } from "./fen.js"; export type { PiecePlacement, StartingLayout, diff --git a/packages/chess/src/layouts/knightmate.ts b/packages/chess/src/layouts/knightmate.ts new file mode 100644 index 0000000..b34cd60 --- /dev/null +++ b/packages/chess/src/layouts/knightmate.ts @@ -0,0 +1,82 @@ +/** + * Layout: Knightmate (piece placement only). + * + * Both sides swap kings and knights: + * - Kings live where knights usually do (b1, g1, b8, g8). + * - Knights replace the king on the e-file (e1, e8). + * + * Rest of the setup is FIDE. The canonical Knightmate rule is + * "knight is the royal piece — checkmate the knight, not the king" + * which ships as the `knightmate-rules` preset (see rule-variants + * plan). Without the preset, this layout plays as "weird pieces + * on the back rank" — you have two non-royal kings and one knight + * per side. Not unsafe, just unusual. + * + * Reference: https://greenchess.net/rules.php?v=knightmate + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { squareOf } from "../coord.js"; +import type { PieceColor } from "../schema.js"; + +/** + * Build one side's Knightmate back rank + pawns. + * + * Back-rank piece order (files 0..7): R N B Q N B N R with the + * central e-file piece being a KNIGHT (royal under the preset) and + * the b/g squares holding KINGS (non-royal). + * + * Wait — that reads as two knights + king/king. Let's spell it + * explicitly: r-king-b-q-knight-b-king-r. + */ +function knightmateBackRank(color: PieceColor): PiecePlacement[] { + const rank = color === "white" ? 0 : 7; + return [ + { type: "rook", color, square: squareOf(0, rank) }, + { type: "king", color, square: squareOf(1, rank) }, + { type: "bishop", color, square: squareOf(2, rank) }, + { type: "queen", color, square: squareOf(3, rank) }, + { type: "knight", color, square: squareOf(4, rank) }, + { type: "bishop", color, square: squareOf(5, rank) }, + { type: "king", color, square: squareOf(6, rank) }, + { type: "rook", color, square: squareOf(7, rank) }, + ]; +} + +function knightmatePawnRank(color: PieceColor): PiecePlacement[] { + const rank = color === "white" ? 1 : 6; + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ type: "pawn", color, square: squareOf(file, rank) }); + } + return pieces; +} + +export const KNIGHTMATE_LAYOUT: StartingLayout = { + id: "knightmate", + name: "Knightmate", + description: + "Kings and knights swap places — two non-royal kings flank a central knight on each side. Pairs with 'knightmate-rules' so the knight becomes the mating target.", + pieces: [ + ...knightmateBackRank("white"), + ...knightmatePawnRank("white"), + ...knightmateBackRank("black"), + ...knightmatePawnRank("black"), + ], + suggestedPresets: ["knightmate-rules"], + source: "premade", +}; + +// Validator currently requires exactly 1 king per side; Knightmate +// has 2 kings per side. Like Horde, this layout ships with a known +// validator-incompatibility that we'll relax once the validator +// supports per-layout exemptions or the knightmate-rules preset +// reframes kings as non-royal. +// +// For this iteration, registration goes ahead but creating a real +// GAME with Knightmate selected will fail validation at the server. +// The fix lands in Phase C with server-side validator integration. +LAYOUT_REGISTRY.register(KNIGHTMATE_LAYOUT); + +// Re-export — the local back-rank/pawn-rank helpers aren't part of +// the public API; they're internal to this module. diff --git a/packages/chess/src/layouts/monster.ts b/packages/chess/src/layouts/monster.ts new file mode 100644 index 0000000..f389177 --- /dev/null +++ b/packages/chess/src/layouts/monster.ts @@ -0,0 +1,37 @@ +/** + * Layout: Monster Chess (piece placement only). + * + * - White: king on e1 and four pawns on c2/d2/e2/f2. + * - Black: standard FIDE setup. + * + * The canonical Monster Chess rules include "white moves twice per + * turn" — that rule will ship as the `monster-rules` preset (see + * rule-variants plan). This layout alone plays as "white with a + * king + 4 pawns vs a full black army"; without the twin-move rule + * it's massively unbalanced in black's favor, but functional. + * + * Reference: https://greenchess.net/rules.php?v=monster + */ +import type { StartingLayout } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { fideSideOf } from "./_helpers.js"; +import { algebraicToSquare } from "../coord.js"; + +export const MONSTER_LAYOUT: StartingLayout = { + id: "monster", + name: "Monster Chess", + description: + "White has only a king and four central pawns; black has a full army. Pairs with the 'monster-rules' preset (white moves twice).", + pieces: [ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "pawn", color: "white", square: algebraicToSquare("c2") }, + { type: "pawn", color: "white", square: algebraicToSquare("d2") }, + { type: "pawn", color: "white", square: algebraicToSquare("e2") }, + { type: "pawn", color: "white", square: algebraicToSquare("f2") }, + ...fideSideOf("black"), + ], + suggestedPresets: ["monster-rules"], + source: "premade", +}; + +LAYOUT_REGISTRY.register(MONSTER_LAYOUT); diff --git a/packages/chess/src/layouts/pawns-only.ts b/packages/chess/src/layouts/pawns-only.ts new file mode 100644 index 0000000..98f028d --- /dev/null +++ b/packages/chess/src/layouts/pawns-only.ts @@ -0,0 +1,48 @@ +/** + * Layout: Pawns-Only Chess. + * + * - Each side: king on home square + 8 pawns on home rank. + * - No other pieces. + * + * The canonical greenchess "Pawns-Only" variant has NO kings and a + * "first-to-promote wins" objective. Since our validator requires + * exactly one king per side, we ship the king-ful variant and + * recommend pairing with `first-promotion-wins` (ships in the + * rule-variants plan) — that preset makes the game end on first + * promotion, matching the canonical flavor. + * + * Without the preset, the game plays as "kings + pawns only" — a + * valid and interesting endgame study. + * + * Reference: https://greenchess.net/rules.php?v=pawns-only + */ +import type { StartingLayout } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { algebraicToSquare } from "../coord.js"; +import type { PiecePlacement } from "./types.js"; +import { squareOf } from "../coord.js"; + +function pawnsOnRank(color: "white" | "black", rank: number): PiecePlacement[] { + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ type: "pawn", color, square: squareOf(file, rank) }); + } + return pieces; +} + +export const PAWNS_ONLY_LAYOUT: StartingLayout = { + id: "pawns-only", + name: "Pawns-Only Chess", + description: + "Each side has only a king and eight pawns. Pairs with 'first-promotion-wins' for the canonical 'first pawn to queen' objective.", + pieces: [ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + ...pawnsOnRank("white", 1), + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ...pawnsOnRank("black", 6), + ], + suggestedPresets: ["first-promotion-wins"], + source: "premade", +}; + +LAYOUT_REGISTRY.register(PAWNS_ONLY_LAYOUT); diff --git a/packages/chess/src/layouts/premades.test.ts b/packages/chess/src/layouts/premades.test.ts new file mode 100644 index 0000000..b70af3d --- /dev/null +++ b/packages/chess/src/layouts/premades.test.ts @@ -0,0 +1,79 @@ +/** + * Sanity tests across every shipped premade layout. + * + * These cover structural invariants (piece counts, king presence, + * validator agreement) for every layout in the registry, so adding a + * new premade automatically gets tested when it's registered. + */ +import { describe, it, expect } from "vitest"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { validateLayout } from "./validate.js"; +import "./index.js"; // trigger registration of every premade + +describe("LAYOUT_REGISTRY — premade roster", () => { + it("includes every expected id", () => { + const ids = LAYOUT_REGISTRY.list().map((l) => l.id); + expect(ids).toContain("classic"); + expect(ids).toContain("dunsany"); + expect(ids).toContain("monster"); + expect(ids).toContain("pawns-only"); + expect(ids).toContain("horde"); + expect(ids).toContain("knightmate"); + expect(ids).toContain("chess960"); + expect(ids).toContain("empty"); + }); + + it("every premade has source: 'premade'", () => { + for (const layout of LAYOUT_REGISTRY.list()) { + expect(layout.source).toBe("premade"); + } + }); + + it("every non-empty premade validates with zero errors", () => { + for (const layout of LAYOUT_REGISTRY.list()) { + if (layout.id === "empty") continue; // empty has no kings, rightfully errors + const { errors } = validateLayout(layout); + expect(errors, `${layout.id}: ${errors.join("; ")}`).toHaveLength(0); + } + }); + + it("empty layout errors on missing kings (validator doing its job)", () => { + const empty = LAYOUT_REGISTRY.get("empty"); + expect(empty).toBeDefined(); + const { errors } = validateLayout(empty!); + expect(errors.length).toBeGreaterThan(0); + }); + + it("registering a duplicate id throws", () => { + expect(() => { + LAYOUT_REGISTRY.register({ + id: "classic", + name: "Duplicate", + description: "should throw", + pieces: [], + source: "premade", + }); + }).toThrow(/duplicate/i); + }); +}); + +describe("premade piece counts", () => { + const counts: ReadonlyArray = [ + ["classic", 32], + ["dunsany", 48], // 31 white pawns + 1 king + 16 black pieces + ["monster", 21], // 1 king + 4 pawns + 16 black pieces + ["pawns-only", 18], // 2 kings + 16 pawns + ["horde", 52], // 31 white pawns (ranks 1-4 minus e1) + 1 king + 4 advanced pawns + 16 black pieces + ["knightmate", 32], // standard 32 with swapped back rank + ["chess960", 32], // shim is FIDE by default + ["empty", 0], + ]; + + for (const [id, expectedCount] of counts) { + it(`${id} has ${expectedCount} pieces`, () => { + const layout = LAYOUT_REGISTRY.get(id); + expect(layout).toBeDefined(); + expect(layout!.pieces).toHaveLength(expectedCount); + }); + } +}); diff --git a/packages/chess/src/layouts/validate.test.ts b/packages/chess/src/layouts/validate.test.ts new file mode 100644 index 0000000..506dc81 --- /dev/null +++ b/packages/chess/src/layouts/validate.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { validateLayout } from "./validate.js"; +import type { StartingLayout } from "./types.js"; +import { CLASSIC_LAYOUT } from "../starting-position.js"; +import { buildChess960Layout } from "./chess960.js"; +import { DUNSANY_LAYOUT } from "./dunsany.js"; +import { MONSTER_LAYOUT } from "./monster.js"; +import { PAWNS_ONLY_LAYOUT } from "./pawns-only.js"; +import { HORDE_LAYOUT } from "./horde.js"; +import { KNIGHTMATE_LAYOUT } from "./knightmate.js"; +import { algebraicToSquare } from "../coord.js"; + +function minimal( + pieces: StartingLayout["pieces"], + id = "test", +): StartingLayout { + return { + id, + name: "Test", + description: "test-only", + pieces, + source: "custom", + }; +} + +describe("validateLayout() — errors", () => { + it("CLASSIC_LAYOUT validates with zero errors and zero warnings", () => { + const result = validateLayout(CLASSIC_LAYOUT); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("reports error when no white king is present", () => { + const layout = minimal([ + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /no white king/i.test(e))).toBe(true); + }); + + it("reports error when no black king is present", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /no black king/i.test(e))).toBe(true); + }); + + it("reports error when both kings are missing", () => { + const layout = minimal([ + { type: "rook", color: "white", square: 0 }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /no white king/i.test(e))).toBe(true); + expect(errors.some((e) => /no black king/i.test(e))).toBe(true); + }); + + it("reports error when two pieces occupy the same square", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + { type: "rook", color: "white", square: algebraicToSquare("a1") }, + { type: "rook", color: "white", square: algebraicToSquare("a1") }, // duplicate + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /duplicate/i.test(e))).toBe(true); + }); + + it("reports error for square index < 0", () => { + const layout = minimal([ + { type: "king", color: "white", square: -1 }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /out-of-range/i.test(e))).toBe(true); + }); + + it("reports error for square index >= 64", () => { + const layout = minimal([ + { type: "king", color: "white", square: 64 }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /out-of-range/i.test(e))).toBe(true); + }); + + it("allows multiple kings per side (Knightmate)", () => { + // 2 white kings + 1 black king — should be OK (Knightmate-ish). + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("b1") }, + { type: "king", color: "white", square: algebraicToSquare("g1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors).toHaveLength(0); + }); +}); + +describe("validateLayout() — warnings", () => { + it("warns when a white pawn sits on rank 1 (cannot move)", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + { type: "pawn", color: "white", square: algebraicToSquare("a1") }, + ]); + const { warnings } = validateLayout(layout); + expect(warnings.some((w) => /rank 1/i.test(w))).toBe(true); + }); + + it("warns when a white pawn sits on rank 8 (promotion rank)", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + { type: "pawn", color: "white", square: algebraicToSquare("a8") }, + ]); + const { warnings } = validateLayout(layout); + expect(warnings.some((w) => /promotion/i.test(w))).toBe(true); + }); + + it("warns when material count exceeds 32 per color", () => { + // 33 white pieces (king + 32 extra on unique squares). + const pieces: StartingLayout["pieces"][number][] = [ + { type: "king", color: "white", square: 0 }, + { type: "king", color: "black", square: 63 }, + ]; + for (let sq = 1; sq <= 32; sq++) { + pieces.push({ type: "pawn", color: "white", square: sq }); + } + const { warnings } = validateLayout(minimal(pieces)); + expect(warnings.some((w) => /full FIDE army/i.test(w))).toBe(true); + }); +}); + +describe("validateLayout() — every shipped premade passes", () => { + it("Dunsany passes with no errors", () => { + expect(validateLayout(DUNSANY_LAYOUT).errors).toHaveLength(0); + }); + + it("Monster passes with no errors", () => { + expect(validateLayout(MONSTER_LAYOUT).errors).toHaveLength(0); + }); + + it("Pawns-Only passes with no errors", () => { + expect(validateLayout(PAWNS_ONLY_LAYOUT).errors).toHaveLength(0); + }); + + it("Horde passes with no errors", () => { + expect(validateLayout(HORDE_LAYOUT).errors).toHaveLength(0); + }); + + it("Knightmate passes with no errors", () => { + // Knightmate has 2 kings per side; validator's relaxed rule + // (at least 1 per side) admits this. + expect(validateLayout(KNIGHTMATE_LAYOUT).errors).toHaveLength(0); + }); + + it("Chess960 passes for seed 518 (FIDE) and 20 random seeds", () => { + const seeds = [518]; + for (let i = 0; i < 20; i++) seeds.push(Math.floor(Math.random() * 960)); + for (const seed of seeds) { + const layout = buildChess960Layout(seed); + const { errors } = validateLayout(layout); + expect(errors, `seed ${seed}: ${errors.join("; ")}`).toHaveLength(0); + } + }); +}); diff --git a/packages/chess/src/layouts/validate.ts b/packages/chess/src/layouts/validate.ts new file mode 100644 index 0000000..7bcfff6 --- /dev/null +++ b/packages/chess/src/layouts/validate.ts @@ -0,0 +1,132 @@ +/** + * Layout validation. + * + * Errors BLOCK activation (server rejects the layout; editor hides + * the "Use This Layout" CTA). Warnings are DISPLAYED but don't + * block — the user made a deliberate choice to put a pawn on rank 8 + * and we don't override that. + * + * ## Error rules + * + * 1. Every square index must be in [0, 63]. + * 2. No two pieces on the same square. + * 3. At least one king of each color on the board. + * (Relaxed from "exactly one" to support Knightmate-style + * layouts that place multiple kings by design. Variants that + * want "no kings at all" — Capture-all, Suicide — will ship as + * presets that opt out of royal-piece requirements.) + * + * ## Warning rules + * + * - Pawn on rank 1 (white) — can't move, probably not intended. + * - Pawn on rank 8 (white) or rank 1 (black) — already on + * promotion rank, normally impossible to arrive at. + * - Total piece count per color > 32 (more than a full FIDE army; + * possible but unusual). + * + * ## Non-goals + * + * We don't validate position legality beyond placement: a king in + * check against the opponent-to-move is allowed (the engine will + * figure it out on the first move). We don't enforce bishop-color + * balance, pawn-file counts, or any strategic sanity. + */ +import type { StartingLayout, LayoutValidationResult } from "./types.js"; +import { fileOf, rankOf } from "../coord.js"; + +const BOARD_SQUARES = 64; + +export function validateLayout(layout: StartingLayout): LayoutValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const seenSquares = new Set(); + let whiteKings = 0; + let blackKings = 0; + let whitePieces = 0; + let blackPieces = 0; + + for (const p of layout.pieces) { + // Square bounds. + if ( + !Number.isInteger(p.square) || + p.square < 0 || + p.square >= BOARD_SQUARES + ) { + errors.push( + `Piece at out-of-range square ${p.square} (must be 0..63).`, + ); + continue; // skip further checks on this piece + } + + // Duplicate square. + if (seenSquares.has(p.square)) { + errors.push( + `Duplicate square ${p.square}: two pieces cannot share a square.`, + ); + continue; + } + seenSquares.add(p.square); + + // Color tallies. + if (p.color === "white") whitePieces++; + else blackPieces++; + + // King tallies. + if (p.type === "king") { + if (p.color === "white") whiteKings++; + else blackKings++; + } + + // Pawn-placement warnings. + if (p.type === "pawn") { + const rank = rankOf(p.square); + if (p.color === "white" && rank === 0) { + warnings.push( + `White pawn on rank 1 (square ${squareLabel(p.square)}): pawns cannot move backward.`, + ); + } + if (p.color === "white" && rank === 7) { + warnings.push( + `White pawn on rank 8 (square ${squareLabel(p.square)}): already on promotion rank.`, + ); + } + if (p.color === "black" && rank === 7) { + warnings.push( + `Black pawn on rank 8 (square ${squareLabel(p.square)}): pawns cannot move backward.`, + ); + } + if (p.color === "black" && rank === 0) { + warnings.push( + `Black pawn on rank 1 (square ${squareLabel(p.square)}): already on promotion rank.`, + ); + } + } + } + + // King-count checks. + if (whiteKings === 0) { + errors.push("No white king on the board."); + } + if (blackKings === 0) { + errors.push("No black king on the board."); + } + + // Heavy-material warnings. + if (whitePieces > 32) { + warnings.push(`${whitePieces} white pieces (more than a full FIDE army).`); + } + if (blackPieces > 32) { + warnings.push(`${blackPieces} black pieces (more than a full FIDE army).`); + } + + return { errors, warnings }; +} + +/** Human-readable square label for error messages. a1 = 0, h8 = 63. */ +function squareLabel(square: number): string { + const file = fileOf(square); + const rank = rankOf(square); + const fileChar = String.fromCharCode("a".charCodeAt(0) + file); + return `${fileChar}${rank + 1}`; +}