diff --git a/packages/chess/src/starting-position.test.ts b/packages/chess/src/starting-position.test.ts new file mode 100644 index 0000000..f9e741a --- /dev/null +++ b/packages/chess/src/starting-position.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { generateStartingPosition, STARTING_PIECES_DEF } from "./starting-position.js"; +import { Session } from "@paratype/rete"; +import { GAME_ENTITY } from "./schema.js"; + +describe("generateStartingPosition()", () => { + it("inserts exactly 32 piece entities", () => { + const session = new Session({ autoFire: false }); + const ids = generateStartingPosition(session); + expect(ids).toHaveLength(32); + expect(new Set(ids).size).toBe(32); // all unique + }); + + it("inserts 4 facts per piece (PieceType, Color, Position, HasMoved)", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + const facts = session.allFacts(); + + // Count piece facts (exclude game-level facts on GAME_ENTITY) + const pieceFacts = facts.filter((f) => f.id !== GAME_ENTITY); + // 32 pieces × 4 attrs per piece + expect(pieceFacts).toHaveLength(128); + }); + + it("has exactly 8 white pawns on rank 2 (squares 8-15)", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + const facts = session.allFacts(); + + const whitePawnPositions = facts + .filter((f) => f.attr === "Position" && f.id !== GAME_ENTITY) + .map((f) => { + const colorFact = facts.find( + (c) => c.id === f.id && c.attr === "Color" + ); + const typeFact = facts.find((t) => t.id === f.id && t.attr === "PieceType"); + return { + position: f.value as number, + color: colorFact?.value, + type: typeFact?.value, + }; + }) + .filter((p) => p.color === "white" && p.type === "pawn") + .map((p) => p.position); + + expect(whitePawnPositions).toHaveLength(8); + expect(whitePawnPositions.every((sq) => sq >= 8 && sq <= 15)).toBe(true); + }); + + it("white king is on e1 (square 4)", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + const facts = session.allFacts(); + + const kingEntity = facts.find((f) => { + if (f.attr !== "PieceType" || f.value !== "king" || f.id === GAME_ENTITY) { + return false; + } + const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color"); + return colorFact?.value === "white"; + })?.id; + + expect(kingEntity).toBeDefined(); + const kingPos = facts.find((f) => f.id === kingEntity && f.attr === "Position")?.value; + expect(kingPos).toBe(4); // e1 + }); + + it("game-level Turn is white initially", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + const turnFact = session.get(GAME_ENTITY, "Turn"); + expect(turnFact).toBe("white"); + }); + + it("HasMoved is false for all pieces initially", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + const facts = session.allFacts(); + + const movedFacts = facts.filter( + (f) => f.attr === "HasMoved" && f.id !== GAME_ENTITY + ); + expect(movedFacts).toHaveLength(32); + expect(movedFacts.every((f) => f.value === false)).toBe(true); + }); + + it("black king is on e8 (square 60)", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + const facts = session.allFacts(); + + const kingEntity = facts.find((f) => { + if (f.attr !== "PieceType" || f.value !== "king" || f.id === GAME_ENTITY) { + return false; + } + const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color"); + return colorFact?.value === "black"; + })?.id; + + expect(kingEntity).toBeDefined(); + const kingPos = facts.find((f) => f.id === kingEntity && f.attr === "Position")?.value; + expect(kingPos).toBe(60); // e8 + }); + + it("STARTING_PIECES_DEF has 32 entries", () => { + expect(STARTING_PIECES_DEF).toHaveLength(32); + }); + + it("game-level facts are correctly initialized", () => { + const session = new Session({ autoFire: false }); + generateStartingPosition(session); + + expect(session.get(GAME_ENTITY, "Turn")).toBe("white"); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0); + expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1); + expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(null); + expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active"); + expect(session.get(GAME_ENTITY, "Winner")).toBe(null); + }); +}); diff --git a/packages/chess/src/starting-position.ts b/packages/chess/src/starting-position.ts new file mode 100644 index 0000000..c9ebc5a --- /dev/null +++ b/packages/chess/src/starting-position.ts @@ -0,0 +1,118 @@ +/** + * FIDE starting position generator. + * Inserts 32 piece entities + game-level facts into a Session. + * Per SPEC.md §ID Authority, pieces get incremental EntityIds; game uses GAME_ENTITY (0). + */ +import type { EntityId } from "@paratype/rete"; +import type { Session } from "@paratype/rete"; +import { + GAME_ENTITY, + type PieceType, + type PieceColor, + type Square, +} from "./schema.js"; + +interface PieceDef { + type: PieceType; + color: PieceColor; + square: Square; +} + +const WHITE_BACK_RANK: PieceType[] = [ + "rook", + "knight", + "bishop", + "queen", + "king", + "bishop", + "knight", + "rook", +]; +const BLACK_BACK_RANK: PieceType[] = [ + "rook", + "knight", + "bishop", + "queen", + "king", + "bishop", + "knight", + "rook", +]; + +function buildStartingPieces(): PieceDef[] { + const pieces: PieceDef[] = []; + + // White back rank (rank 1 = squares 0-7) + for (let file = 0; file < 8; file++) { + pieces.push({ + type: WHITE_BACK_RANK[file]!, + color: "white", + square: file, + }); + } + + // White pawns (rank 2 = squares 8-15) + for (let file = 0; file < 8; file++) { + pieces.push({ + type: "pawn", + color: "white", + square: 8 + file, + }); + } + + // Black pawns (rank 7 = squares 48-55) + for (let file = 0; file < 8; file++) { + pieces.push({ + type: "pawn", + color: "black", + square: 48 + file, + }); + } + + // Black back rank (rank 8 = squares 56-63) + for (let file = 0; file < 8; file++) { + pieces.push({ + type: BLACK_BACK_RANK[file]!, + color: "black", + square: 56 + file, + }); + } + + return pieces; +} + +const STARTING_PIECES = buildStartingPieces(); + +/** + * Insert all 32 FIDE starting pieces + game-level facts into a Session. + * Each piece gets 4 facts: PieceType, Color, Position, HasMoved. + * Game level gets: Turn, HalfmoveClock, FullmoveNumber, EnPassantTarget, GameStatus, Winner. + * Returns the array of EntityIds assigned to pieces (in STARTING_PIECES order). + */ +export function generateStartingPosition(session: Session): EntityId[] { + const pieceIds: EntityId[] = []; + + // Insert all 32 pieces with their attributes + for (const piece of STARTING_PIECES) { + const id = session.nextId(); + session.insert(id, "PieceType", piece.type); + session.insert(id, "Color", piece.color); + session.insert(id, "Position", piece.square); + session.insert(id, "HasMoved", false); + pieceIds.push(id); + } + + // Game-level facts + session.insert(GAME_ENTITY, "Turn", "white"); + session.insert(GAME_ENTITY, "HalfmoveClock", 0); + session.insert(GAME_ENTITY, "FullmoveNumber", 1); + session.insert(GAME_ENTITY, "EnPassantTarget", null); + session.insert(GAME_ENTITY, "GameStatus", "active"); + session.insert(GAME_ENTITY, "Winner", null); + + return pieceIds; +} + +/** The canonical FIDE starting piece definitions (for testing and reference). */ +export const STARTING_PIECES_DEF: ReadonlyArray> = + STARTING_PIECES;