feat(chess): add 50-move and threefold repetition rules (P2.21)
This commit is contained in:
parent
20a18e6c78
commit
29ea2136b8
2 changed files with 388 additions and 0 deletions
282
packages/chess/src/rules/draws.test.ts
Normal file
282
packages/chess/src/rules/draws.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
packages/chess/src/rules/draws.ts
Normal file
106
packages/chess/src/rules/draws.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue