feat(chess): add bishop/rook/queen sliding rules (P2.11)

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

View file

@ -0,0 +1,220 @@
import { describe, it, expect } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import {
getLegalBishopMoves,
getLegalRookMoves,
getLegalQueenMoves,
} from "./sliding.js";
import type { PieceColor, PieceType, Square } from "../schema.js";
function setupSession(): Session {
return new Session({ autoFire: false });
}
function insertPiece(
session: Session,
id: number,
type: PieceType,
color: PieceColor,
square: Square,
): EntityId {
const eid = id as EntityId;
session.insert(eid, "PieceType", type);
session.insert(eid, "Color", color);
session.insert(eid, "Position", square);
return eid;
}
// ─── Rook ────────────────────────────────────────────────────────────────────
describe("getLegalRookMoves", () => {
it("rook at a1 on empty board produces 14 moves (7 right + 7 up)", () => {
const session = setupSession();
const rook = insertPiece(session, 1, "rook", "white", 0); // a1
const moves = getLegalRookMoves(session, rook);
expect(moves).toHaveLength(14);
expect(moves.every(m => !m.isCapture)).toBe(true);
// along rank 1: b1..h1
expect(moves.some(m => m.to === 1)).toBe(true);
expect(moves.some(m => m.to === 7)).toBe(true);
// along a-file: a2..a8
expect(moves.some(m => m.to === 8)).toBe(true);
expect(moves.some(m => m.to === 56)).toBe(true);
});
it("rook at e4 (28) on empty board produces 14 moves", () => {
const session = setupSession();
const rook = insertPiece(session, 1, "rook", "white", 28);
const moves = getLegalRookMoves(session, rook);
expect(moves).toHaveLength(14);
});
it("rook stops before an allied piece (does not include it)", () => {
const session = setupSession();
const rook = insertPiece(session, 1, "rook", "white", 0); // a1
insertPiece(session, 2, "pawn", "white", 4); // e1 ally blocker
const rightMoves = getLegalRookMoves(session, rook)
.filter(m => m.to >= 1 && m.to <= 7);
expect(rightMoves.map(m => m.to)).toEqual([1, 2, 3]); // b1, c1, d1
expect(rightMoves.every(m => !m.isCapture)).toBe(true);
});
it("rook captures an enemy piece and then stops", () => {
const session = setupSession();
const rook = insertPiece(session, 1, "rook", "white", 0); // a1
insertPiece(session, 2, "pawn", "black", 4); // e1 enemy
const rightMoves = getLegalRookMoves(session, rook)
.filter(m => m.to >= 1 && m.to <= 7);
expect(rightMoves.map(m => m.to)).toEqual([1, 2, 3, 4]);
expect(rightMoves.find(m => m.to === 4)?.isCapture).toBe(true);
expect(rightMoves.filter(m => m.to !== 4).every(m => !m.isCapture)).toBe(true);
// f1..h1 unreachable
expect(rightMoves.some(m => m.to === 5)).toBe(false);
expect(rightMoves.some(m => m.to === 6)).toBe(false);
expect(rightMoves.some(m => m.to === 7)).toBe(false);
});
it("rook boxed in by four allies produces zero moves", () => {
const session = setupSession();
const rook = insertPiece(session, 1, "rook", "white", 28); // e4
insertPiece(session, 2, "pawn", "white", 29); // f4 right
insertPiece(session, 3, "pawn", "white", 27); // d4 left
insertPiece(session, 4, "pawn", "white", 36); // e5 up
insertPiece(session, 5, "pawn", "white", 20); // e3 down
expect(getLegalRookMoves(session, rook)).toHaveLength(0);
});
it("rook boxed in by four enemies produces four captures only", () => {
const session = setupSession();
const rook = insertPiece(session, 1, "rook", "white", 28); // e4
insertPiece(session, 2, "pawn", "black", 29); // f4
insertPiece(session, 3, "pawn", "black", 27); // d4
insertPiece(session, 4, "pawn", "black", 36); // e5
insertPiece(session, 5, "pawn", "black", 20); // e3
const moves = getLegalRookMoves(session, rook);
expect(moves).toHaveLength(4);
expect(moves.every(m => m.isCapture)).toBe(true);
expect(moves.map(m => m.to).sort((a, b) => a - b)).toEqual([20, 27, 29, 36]);
});
});
// ─── Bishop ──────────────────────────────────────────────────────────────────
describe("getLegalBishopMoves", () => {
it("bishop at a1 on empty board has exactly 7 moves along the a1-h8 diagonal", () => {
const session = setupSession();
const bishop = insertPiece(session, 1, "bishop", "white", 0);
const moves = getLegalBishopMoves(session, bishop);
expect(moves).toHaveLength(7);
expect(moves.map(m => m.to).sort((a, b) => a - b))
.toEqual([9, 18, 27, 36, 45, 54, 63]);
expect(moves.every(m => !m.isCapture)).toBe(true);
});
it("bishop at e4 (28) on empty board has 13 moves across four diagonals", () => {
const session = setupSession();
const bishop = insertPiece(session, 1, "bishop", "white", 28);
const moves = getLegalBishopMoves(session, bishop);
// up-right: f5,g6,h7 = 3; down-right: f3,g2,h1 = 3
// up-left: d5,c6,b7,a8 = 4; down-left: d3,c2,b1 = 3
expect(moves).toHaveLength(13);
});
it("bishop stops before an allied piece on the diagonal", () => {
const session = setupSession();
const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1
insertPiece(session, 2, "pawn", "white", 18); // c3 ally
const moves = getLegalBishopMoves(session, bishop);
expect(moves.map(m => m.to)).toContain(9); // b2 reachable
expect(moves.map(m => m.to)).not.toContain(18); // c3 ally blocks
expect(moves.map(m => m.to)).not.toContain(27); // d4 beyond
expect(moves.map(m => m.to)).not.toContain(36); // e5 beyond
});
it("bishop captures an enemy on the diagonal and then stops", () => {
const session = setupSession();
const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1
insertPiece(session, 2, "pawn", "black", 18); // c3 enemy
const moves = getLegalBishopMoves(session, bishop);
expect(moves.map(m => m.to)).toContain(9); // b2
expect(moves.map(m => m.to)).toContain(18); // c3 captured
expect(moves.find(m => m.to === 18)?.isCapture).toBe(true);
expect(moves.map(m => m.to)).not.toContain(27); // d4 unreachable
});
});
// ─── Queen ───────────────────────────────────────────────────────────────────
describe("getLegalQueenMoves", () => {
it("queen at e4 (28) on empty board has 27 moves", () => {
const session = setupSession();
const queen = insertPiece(session, 1, "queen", "white", 28);
const moves = getLegalQueenMoves(session, queen);
expect(moves).toHaveLength(27);
expect(moves.every(m => !m.isCapture)).toBe(true);
});
it("queen at a1 on empty board has 21 moves (14 rook + 7 bishop)", () => {
const session = setupSession();
const queen = insertPiece(session, 1, "queen", "white", 0);
const moves = getLegalQueenMoves(session, queen);
expect(moves).toHaveLength(21);
});
it("queen move set equals union of rook and bishop moves from the same square", () => {
const session = setupSession();
const queen = insertPiece(session, 1, "queen", "white", 28);
const session2 = setupSession();
const rook = insertPiece(session2, 1, "rook", "white", 28);
const session3 = setupSession();
const bishop = insertPiece(session3, 1, "bishop", "white", 28);
const qTos = getLegalQueenMoves(session, queen).map(m => m.to).sort((a, b) => a - b);
const rTos = getLegalRookMoves(session2, rook).map(m => m.to);
const bTos = getLegalBishopMoves(session3, bishop).map(m => m.to);
const combined = [...rTos, ...bTos].sort((a, b) => a - b);
expect(qTos).toEqual(combined);
});
it("queen captures enemy and stops along a diagonal ray", () => {
const session = setupSession();
const queen = insertPiece(session, 1, "queen", "white", 0); // a1
insertPiece(session, 2, "pawn", "black", 18); // c3 enemy on diagonal
insertPiece(session, 3, "pawn", "black", 4); // e1 enemy on rank
const moves = getLegalQueenMoves(session, queen);
// Diagonal: b2 ok, c3 capture, no d4
expect(moves.find(m => m.to === 9)?.isCapture).toBe(false);
expect(moves.find(m => m.to === 18)?.isCapture).toBe(true);
expect(moves.some(m => m.to === 27)).toBe(false);
// Rank: b1, c1, d1 ok, e1 capture, no f1
expect(moves.find(m => m.to === 4)?.isCapture).toBe(true);
expect(moves.some(m => m.to === 5)).toBe(false);
});
});
// ─── Degenerate inputs ───────────────────────────────────────────────────────
describe("sliding rules — degenerate inputs", () => {
it("returns [] for an unknown piece id (rook)", () => {
const session = setupSession();
expect(getLegalRookMoves(session, 999 as EntityId)).toEqual([]);
});
it("returns [] for an unknown piece id (bishop)", () => {
const session = setupSession();
expect(getLegalBishopMoves(session, 999 as EntityId)).toEqual([]);
});
it("returns [] for an unknown piece id (queen)", () => {
const session = setupSession();
expect(getLegalQueenMoves(session, 999 as EntityId)).toEqual([]);
});
it("returns [] when piece has Position but no Color", () => {
const session = setupSession();
const eid = 1 as EntityId;
session.insert(eid, "Position", 28);
expect(getLegalRookMoves(session, eid)).toEqual([]);
expect(getLegalBishopMoves(session, eid)).toEqual([]);
expect(getLegalQueenMoves(session, eid)).toEqual([]);
});
});

View file

@ -0,0 +1,92 @@
/**
* Sliding piece rules: bishop, rook, queen (P2.11).
*
* Each sliding piece walks along a set of rays emitted by the
* corresponding candidate helper in primitives.ts. For every ray we
* iterate squares from the piece outward and apply blocker logic:
*
* 1. Empty square → legal non-capture move, keep walking
* 2. Ally-occupied → stop immediately (square NOT included)
* 3. Enemy-occupied → legal capture, then stop
*
* Intentionally NOT handled here (see later phases):
* - check filtering → P2.18
* - turn order → P2.13
*/
import type { Session, EntityId } from "@paratype/rete";
import {
rookCandidates,
bishopCandidates,
queenCandidates,
} from "./primitives.js";
import type { LegalMove } from "./types.js";
import {
getPiecePosition,
getPieceColor,
isAllyAt,
isEnemyAt,
} from "./board-queries.js";
import type { PieceColor, Square } from "../schema.js";
/**
* Walk each ray in `rays` from the piece outward, emitting legal moves
* until a blocker is hit.
*/
function getSlidingMoves(
session: Session,
pieceId: EntityId,
from: Square,
color: PieceColor,
rays: Square[][],
): LegalMove[] {
const moves: LegalMove[] = [];
for (const ray of rays) {
for (const to of ray) {
if (isAllyAt(session, to, color)) {
// Own piece blocks — do not include this square, stop the ray.
break;
}
if (isEnemyAt(session, to, color)) {
// Enemy piece — legal capture, then stop.
moves.push({ pieceId, from, to, isCapture: true });
break;
}
// Empty square — legal non-capture move, keep walking.
moves.push({ pieceId, from, to, isCapture: false });
}
}
return moves;
}
/** Get all legal bishop moves for the given bishop entity. */
export function getLegalBishopMoves(
session: Session,
pieceId: EntityId,
): LegalMove[] {
const from = getPiecePosition(session, pieceId);
const color = getPieceColor(session, pieceId);
if (from === null || color === null) return [];
return getSlidingMoves(session, pieceId, from, color, bishopCandidates(from));
}
/** Get all legal rook moves for the given rook entity. */
export function getLegalRookMoves(
session: Session,
pieceId: EntityId,
): LegalMove[] {
const from = getPiecePosition(session, pieceId);
const color = getPieceColor(session, pieceId);
if (from === null || color === null) return [];
return getSlidingMoves(session, pieceId, from, color, rookCandidates(from));
}
/** Get all legal queen moves for the given queen entity. */
export function getLegalQueenMoves(
session: Session,
pieceId: EntityId,
): LegalMove[] {
const from = getPiecePosition(session, pieceId);
const color = getPieceColor(session, pieceId);
if (from === null || color === null) return [];
return getSlidingMoves(session, pieceId, from, color, queenCandidates(from));
}