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:
parent
d93bcf6c81
commit
f7099d754d
14 changed files with 1487 additions and 0 deletions
73
packages/chess/src/layouts/_helpers.ts
Normal file
73
packages/chess/src/layouts/_helpers.ts
Normal 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;
|
||||
}
|
||||
124
packages/chess/src/layouts/chess960.test.ts
Normal file
124
packages/chess/src/layouts/chess960.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
202
packages/chess/src/layouts/chess960.ts
Normal file
202
packages/chess/src/layouts/chess960.ts
Normal 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);
|
||||
50
packages/chess/src/layouts/dunsany.ts
Normal file
50
packages/chess/src/layouts/dunsany.ts
Normal 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);
|
||||
144
packages/chess/src/layouts/fen.test.ts
Normal file
144
packages/chess/src/layouts/fen.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
271
packages/chess/src/layouts/fen.ts
Normal file
271
packages/chess/src/layouts/fen.ts
Normal 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 };
|
||||
}
|
||||
66
packages/chess/src/layouts/horde.ts
Normal file
66
packages/chess/src/layouts/horde.ts
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
82
packages/chess/src/layouts/knightmate.ts
Normal file
82
packages/chess/src/layouts/knightmate.ts
Normal 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.
|
||||
37
packages/chess/src/layouts/monster.ts
Normal file
37
packages/chess/src/layouts/monster.ts
Normal 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);
|
||||
48
packages/chess/src/layouts/pawns-only.ts
Normal file
48
packages/chess/src/layouts/pawns-only.ts
Normal 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);
|
||||
79
packages/chess/src/layouts/premades.test.ts
Normal file
79
packages/chess/src/layouts/premades.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
166
packages/chess/src/layouts/validate.test.ts
Normal file
166
packages/chess/src/layouts/validate.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
132
packages/chess/src/layouts/validate.ts
Normal file
132
packages/chess/src/layouts/validate.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue