feat(chess): add 50-move and threefold repetition rules (P2.21)

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

View file

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

View file

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