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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 19:52:22 -06:00
commit f7099d754d
No known key found for this signature in database
14 changed files with 1487 additions and 0 deletions

View file

@ -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;
}

View file

@ -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<typeof buildChess960Layout>["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<typeof buildChess960Layout>["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),
);
});
});

View file

@ -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<readonly [number, number]> = [
[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);

View file

@ -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);

View file

@ -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"));
});
});

View file

@ -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<PieceType, string> = 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<string, PieceType> = 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 };
}

View file

@ -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);

View file

@ -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,

View file

@ -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.

View file

@ -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);

View file

@ -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);

View file

@ -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<readonly [string, number]> = [
["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);
});
}
});

View file

@ -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);
}
});
});

View file

@ -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<number>();
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}`;
}