feat(chess): add bishop/rook/queen sliding rules (P2.11)
This commit is contained in:
parent
6994d5fe55
commit
1013c40e2a
2 changed files with 312 additions and 0 deletions
220
packages/chess/src/rules/sliding.test.ts
Normal file
220
packages/chess/src/rules/sliding.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
92
packages/chess/src/rules/sliding.ts
Normal file
92
packages/chess/src/rules/sliding.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue