diff --git a/packages/chess/src/rules/draws.test.ts b/packages/chess/src/rules/draws.test.ts new file mode 100644 index 0000000..9784bd3 --- /dev/null +++ b/packages/chess/src/rules/draws.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { + isFiftyMoveDraw, + updateHalfmoveClock, + resetHalfmoveClock, + recordPosition, + isThreefoldRepetition, + computePositionHash, +} from "./draws.js"; +import { GAME_ENTITY } from "../schema.js"; +import type { PieceColor, PieceType, Square } from "../schema.js"; +import type { LegalMove } from "./types.js"; + +function mkSession(): Session { + return new Session({ autoFire: false }); +} + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = id as EntityId; + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +const noCapNonPawn: LegalMove = { + pieceId: 2 as EntityId, + from: 0, + to: 1, + isCapture: false, +}; +const captureMove: LegalMove = { + pieceId: 2 as EntityId, + from: 0, + to: 1, + isCapture: true, +}; + +// ─── isFiftyMoveDraw ───────────────────────────────────────────────────────── + +describe("isFiftyMoveDraw", () => { + it("false when clock is unset", () => { + const session = mkSession(); + expect(isFiftyMoveDraw(session)).toBe(false); + }); + + it("false when clock < 100", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 50); + expect(isFiftyMoveDraw(session)).toBe(false); + }); + + it("false at 99 (boundary)", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 99); + expect(isFiftyMoveDraw(session)).toBe(false); + }); + + it("true when clock = 100", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 100); + expect(isFiftyMoveDraw(session)).toBe(true); + }); + + it("true when clock > 100", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 105); + expect(isFiftyMoveDraw(session)).toBe(true); + }); +}); + +// ─── updateHalfmoveClock ───────────────────────────────────────────────────── + +describe("updateHalfmoveClock", () => { + it("increments for non-pawn non-capture moves", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 10); + updateHalfmoveClock(session, noCapNonPawn, false); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(11); + }); + + it("starts from 0 when clock is unset and move is non-reset", () => { + const session = mkSession(); + updateHalfmoveClock(session, noCapNonPawn, false); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(1); + }); + + it("resets on capture", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 50); + updateHalfmoveClock(session, captureMove, false); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0); + }); + + it("resets on pawn move", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 30); + updateHalfmoveClock(session, noCapNonPawn, true); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0); + }); + + it("resets on pawn capture (both conditions true)", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 42); + updateHalfmoveClock(session, captureMove, true); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0); + }); + + it("reaches 100 after 100 non-reset increments → triggers 50-move draw", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 0); + for (let i = 0; i < 100; i++) { + updateHalfmoveClock(session, noCapNonPawn, false); + } + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(100); + expect(isFiftyMoveDraw(session)).toBe(true); + }); +}); + +// ─── resetHalfmoveClock ────────────────────────────────────────────────────── + +describe("resetHalfmoveClock", () => { + it("sets the clock to 0", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "HalfmoveClock", 73); + resetHalfmoveClock(session); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0); + }); + + it("works when the clock was unset", () => { + const session = mkSession(); + resetHalfmoveClock(session); + expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0); + }); +}); + +// ─── computePositionHash ───────────────────────────────────────────────────── + +describe("computePositionHash", () => { + it("is deterministic for identical positions", () => { + const s1 = mkSession(); + s1.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(s1, 1, "king", "white", 4); + + const s2 = mkSession(); + s2.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(s2, 1, "king", "white", 4); + + expect(computePositionHash(s1)).toBe(computePositionHash(s2)); + }); + + it("differs when side-to-move changes", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + const a = computePositionHash(session); + session.insert(GAME_ENTITY, "Turn", "black"); + const b = computePositionHash(session); + expect(a).not.toBe(b); + }); + + it("differs when en passant target changes", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + session.insert(GAME_ENTITY, "EnPassantTarget", null); + const a = computePositionHash(session); + session.insert(GAME_ENTITY, "EnPassantTarget", 20); + const b = computePositionHash(session); + expect(a).not.toBe(b); + }); + + it("excludes the GAME_ENTITY from piece facts", () => { + // GAME_ENTITY is id=0; piece fact filter requires id > 0. + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + // Inserting piece-shaped facts on GAME_ENTITY must NOT affect the hash's + // piece-facts slice (they would pollute cross-game comparisons). + const hashBefore = computePositionHash(session); + session.insert(GAME_ENTITY, "HalfmoveClock", 5); + expect(computePositionHash(session)).toBe(hashBefore); + }); +}); + +// ─── threefold repetition ──────────────────────────────────────────────────── + +describe("threefold repetition", () => { + it("not a repetition after 1 occurrence", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); + expect(isThreefoldRepetition(session)).toBe(false); + }); + + it("not a repetition after 2 occurrences", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); + recordPosition(session); + expect(isThreefoldRepetition(session)).toBe(false); + }); + + it("detected after 3 occurrences", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); + recordPosition(session); + recordPosition(session); + expect(isThreefoldRepetition(session)).toBe(true); + }); + + it("still detected after 4+ occurrences", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); + recordPosition(session); + recordPosition(session); + recordPosition(session); + expect(isThreefoldRepetition(session)).toBe(true); + }); + + it("different positions don't trigger repetition", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); // position A, count 1 + + // Change position → new hash + session.insert(1 as EntityId, "Position", 5); + recordPosition(session); // position B, count 1 + + // Change back → back to position A + session.insert(1 as EntityId, "Position", 4); + recordPosition(session); // position A, count 2 + + expect(isThreefoldRepetition(session)).toBe(false); + }); + + it("tracks each distinct position separately", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); // A:1 + session.insert(1 as EntityId, "Position", 5); + recordPosition(session); // B:1 + session.insert(1 as EntityId, "Position", 4); + recordPosition(session); // A:2 + session.insert(1 as EntityId, "Position", 5); + recordPosition(session); // B:2 + session.insert(1 as EntityId, "Position", 4); + recordPosition(session); // A:3 → repetition! + + expect(isThreefoldRepetition(session)).toBe(true); + + // B is not yet threefold + session.insert(1 as EntityId, "Position", 5); + expect(isThreefoldRepetition(session)).toBe(false); + }); + + it("turn change produces a different hash (so same board with different side to move counts separately)", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "Turn", "white"); + insertPiece(session, 1, "king", "white", 4); + recordPosition(session); // white-to-move: count 1 + session.insert(GAME_ENTITY, "Turn", "black"); + recordPosition(session); // black-to-move: count 1 + session.insert(GAME_ENTITY, "Turn", "white"); + recordPosition(session); // white-to-move: count 2 + expect(isThreefoldRepetition(session)).toBe(false); + }); +}); diff --git a/packages/chess/src/rules/draws.ts b/packages/chess/src/rules/draws.ts new file mode 100644 index 0000000..cee5aec --- /dev/null +++ b/packages/chess/src/rules/draws.ts @@ -0,0 +1,106 @@ +/** + * Draw rules (P2.21): 50-move rule and threefold repetition. + * + * 50-move rule: + * Draw if 50 consecutive full moves (= 100 half-moves / plies) occur with + * neither a pawn move nor a capture. Tracked via the `HalfmoveClock` + * game-level fact, which increments each ply and resets on a pawn move + * or capture. + * + * Threefold repetition: + * Draw may be claimed when the same position appears three times. A + * "position" here is the hash of all piece placements + side-to-move + + * en passant target. Since the EAV store has no native map/array, each + * position hash is stored as a derived attribute on `GAME_ENTITY` of + * the form `PH_{hash}` whose value is a count. + * + * Neither helper auto-claims the draw — they only expose detection. A + * higher-level game manager decides whether to end the game. + */ +import type { Session } from "@paratype/rete"; +import { GAME_ENTITY } from "../schema.js"; +import type { LegalMove } from "./types.js"; + +/** + * Compute a deterministic hash of the current position. + * + * The hash is derived from: + * - all piece facts (PieceType, Color, Position) for entities with id > 0 + * - the side to move (Turn) + * - the en passant target (EnPassantTarget) + * + * Piece facts are sorted so the hash is independent of insertion order. + */ +export function computePositionHash(session: Session): string { + const facts = session.allFacts(); + const pieceFacts = facts + .filter( + (f) => + (f.id as number) > 0 && + (f.attr === "PieceType" || f.attr === "Color" || f.attr === "Position"), + ) + .map((f) => `${String(f.id)}:${f.attr}=${String(f.value)}`) + .sort() + .join(","); + const turnFact = facts.find((f) => f.id === GAME_ENTITY && f.attr === "Turn"); + const turn = String(turnFact?.value ?? "white"); + const epFact = facts.find( + (f) => f.id === GAME_ENTITY && f.attr === "EnPassantTarget", + ); + const ep = String(epFact?.value ?? "null"); + return `${pieceFacts}|${turn}|ep:${ep}`; +} + +/** + * Record the current position in the position-history counter map. + * Idempotently increments the `PH_{hash}` counter on GAME_ENTITY. + */ +export function recordPosition(session: Session): void { + const hash = computePositionHash(session); + const attrKey = `PH_${hash}`; + const current = (session.get(GAME_ENTITY, attrKey) as number | undefined) ?? 0; + session.insert(GAME_ENTITY, attrKey, current + 1); +} + +/** + * Has the current position occurred at least three times? + * Does not modify state; callers may combine with `recordPosition`. + */ +export function isThreefoldRepetition(session: Session): boolean { + const hash = computePositionHash(session); + const attrKey = `PH_${hash}`; + const count = session.get(GAME_ENTITY, attrKey) as number | undefined; + return (count ?? 0) >= 3; +} + +/** + * Update the halfmove clock after a move. + * Resets to 0 on capture or pawn move; otherwise increments by 1. + */ +export function updateHalfmoveClock( + session: Session, + move: LegalMove, + isPawnMove: boolean, +): void { + if (move.isCapture || isPawnMove) { + session.insert(GAME_ENTITY, "HalfmoveClock", 0); + return; + } + const current = (session.get(GAME_ENTITY, "HalfmoveClock") as number | undefined) ?? 0; + session.insert(GAME_ENTITY, "HalfmoveClock", current + 1); +} + +/** Reset the halfmove clock to 0. */ +export function resetHalfmoveClock(session: Session): void { + session.insert(GAME_ENTITY, "HalfmoveClock", 0); +} + +/** + * Does the 50-move rule draw apply? + * True when the halfmove clock has reached 100 plies (= 50 full moves) + * without a capture or pawn move. + */ +export function isFiftyMoveDraw(session: Session): boolean { + const clock = session.get(GAME_ENTITY, "HalfmoveClock") as number | undefined; + return (clock ?? 0) >= 100; +}