feat(chess): add insufficient material draw (P2.22)
This commit is contained in:
parent
29ea2136b8
commit
3f8a38bb41
2 changed files with 210 additions and 0 deletions
122
packages/chess/src/rules/insufficient.test.ts
Normal file
122
packages/chess/src/rules/insufficient.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
88
packages/chess/src/rules/insufficient.ts
Normal file
88
packages/chess/src/rules/insufficient.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue