diff --git a/packages/chess/src/coord.test.ts b/packages/chess/src/coord.test.ts new file mode 100644 index 0000000..abb211f --- /dev/null +++ b/packages/chess/src/coord.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + fileOf, rankOf, squareOf, isOnBoard, isValidSquare, squareColor, + squareToAlgebraic, algebraicToSquare, slidingSquares, knightSquares, kingSquares, +} from "./coord.js"; + +describe("fileOf / rankOf / squareOf", () => { + it("a1=0: file=0, rank=0", () => { + expect(fileOf(0)).toBe(0); + expect(rankOf(0)).toBe(0); + }); + it("h1=7: file=7, rank=0", () => { + expect(fileOf(7)).toBe(7); + expect(rankOf(7)).toBe(0); + }); + it("a2=8: file=0, rank=1", () => { + expect(fileOf(8)).toBe(0); + expect(rankOf(8)).toBe(1); + }); + it("e4=28: file=4, rank=3", () => { + expect(fileOf(28)).toBe(4); + expect(rankOf(28)).toBe(3); + }); + it("h8=63: file=7, rank=7", () => { + expect(fileOf(63)).toBe(7); + expect(rankOf(63)).toBe(7); + }); + it("squareOf round-trips", () => { + expect(squareOf(0, 0)).toBe(0); + expect(squareOf(4, 3)).toBe(28); + expect(squareOf(7, 7)).toBe(63); + }); +}); + +describe("isOnBoard / isValidSquare", () => { + it("(0,0) is on board", () => { expect(isOnBoard(0, 0)).toBe(true); }); + it("(7,7) is on board", () => { expect(isOnBoard(7, 7)).toBe(true); }); + it("(-1,0) is off board", () => { expect(isOnBoard(-1, 0)).toBe(false); }); + it("(8,0) is off board", () => { expect(isOnBoard(8, 0)).toBe(false); }); + it("square 0 valid", () => { expect(isValidSquare(0)).toBe(true); }); + it("square 63 valid", () => { expect(isValidSquare(63)).toBe(true); }); + it("square -1 invalid", () => { expect(isValidSquare(-1)).toBe(false); }); + it("square 64 invalid", () => { expect(isValidSquare(64)).toBe(false); }); +}); + +describe("squareColor", () => { + it("a1 (0) is dark (0+0=0 even)", () => { expect(squareColor(0)).toBe("dark"); }); + it("b1 (1) is light (1+0=1 odd)", () => { expect(squareColor(1)).toBe("light"); }); + it("h8 (63) is dark (7+7=14 even)", () => { + expect(squareColor(63)).toBe("dark"); + }); +}); + +describe("squareToAlgebraic / algebraicToSquare", () => { + it("0 → 'a1'", () => { expect(squareToAlgebraic(0)).toBe("a1"); }); + it("28 → 'e4'", () => { expect(squareToAlgebraic(28)).toBe("e4"); }); + it("63 → 'h8'", () => { expect(squareToAlgebraic(63)).toBe("h8"); }); + it("'e4' → 28", () => { expect(algebraicToSquare("e4")).toBe(28); }); + it("'a1' → 0", () => { expect(algebraicToSquare("a1")).toBe(0); }); + it("'h8' → 63", () => { expect(algebraicToSquare("h8")).toBe(63); }); + it("invalid → -1", () => { expect(algebraicToSquare("z9")).toBe(-1); }); +}); + +describe("slidingSquares", () => { + it("rook from a1 going right: b1,c1,...,h1", () => { + const sq = slidingSquares(0, 1, 0); + expect(sq).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + it("bishop from h8 going down-left: g7,f6,e5,d4,c3,b2,a1", () => { + const sq = slidingSquares(63, -1, -1); + expect(sq).toHaveLength(7); + expect(sq[0]).toBe(54); // g7 + }); + it("stops at board edge immediately if no room", () => { + const sq = slidingSquares(7, 1, 0); // rook at h1 going right + expect(sq).toEqual([]); + }); +}); + +describe("knightSquares", () => { + it("knight at e4 (28) has 8 moves", () => { + expect(knightSquares(28)).toHaveLength(8); + }); + it("knight at a1 (0) has 2 moves only", () => { + expect(knightSquares(0)).toHaveLength(2); + }); + it("knight at h8 (63) has 2 moves only", () => { + expect(knightSquares(63)).toHaveLength(2); + }); +}); + +describe("kingSquares", () => { + it("king at e4 (28) has 8 moves", () => { + expect(kingSquares(28)).toHaveLength(8); + }); + it("king at a1 (0) has 3 moves only", () => { + expect(kingSquares(0)).toHaveLength(3); + }); + it("king at h8 (63) has 3 moves only", () => { + expect(kingSquares(63)).toHaveLength(3); + }); +}); diff --git a/packages/chess/src/coord.ts b/packages/chess/src/coord.ts new file mode 100644 index 0000000..98596c9 --- /dev/null +++ b/packages/chess/src/coord.ts @@ -0,0 +1,126 @@ +/** + * Board coordinate helpers for @paratype/chess. + * Square encoding: square = rank * 8 + file + * file: 0=a, 1=b, 2=c, 3=d, 4=e, 5=f, 6=g, 7=h + * rank: 0=rank1, 1=rank2, ..., 7=rank8 + * Examples: a1=0, h1=7, e4=28, h8=63 + */ +import type { Square } from "./schema.js"; + +/** Extract file (0=a..7=h) from a square. */ +export function fileOf(square: Square): number { + return square % 8; +} + +/** Extract rank (0=rank1..7=rank8) from a square. */ +export function rankOf(square: Square): number { + return Math.floor(square / 8); +} + +/** Construct a square from file and rank. Does NOT validate range. */ +export function squareOf(file: number, rank: number): Square { + return (rank * 8 + file) as Square; +} + +/** Is the (file, rank) pair on the board (0..7 for both)? */ +export function isOnBoard(file: number, rank: number): boolean { + return file >= 0 && file <= 7 && rank >= 0 && rank <= 7; +} + +/** Is a square in the valid 0..63 range? */ +export function isValidSquare(square: number): boolean { + return square >= 0 && square <= 63; +} + +/** + * Light/dark square color. Returns "light" or "dark". + * A square is light if (file + rank) is odd, dark if even. + */ +export function squareColor(square: Square): "light" | "dark" { + const file = fileOf(square); + const rank = rankOf(square); + return (file + rank) % 2 === 0 ? "dark" : "light"; +} + +/** + * Convert a square to algebraic notation (e.g., 0 → "a1", 28 → "e4"). + * Useful for debugging and PGN parsing. + */ +export function squareToAlgebraic(square: Square): string { + const file = fileOf(square); + const rank = rankOf(square); + return String.fromCharCode(97 + file) + String(rank + 1); +} + +/** + * Parse algebraic notation to square number (e.g., "e4" → 28). + * Returns -1 for invalid input. + */ +export function algebraicToSquare(notation: string): Square { + if (notation.length !== 2) return -1 as Square; + const file = notation.charCodeAt(0) - 97; // 'a'=0 + const rank = parseInt(notation[1]!, 10) - 1; + if (!isOnBoard(file, rank)) return -1 as Square; + return squareOf(file, rank); +} + +/** + * Return all squares a sliding piece can reach in one direction, + * stopping when it hits the board edge (exclusive). + * @param from — starting square + * @param fileDelta — file change per step (-1, 0, or 1) + * @param rankDelta — rank change per step (-1, 0, or 1) + */ +export function slidingSquares( + from: Square, + fileDelta: number, + rankDelta: number, +): Square[] { + const squares: Square[] = []; + let file = fileOf(from) + fileDelta; + let rank = rankOf(from) + rankDelta; + while (isOnBoard(file, rank)) { + squares.push(squareOf(file, rank)); + file += fileDelta; + rank += rankDelta; + } + return squares; +} + +/** + * Knight move offsets: all 8 L-shaped moves. + * Returns valid target squares from a given square. + */ +export function knightSquares(from: Square): Square[] { + const offsets: [number, number][] = [ + [1, 2], [2, 1], [2, -1], [1, -2], + [-1, -2], [-2, -1], [-2, 1], [-1, 2], + ]; + const file = fileOf(from); + const rank = rankOf(from); + const squares: Square[] = []; + for (const [df, dr] of offsets) { + const nf = file + df; + const nr = rank + dr; + if (isOnBoard(nf, nr)) squares.push(squareOf(nf, nr)); + } + return squares; +} + +/** + * King move squares: all 8 adjacent squares (within board bounds). + */ +export function kingSquares(from: Square): Square[] { + const file = fileOf(from); + const rank = rankOf(from); + const squares: Square[] = []; + for (let df = -1; df <= 1; df++) { + for (let dr = -1; dr <= 1; dr++) { + if (df === 0 && dr === 0) continue; + const nf = file + df; + const nr = rank + dr; + if (isOnBoard(nf, nr)) squares.push(squareOf(nf, nr)); + } + } + return squares; +}