feat(chess): add movement primitive rules (P2.8)

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

View file

@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import {
rookCandidates, bishopCandidates, queenCandidates,
knightCandidates, kingCandidates,
pawnSingleAdvance, pawnDoubleAdvance, pawnCaptureSqares,
} from "./primitives.js";
describe("rookCandidates", () => {
it("rook at a1 (0): right ray has 7 squares, others are bounded", () => {
const rays = rookCandidates(0);
expect(rays).toHaveLength(4);
expect(rays[0]).toHaveLength(7); // right: b1-h1
expect(rays[1]).toHaveLength(0); // left: nothing (a-file)
expect(rays[2]).toHaveLength(7); // up: a2-a8
expect(rays[3]).toHaveLength(0); // down: nothing (rank1)
});
it("rook at e4 (28): all 4 rays non-empty", () => {
const rays = rookCandidates(28);
expect(rays.every(r => r.length > 0)).toBe(true);
});
});
describe("bishopCandidates", () => {
it("bishop at a1 (0): only one diagonal (up-right)", () => {
const rays = bishopCandidates(0);
expect(rays[0]).toHaveLength(7); // up-right diagonal: b2-h8
expect(rays[1]).toHaveLength(0); // down-right: nothing
expect(rays[2]).toHaveLength(0); // up-left: nothing
expect(rays[3]).toHaveLength(0); // down-left: nothing
});
});
describe("queenCandidates", () => {
it("queen at e4 (28): 8 rays total", () => {
const rays = queenCandidates(28);
expect(rays).toHaveLength(8);
});
});
describe("knightCandidates", () => {
it("knight at e4 (28) has 8 moves", () => {
expect(knightCandidates(28)).toHaveLength(8);
});
it("knight at a1 (0) has 2 moves", () => {
expect(knightCandidates(0)).toHaveLength(2);
});
});
describe("kingCandidates", () => {
it("king at e4 (28) has 8 moves", () => {
expect(kingCandidates(28)).toHaveLength(8);
});
it("king at a1 (0) has 3 moves", () => {
expect(kingCandidates(0)).toHaveLength(3);
});
});
describe("pawnSingleAdvance", () => {
it("white pawn at e2 (12) advances to e3 (20)", () => {
expect(pawnSingleAdvance(12, "white")).toBe(20);
});
it("black pawn at e7 (52) advances to e6 (44)", () => {
expect(pawnSingleAdvance(52, "black")).toBe(44);
});
it("white pawn at rank8 returns null", () => {
expect(pawnSingleAdvance(60, "white")).toBeNull();
});
});
describe("pawnDoubleAdvance", () => {
it("white pawn at e2 (12) can double-advance to e4 (28)", () => {
expect(pawnDoubleAdvance(12, "white")).toBe(28);
});
it("white pawn at e3 (20) cannot double-advance", () => {
expect(pawnDoubleAdvance(20, "white")).toBeNull();
});
it("black pawn at e7 (52) can double-advance to e5 (36)", () => {
expect(pawnDoubleAdvance(52, "black")).toBe(36);
});
});
describe("pawnCaptureSquares", () => {
it("white pawn at e4 (28) can capture on d5 (35) and f5 (37)", () => {
const sq = pawnCaptureSqares(28, "white");
expect(sq).toContain(35); // d5
expect(sq).toContain(37); // f5
expect(sq).toHaveLength(2);
});
it("white pawn at a4 (24) can only capture right (b5=33)", () => {
const sq = pawnCaptureSqares(24, "white");
expect(sq).toHaveLength(1);
expect(sq[0]).toBe(33);
});
});

View file

@ -0,0 +1,104 @@
/**
* Movement primitive rules for @paratype/chess.
* Each primitive computes candidate target squares for a piece type.
* These are used by higher-level rule files (pawn.ts, knight.ts, etc.)
*
* NOTE: These are CANDIDATE moves only — they do not check:
* - Whether target squares are occupied (capture/blocking handled in P2.9+)
* - Whether the move would leave the king in check (P2.18)
* - Turn order (P2.13)
*/
import type { PieceColor, Square } from "../schema.js";
import {
slidingSquares,
knightSquares as knightTargets,
kingSquares as kingTargets,
rankOf,
fileOf,
} from "../coord.js";
export interface CandidateMove {
readonly pieceId: number;
readonly from: Square;
readonly to: Square;
}
// ─── Sliding piece helpers ───────────────────────────────────────────────────
/** All candidate squares for a rook (4 orthogonal rays). */
export function rookCandidates(from: Square): Square[][] {
return [
slidingSquares(from, 1, 0), // right
slidingSquares(from, -1, 0), // left
slidingSquares(from, 0, 1), // up
slidingSquares(from, 0, -1), // down
];
}
/** All candidate squares for a bishop (4 diagonal rays). */
export function bishopCandidates(from: Square): Square[][] {
return [
slidingSquares(from, 1, 1), // up-right
slidingSquares(from, 1, -1), // down-right
slidingSquares(from, -1, 1), // up-left
slidingSquares(from, -1, -1), // down-left
];
}
/** All candidate squares for a queen (8 rays). */
export function queenCandidates(from: Square): Square[][] {
return [...rookCandidates(from), ...bishopCandidates(from)];
}
// ─── Leap piece helpers ──────────────────────────────────────────────────────
/** Knight candidate squares (already filters off-board). */
export function knightCandidates(from: Square): Square[] {
return knightTargets(from);
}
/** King candidate squares (already filters off-board). */
export function kingCandidates(from: Square): Square[] {
return kingTargets(from);
}
// ─── Pawn helpers ────────────────────────────────────────────────────────────
/**
* Pawn single-advance target (no capture).
* Returns null if pawn is on the back rank (invalid).
*/
export function pawnSingleAdvance(from: Square, color: PieceColor): Square | null {
const rank = rankOf(from);
const targetRank = color === "white" ? rank + 1 : rank - 1;
if (targetRank < 0 || targetRank > 7) return null;
return (from + (color === "white" ? 8 : -8)) as Square;
}
/**
* Pawn double-advance target (from home rank only).
* White home rank = rank 1 (squares 8-15)
* Black home rank = rank 6 (squares 48-55)
*/
export function pawnDoubleAdvance(from: Square, color: PieceColor): Square | null {
const rank = rankOf(from);
const homeRank = color === "white" ? 1 : 6;
if (rank !== homeRank) return null;
return (from + (color === "white" ? 16 : -16)) as Square;
}
/**
* Pawn diagonal capture squares (1 or 2, within board bounds).
* Pawns capture diagonally forward — this returns the squares, not whether
* an enemy occupies them (capture validation is in P2.9).
*/
export function pawnCaptureSqares(from: Square, color: PieceColor): Square[] {
const rank = rankOf(from);
const targetRank = color === "white" ? rank + 1 : rank - 1;
if (targetRank < 0 || targetRank > 7) return [];
const file = fileOf(from);
const squares: Square[] = [];
if (file > 0) squares.push((from + (color === "white" ? 7 : -9)) as Square); // left diagonal
if (file < 7) squares.push((from + (color === "white" ? 9 : -7)) as Square); // right diagonal
return squares;
}