diff --git a/packages/chess/src/rules/insufficient.test.ts b/packages/chess/src/rules/insufficient.test.ts new file mode 100644 index 0000000..d1ec165 --- /dev/null +++ b/packages/chess/src/rules/insufficient.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for insufficient material draw detection (P2.22). + * + * Square encoding: square = rank*8 + file. a1=0, b1=1, c1=2, e1=4, e8=60. + * squareColor: (file+rank) even → dark, odd → light. + * a1 (0+0=0) → dark + * b1 (1+0=1) → light + * c1 (2+0=2) → dark + */ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { isInsufficientMaterial } from "./insufficient.js"; + +const mkId = (n: number) => n as EntityId; + +function ins( + s: Session, + id: number, + type: string, + color: string, + sq: number, +): EntityId { + const eid = mkId(id); + s.insert(eid, "PieceType", type); + s.insert(eid, "Color", color); + s.insert(eid, "Position", sq); + return eid; +} + +describe("isInsufficientMaterial", () => { + it("K vs K: draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(true); + }); + + it("K + N vs K: draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "knight", "white", 12); + ins(session, 3, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(true); + }); + + it("K vs K + N (black has the minor): draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "king", "black", 60); + ins(session, 3, "knight", "black", 52); + expect(isInsufficientMaterial(session)).toBe(true); + }); + + it("K + B vs K: draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "bishop", "white", 9); // b2 — light (1+1=2 even? → dark actually) + ins(session, 3, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(true); + }); + + it("K + B vs K + B same color (both dark): draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "bishop", "white", 0); // a1 — dark + ins(session, 3, "king", "black", 60); + ins(session, 4, "bishop", "black", 2); // c1 — dark + expect(isInsufficientMaterial(session)).toBe(true); + }); + + it("K + B vs K + B opposite color: NOT draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "bishop", "white", 0); // a1 — dark + ins(session, 3, "king", "black", 60); + ins(session, 4, "bishop", "black", 1); // b1 — light + expect(isInsufficientMaterial(session)).toBe(false); + }); + + it("K + Q vs K: NOT draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "queen", "white", 3); + ins(session, 3, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(false); + }); + + it("K + R vs K: NOT draw", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "rook", "white", 0); + ins(session, 3, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(false); + }); + + it("K + P vs K: NOT draw (pawn can promote)", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "pawn", "white", 12); + ins(session, 3, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(false); + }); + + it("K + N + N vs K: NOT draw (outside plan scope)", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "knight", "white", 12); + ins(session, 3, "knight", "white", 14); + ins(session, 4, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(false); + }); + + it("K + B + N vs K: NOT draw (forced mate exists)", () => { + const session = new Session({ autoFire: false }); + ins(session, 1, "king", "white", 4); + ins(session, 2, "bishop", "white", 9); + ins(session, 3, "knight", "white", 12); + ins(session, 4, "king", "black", 60); + expect(isInsufficientMaterial(session)).toBe(false); + }); +}); diff --git a/packages/chess/src/rules/insufficient.ts b/packages/chess/src/rules/insufficient.ts new file mode 100644 index 0000000..bb1f588 --- /dev/null +++ b/packages/chess/src/rules/insufficient.ts @@ -0,0 +1,88 @@ +/** + * Insufficient material draw detection (P2.22). + * + * FIDE draw by insufficient material — neither side retains a forcing + * checkmate pattern with only these pieces: + * 1. K vs K + * 2. K + B vs K + * 3. K + N vs K + * 4. K + B vs K + B, where both bishops live on the SAME color square + * + * K + N + N vs K is technically a draw under FIDE too, but its rarity + * (and that it can be forced to mate in contrived positions) places it + * outside the scope of this plan. Opposite-color bishop endings are + * NEVER detected as a draw here — they can produce checkmate positions + * and must be resolved by stalemate / 50-move / repetition instead. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceType, PieceColor, Square } from "../schema.js"; +import { squareColor } from "../coord.js"; + +interface PieceInfo { + readonly id: EntityId; + readonly type: PieceType; + readonly color: PieceColor; + readonly square: Square; +} + +/** + * Project all pieces (id > 0 filter skips the GAME_ENTITY-side + * game-level facts) into a compact {type, color, square} view. + */ +function getPieces(session: Session): PieceInfo[] { + const facts = session.allFacts(); + const typeFacts = facts.filter((f) => f.attr === "PieceType" && f.id > 0); + const out: PieceInfo[] = []; + for (const tf of typeFacts) { + const colorFact = facts.find((c) => c.id === tf.id && c.attr === "Color"); + const posFact = facts.find((p) => p.id === tf.id && p.attr === "Position"); + if (!colorFact || !posFact) continue; + out.push({ + id: tf.id, + type: tf.value as PieceType, + color: colorFact.value as PieceColor, + square: posFact.value as Square, + }); + } + return out; +} + +/** + * Returns true if the current material configuration matches one of the + * four FIDE insufficient-material cases. + */ +export function isInsufficientMaterial(session: Session): boolean { + const pieces = getPieces(session); + + const white = pieces.filter((p) => p.color === "white"); + const black = pieces.filter((p) => p.color === "black"); + + const nonKingWhite = white.filter((p) => p.type !== "king"); + const nonKingBlack = black.filter((p) => p.type !== "king"); + + // Case 1: K vs K + if (nonKingWhite.length === 0 && nonKingBlack.length === 0) return true; + + // Case 2: K vs K + minor (bishop or knight) + if (nonKingWhite.length === 0 && nonKingBlack.length === 1) { + const p = nonKingBlack[0]!; + if (p.type === "bishop" || p.type === "knight") return true; + } + if (nonKingBlack.length === 0 && nonKingWhite.length === 1) { + const p = nonKingWhite[0]!; + if (p.type === "bishop" || p.type === "knight") return true; + } + + // Case 4 (aka "Case 3" in FIDE ordering): K + B vs K + B — draw iff + // bishops share square color. Opposite-color bishops CAN still mate + // in contrived positions (though not by force), so not a draw here. + if (nonKingWhite.length === 1 && nonKingBlack.length === 1) { + const wb = nonKingWhite[0]!; + const bb = nonKingBlack[0]!; + if (wb.type === "bishop" && bb.type === "bishop") { + return squareColor(wb.square) === squareColor(bb.square); + } + } + + return false; +}