diff --git a/packages/chess/src/rules/knight.test.ts b/packages/chess/src/rules/knight.test.ts new file mode 100644 index 0000000..500f9c1 --- /dev/null +++ b/packages/chess/src/rules/knight.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { getLegalKnightMoves } from "./knight.js"; + +const mkId = (n: number) => n as EntityId; + +function insertPiece( + session: Session, + id: number, + type: string, + color: string, + square: number, +): EntityId { + const eid = mkId(id); + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +describe("getLegalKnightMoves", () => { + it("knight at e4 (28) has 8 moves when board is empty", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + const moves = getLegalKnightMoves(session, knight); + expect(moves).toHaveLength(8); + // All 8 L-shapes from e4: f6=45, g5=38, g3=22, f2=13, d2=11, c3=18, c5=34, d6=43 + const targets = moves.map(m => m.to).sort((a, b) => a - b); + expect(targets).toEqual([11, 13, 18, 22, 34, 38, 43, 45]); + }); + + it("knight at a1 (0) has 2 moves only (board edge)", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 0); + const moves = getLegalKnightMoves(session, knight); + expect(moves).toHaveLength(2); + // From a1: b3=17, c2=10 + const targets = moves.map(m => m.to).sort((a, b) => a - b); + expect(targets).toEqual([10, 17]); + }); + + it("knight ignores intervening pieces (leaps)", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); // e4 + // Block all orthogonally adjacent squares — knight should still leap over them. + insertPiece(session, 2, "pawn", "white", 27); // d4 + insertPiece(session, 3, "pawn", "white", 29); // f4 + insertPiece(session, 4, "pawn", "white", 20); // e3 + insertPiece(session, 5, "pawn", "white", 36); // e5 + const moves = getLegalKnightMoves(session, knight); + // None of the blockers sits on a knight-target square, so all 8 reachable. + expect(moves).toHaveLength(8); + }); + + it("cannot capture ally piece", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); // e4 + insertPiece(session, 2, "rook", "white", 38); // g5 — knight target + const moves = getLegalKnightMoves(session, knight); + expect(moves.find(m => m.to === 38)).toBeUndefined(); + expect(moves).toHaveLength(7); + }); + + it("can capture enemy piece", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); // e4 + insertPiece(session, 2, "rook", "black", 38); // g5 — knight target + const move = getLegalKnightMoves(session, knight).find(m => m.to === 38); + expect(move).toBeDefined(); + expect(move?.isCapture).toBe(true); + }); + + it("quiet moves are marked isCapture=false", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + const moves = getLegalKnightMoves(session, knight); + for (const m of moves) { + expect(m.isCapture).toBe(false); + } + }); + + it("returns empty list when pieceId has no Position/Color", () => { + const session = new Session({ autoFire: false }); + expect(getLegalKnightMoves(session, mkId(999))).toEqual([]); + }); +}); diff --git a/packages/chess/src/rules/knight.ts b/packages/chess/src/rules/knight.ts new file mode 100644 index 0000000..694eb59 --- /dev/null +++ b/packages/chess/src/rules/knight.ts @@ -0,0 +1,28 @@ +/** + * Knight movement rules for @paratype/chess. + * + * Knights leap — they do NOT check for intervening pieces on the path + * between `from` and `to`. Only the target square matters: + * - empty → legal quiet move + * - enemy → legal capture + * - ally → illegal + */ +import type { Session, EntityId } from "@paratype/rete"; +import { knightCandidates } from "./primitives.js"; +import type { LegalMove } from "./types.js"; +import { getPiecePosition, getPieceColor, isAllyAt, isPieceAt } from "./board-queries.js"; + +/** Get all legal knight moves (leaps — no path obstruction check). */ +export function getLegalKnightMoves(session: Session, pieceId: EntityId): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + + const moves: LegalMove[] = []; + for (const to of knightCandidates(from)) { + if (isAllyAt(session, to, color)) continue; + const isCapture = isPieceAt(session, to); + moves.push({ pieceId, from, to, isCapture }); + } + return moves; +}