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