feat(chess): add insufficient material draw (P2.22)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:05:54 -06:00
commit 3f8a38bb41
No known key found for this signature in database
2 changed files with 210 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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;
}