feat(chess): add stalemate detection (P2.20)
This commit is contained in:
parent
6b760c9535
commit
20a18e6c78
2 changed files with 207 additions and 0 deletions
83
packages/chess/src/rules/stalemate.test.ts
Normal file
83
packages/chess/src/rules/stalemate.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Tests for stalemate detection (P2.20).
|
||||
*
|
||||
* Square indexing (per schema.ts):
|
||||
* square = rank * 8 + file, file 0=a..7=h, rank 0=rank1..7=rank8
|
||||
* a1=0, h1=7, a8=56, h8=63
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { isStalemate } from "./stalemate.js";
|
||||
|
||||
const mkId = (n: number) => n as EntityId;
|
||||
|
||||
function insertPiece(
|
||||
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("isStalemate", () => {
|
||||
it("classic stalemate: king at h8 with enemy queen on g6, not in check, no legal moves", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
// White king at h8(63); escape squares: g8(62), g7(54), h7(55).
|
||||
// Black queen at g6(46) covers g-file (g7, g8) and diagonal to h7.
|
||||
// Queen does NOT attack h8 itself (h8 vs g6 is file-diff 1, rank-diff 2).
|
||||
insertPiece(session, 1, "king", "white", 63); // h8
|
||||
insertPiece(session, 2, "queen", "black", 46); // g6
|
||||
// Black king needs to exist (and must be far from action so it doesn't
|
||||
// accidentally attack white king or be adjacent to the queen).
|
||||
insertPiece(session, 3, "king", "black", 0); // a1
|
||||
expect(isStalemate(session, "white")).toBe(true);
|
||||
});
|
||||
|
||||
it("NOT stalemate when side is in check", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
insertPiece(session, 1, "king", "white", 4); // e1
|
||||
insertPiece(session, 2, "rook", "black", 60); // e8 — gives check along e-file
|
||||
insertPiece(session, 3, "king", "black", 56); // a8 (far corner)
|
||||
expect(isStalemate(session, "white")).toBe(false);
|
||||
});
|
||||
|
||||
it("NOT stalemate when king has legal escape squares", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
insertPiece(session, 1, "king", "white", 4); // e1, open board
|
||||
insertPiece(session, 2, "king", "black", 60); // e8
|
||||
expect(isStalemate(session, "white")).toBe(false);
|
||||
});
|
||||
|
||||
it("checkmate position is NOT stalemate (in check disqualifies)", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
// Back-rank-style mate: white king h1 boxed in by own pawn g2,h2;
|
||||
// black rook on a1 delivers check along rank 1 (file 0..7). Note
|
||||
// the rook's line to the king is clear — nothing between a1 and h1.
|
||||
// King cannot step to h2/g2 (own pieces) or g1 (attacked along rank).
|
||||
insertPiece(session, 1, "king", "white", 7); // h1
|
||||
insertPiece(session, 2, "pawn", "white", 14); // g2
|
||||
insertPiece(session, 3, "pawn", "white", 15); // h2
|
||||
insertPiece(session, 4, "rook", "black", 0); // a1 — check!
|
||||
insertPiece(session, 5, "king", "black", 56); // a8
|
||||
// In check → isStalemate must return false regardless of move count.
|
||||
expect(isStalemate(session, "white")).toBe(false);
|
||||
});
|
||||
|
||||
it("stalemate check is color-specific", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
// Stalemate white (same position as first test); black side has
|
||||
// plenty of moves (queen can move anywhere) — not stalemated.
|
||||
insertPiece(session, 1, "king", "white", 63); // h8
|
||||
insertPiece(session, 2, "queen", "black", 46); // g6
|
||||
insertPiece(session, 3, "king", "black", 0); // a1
|
||||
expect(isStalemate(session, "white")).toBe(true);
|
||||
expect(isStalemate(session, "black")).toBe(false);
|
||||
});
|
||||
});
|
||||
124
packages/chess/src/rules/stalemate.ts
Normal file
124
packages/chess/src/rules/stalemate.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Stalemate detection for @paratype/chess (P2.20).
|
||||
*
|
||||
* A side is stalemated when:
|
||||
* 1. It is NOT currently in check, AND
|
||||
* 2. It has no legal moves available (every candidate move would
|
||||
* either be illegal for piece-movement reasons or would leave
|
||||
* its own king in check).
|
||||
*
|
||||
* This is the inverse of checkmate on condition (1): checkmate requires
|
||||
* the side to BE in check, stalemate requires the side NOT to be in
|
||||
* check. Both share the "no legal moves" leg.
|
||||
*
|
||||
* Implementation mirrors the what-if approach used by `filterSelfCheckMoves`:
|
||||
* for each friendly piece, generate its pseudo-legal moves, apply each
|
||||
* to a temporary Session snapshot, and check whether the resulting
|
||||
* position leaves our king in check. If ANY move survives, we are not
|
||||
* stalemated (and we can short-circuit).
|
||||
*
|
||||
* P2.19 (checkmate) is implemented in parallel and may expose a shared
|
||||
* `hasAnyEscapeMove` helper; to keep this module independent we
|
||||
* implement our own `hasAnyLegalMove` inline.
|
||||
*/
|
||||
import { Session as ReteSession } from "@paratype/rete";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { PieceColor, PieceType } from "../schema.js";
|
||||
import { isInCheck } from "./check.js";
|
||||
import { getLegalPawnMoves } from "./pawn.js";
|
||||
import { getLegalKnightMoves } from "./knight.js";
|
||||
import {
|
||||
getLegalRookMoves,
|
||||
getLegalBishopMoves,
|
||||
getLegalQueenMoves,
|
||||
} from "./sliding.js";
|
||||
import { getLegalKingMoves } from "./king.js";
|
||||
import type { LegalMove } from "./types.js";
|
||||
|
||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||
|
||||
const MOVE_GETTERS: Record<PieceType, MoveGetter> = {
|
||||
pawn: getLegalPawnMoves,
|
||||
knight: getLegalKnightMoves,
|
||||
bishop: getLegalBishopMoves,
|
||||
rook: getLegalRookMoves,
|
||||
queen: getLegalQueenMoves,
|
||||
king: getLegalKingMoves,
|
||||
};
|
||||
|
||||
/** Attributes we copy when snapshotting a session for "what-if" analysis. */
|
||||
const PIECE_ATTRS = [
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Hp",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Does `color` have at least one move that doesn't leave its king in
|
||||
* check? Short-circuits on the first legal move found.
|
||||
*/
|
||||
function hasAnyLegalMove(session: Session, color: PieceColor): boolean {
|
||||
const facts = session.allFacts();
|
||||
|
||||
// Collect (pieceId, type) for every piece of `color`. Game-level
|
||||
// facts live on id 0 — skip those.
|
||||
const pieces: { id: EntityId; type: PieceType }[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "Color" || f.value !== color) continue;
|
||||
if (f.id <= 0) continue;
|
||||
const typeFact = facts.find(t => t.id === f.id && t.attr === "PieceType");
|
||||
if (typeFact === undefined) continue;
|
||||
pieces.push({ id: f.id, type: typeFact.value as PieceType });
|
||||
}
|
||||
|
||||
for (const piece of pieces) {
|
||||
const getter = MOVE_GETTERS[piece.type];
|
||||
if (getter === undefined) continue;
|
||||
|
||||
for (const move of getter(session, piece.id)) {
|
||||
// Build a what-if session with only the piece-geometry attrs.
|
||||
const temp = new ReteSession({ autoFire: false });
|
||||
for (const f of facts) {
|
||||
if (!(PIECE_ATTRS as readonly string[]).includes(f.attr)) continue;
|
||||
// For captures, drop whatever sits on the destination square.
|
||||
if (move.isCapture && f.attr === "Position" && f.value === move.to) {
|
||||
continue;
|
||||
}
|
||||
temp.insert(f.id, f.attr, f.value);
|
||||
}
|
||||
// For captures we also need to clear the captured piece's other
|
||||
// attrs (PieceType, Color, ...). Easiest: retract by id.
|
||||
if (move.isCapture) {
|
||||
const captured = facts.find(
|
||||
g => g.attr === "Position" && g.value === move.to && g.id !== move.pieceId,
|
||||
);
|
||||
if (captured !== undefined) {
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (temp.contains(captured.id, attr)) {
|
||||
temp.retract(captured.id, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the move (overwrite Position in place).
|
||||
temp.insert(move.pieceId, "Position", move.to);
|
||||
|
||||
if (!isInCheck(temp, color)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `color` stalemated in the given session?
|
||||
*
|
||||
* Stalemate iff `color` is NOT in check AND has no legal moves.
|
||||
* Returns false when in check (that's checkmate, handled by P2.19).
|
||||
*/
|
||||
export function isStalemate(session: Session, color: PieceColor): boolean {
|
||||
if (isInCheck(session, color)) return false;
|
||||
return !hasAnyLegalMove(session, color);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue