feat(chess): add coordinate + color helpers (P2.7)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:47:04 -06:00
commit d4cee82b20
No known key found for this signature in database
2 changed files with 228 additions and 0 deletions

View file

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

126
packages/chess/src/coord.ts Normal file
View file

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