feat(chess): add turn order + move integration (P2.13)
This commit is contained in:
parent
483e4ef686
commit
d9eaeb2f06
2 changed files with 434 additions and 0 deletions
257
packages/chess/src/rules/turn.test.ts
Normal file
257
packages/chess/src/rules/turn.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Tests for turn.ts (P2.13).
|
||||
*
|
||||
* We register a minimal inline pawn generator so the tests do not depend
|
||||
* on pawn.ts being fully wired up at module-load time (parallel-task
|
||||
* robustness). Each describe/it block that needs the registry calls
|
||||
* {@link clearMoveGeneratorRegistry} first to avoid cross-test pollution.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import {
|
||||
registerMoveGenerator,
|
||||
clearMoveGeneratorRegistry,
|
||||
getLegalMovesForColor,
|
||||
getLegalMovesForCurrentTurn,
|
||||
applyMove,
|
||||
isLegalMove,
|
||||
} from "./turn.js";
|
||||
import { generateStartingPosition } from "../starting-position.js";
|
||||
import { GAME_ENTITY, type PieceColor, type PieceType } from "../schema.js";
|
||||
import type { LegalMove } from "./types.js";
|
||||
|
||||
const mkId = (n: number) => n as EntityId;
|
||||
|
||||
/** Minimal single-advance pawn generator — sufficient for these tests. */
|
||||
function registerStubPawnGenerator(): void {
|
||||
registerMoveGenerator("pawn", (session, pieceId) => {
|
||||
const fromRaw = session.get(pieceId, "Position");
|
||||
const colorRaw = session.get(pieceId, "Color");
|
||||
if (fromRaw === undefined || colorRaw === undefined) return [];
|
||||
const from = fromRaw as number;
|
||||
const color = colorRaw as PieceColor;
|
||||
const to = color === "white" ? from + 8 : from - 8;
|
||||
if (to < 0 || to > 63) return [];
|
||||
|
||||
// Occupancy check.
|
||||
const facts = session.allFacts();
|
||||
const occupantPos = facts.find(
|
||||
(f) => f.attr === "Position" && f.value === to,
|
||||
);
|
||||
if (occupantPos === undefined) {
|
||||
return [{ pieceId, from, to, isCapture: false }];
|
||||
}
|
||||
const occupantColor = session.get(occupantPos.id, "Color");
|
||||
if (occupantColor === color) return []; // blocked by ally
|
||||
return [{ pieceId, from, to, isCapture: true }];
|
||||
});
|
||||
}
|
||||
|
||||
function insertPiece(
|
||||
session: Session,
|
||||
id: number,
|
||||
type: PieceType,
|
||||
color: PieceColor,
|
||||
square: number,
|
||||
): EntityId {
|
||||
const eid = mkId(id);
|
||||
session.insert(eid, "PieceType", type);
|
||||
session.insert(eid, "Color", color);
|
||||
session.insert(eid, "Position", square);
|
||||
session.insert(eid, "HasMoved", false);
|
||||
return eid;
|
||||
}
|
||||
|
||||
// ─── applyMove ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyMove", () => {
|
||||
it("updates piece position and flips turn white→black", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
generateStartingPosition(session);
|
||||
|
||||
// e2 pawn: square 12, white. Starting-position insertion assigns
|
||||
// incremental ids — find by Position/Color rather than hard-coding.
|
||||
const facts = session.allFacts();
|
||||
const e2PawnFact = facts.find(
|
||||
(f) =>
|
||||
f.attr === "Position" &&
|
||||
f.value === 12 &&
|
||||
session.get(f.id, "Color") === "white",
|
||||
);
|
||||
expect(e2PawnFact).toBeDefined();
|
||||
const e2Pawn = e2PawnFact!.id;
|
||||
|
||||
const move: LegalMove = { pieceId: e2Pawn, from: 12, to: 20, isCapture: false };
|
||||
applyMove(session, move);
|
||||
|
||||
expect(session.get(e2Pawn, "Position")).toBe(20);
|
||||
expect(session.get(GAME_ENTITY, "Turn")).toBe("black");
|
||||
expect(session.get(e2Pawn, "HasMoved")).toBe(true);
|
||||
});
|
||||
|
||||
it("capture retracts all attrs of the enemy piece at destination", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
const wp = insertPiece(session, 1, "pawn", "white", 28); // e4
|
||||
const bp = insertPiece(session, 2, "pawn", "black", 35); // d5
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
|
||||
applyMove(session, { pieceId: wp, from: 28, to: 35, isCapture: true });
|
||||
|
||||
expect(session.get(wp, "Position")).toBe(35);
|
||||
expect(session.contains(bp, "Position")).toBe(false);
|
||||
expect(session.contains(bp, "Color")).toBe(false);
|
||||
expect(session.contains(bp, "PieceType")).toBe(false);
|
||||
expect(session.contains(bp, "HasMoved")).toBe(false);
|
||||
});
|
||||
|
||||
it("HasMoved flag becomes true after any move (king example)", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
const king = insertPiece(session, 1, "king", "white", 4); // e1
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
|
||||
applyMove(session, { pieceId: king, from: 4, to: 5, isCapture: false });
|
||||
expect(session.get(king, "HasMoved")).toBe(true);
|
||||
});
|
||||
|
||||
it("increments HalfmoveClock on every move", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
insertPiece(session, 1, "knight", "white", 1);
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
|
||||
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
|
||||
|
||||
applyMove(session, {
|
||||
pieceId: mkId(1),
|
||||
from: 1,
|
||||
to: 18,
|
||||
isCapture: false,
|
||||
});
|
||||
expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(1);
|
||||
});
|
||||
|
||||
it("increments FullmoveNumber after black's move, not after white's", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
insertPiece(session, 1, "knight", "white", 1);
|
||||
insertPiece(session, 2, "knight", "black", 57);
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
|
||||
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
|
||||
|
||||
applyMove(session, { pieceId: mkId(1), from: 1, to: 18, isCapture: false });
|
||||
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1); // still 1 after white
|
||||
expect(session.get(GAME_ENTITY, "Turn")).toBe("black");
|
||||
|
||||
applyMove(session, { pieceId: mkId(2), from: 57, to: 42, isCapture: false });
|
||||
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2); // bumped after black
|
||||
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getLegalMovesForColor / CurrentTurn ─────────────────────────────────────
|
||||
|
||||
describe("getLegalMovesForColor + getLegalMovesForCurrentTurn", () => {
|
||||
beforeEach(() => {
|
||||
clearMoveGeneratorRegistry();
|
||||
registerStubPawnGenerator();
|
||||
});
|
||||
|
||||
it("returns only moves for pieces of the requested color", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
generateStartingPosition(session);
|
||||
|
||||
const white = getLegalMovesForColor(session, "white");
|
||||
const black = getLegalMovesForColor(session, "black");
|
||||
|
||||
// With the stub single-advance pawn gen, each side has 8 pawn moves
|
||||
// from the starting position (knights/bishops/etc. have no registered
|
||||
// generator, so they silently contribute zero).
|
||||
expect(white.length).toBe(8);
|
||||
expect(black.length).toBe(8);
|
||||
for (const m of white) {
|
||||
expect(session.get(m.pieceId, "Color")).toBe("white");
|
||||
}
|
||||
for (const m of black) {
|
||||
expect(session.get(m.pieceId, "Color")).toBe("black");
|
||||
}
|
||||
});
|
||||
|
||||
it("getLegalMovesForCurrentTurn respects the Turn fact", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
generateStartingPosition(session);
|
||||
|
||||
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
|
||||
const firstTurn = getLegalMovesForCurrentTurn(session);
|
||||
expect(firstTurn.length).toBe(8);
|
||||
for (const m of firstTurn) {
|
||||
expect(session.get(m.pieceId, "Color")).toBe("white");
|
||||
}
|
||||
|
||||
session.insert(GAME_ENTITY, "Turn", "black");
|
||||
const secondTurn = getLegalMovesForCurrentTurn(session);
|
||||
for (const m of secondTurn) {
|
||||
expect(session.get(m.pieceId, "Color")).toBe("black");
|
||||
}
|
||||
});
|
||||
|
||||
it("pieces without a registered generator contribute zero moves", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
insertPiece(session, 1, "knight", "white", 1); // no knight generator registered
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
|
||||
const moves = getLegalMovesForColor(session, "white");
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores game-level facts (GAME_ENTITY=0)", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
// Only game-level facts, no pieces.
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
|
||||
|
||||
const moves = getLegalMovesForColor(session, "white");
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isLegalMove ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("isLegalMove", () => {
|
||||
beforeEach(() => {
|
||||
clearMoveGeneratorRegistry();
|
||||
registerStubPawnGenerator();
|
||||
});
|
||||
|
||||
it("accepts a single-advance pawn move from the starting position", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
const p = insertPiece(session, 1, "pawn", "white", 12); // e2
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
|
||||
expect(
|
||||
isLegalMove(session, { pieceId: p, from: 12, to: 20, isCapture: false }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a move to a square the piece cannot reach", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
const p = insertPiece(session, 1, "pawn", "white", 12);
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
|
||||
// Stub only generates single-advance; 28 (double advance) is NOT legal.
|
||||
expect(
|
||||
isLegalMove(session, { pieceId: p, from: 12, to: 28, isCapture: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a move whose pieceId has no Color in WM", () => {
|
||||
const session = new Session({ autoFire: false });
|
||||
session.insert(GAME_ENTITY, "Turn", "white");
|
||||
expect(
|
||||
isLegalMove(session, {
|
||||
pieceId: mkId(99),
|
||||
from: 0,
|
||||
to: 8,
|
||||
isCapture: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
177
packages/chess/src/rules/turn.ts
Normal file
177
packages/chess/src/rules/turn.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Turn order + move legality integration (P2.13).
|
||||
*
|
||||
* Wires per-piece move generators (pawn.ts, knight.ts, sliding.ts, king.ts)
|
||||
* into a single entry point via a registry, and implements the move-
|
||||
* application side-effect (Position update, Turn flip, clock bookkeeping,
|
||||
* capture handling).
|
||||
*
|
||||
* Design notes
|
||||
* ────────────
|
||||
* - We use a *registry* of GetLegalMovesForPiece functions rather than
|
||||
* direct imports so that:
|
||||
* (a) turn.ts has zero compile-time dependency on individual piece
|
||||
* modules (they may not yet exist during parallel task execution);
|
||||
* (b) custom rule presets (RULES.md) can swap a generator in — e.g. a
|
||||
* "knight-rider" ruleset replaces the knight entry;
|
||||
* (c) tests can register minimal stubs (see turn.test.ts).
|
||||
*
|
||||
* - `applyMove` treats `session.insert` as *upsert* (see wm.ts): inserting
|
||||
* `Position` again overwrites the previous value and fires retract→insert
|
||||
* listeners, so rules downstream can observe the transition.
|
||||
*
|
||||
* - Capture semantics: we retract ALL known piece-level attrs of the
|
||||
* captured entity (PieceType, Color, Position, HasMoved, Hp). We do NOT
|
||||
* touch the EntityId itself — once minted, ids are never reused
|
||||
* (SPEC.md §ID Authority).
|
||||
*
|
||||
* Explicitly NOT handled here (deferred):
|
||||
* - castling → P2.15
|
||||
* - en passant → P2.16
|
||||
* - promotion choice → P2.17
|
||||
* - check filtering → P2.18
|
||||
*/
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { PieceColor, PieceType } from "../schema.js";
|
||||
import { GAME_ENTITY } from "../schema.js";
|
||||
import type { LegalMove } from "./types.js";
|
||||
import { getPieceColor, getPieceAt } from "./board-queries.js";
|
||||
|
||||
/** Signature for a per-piece-type legal-move generator. */
|
||||
export type GetLegalMovesForPiece = (
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
) => LegalMove[];
|
||||
|
||||
/**
|
||||
* Registry of move generators keyed by PieceType.
|
||||
*
|
||||
* Populated via {@link registerMoveGenerator}. Missing entries cause the
|
||||
* corresponding pieces to contribute zero moves (silently) — this is
|
||||
* intentional so that turn.ts remains usable while peer piece modules are
|
||||
* still being written in parallel.
|
||||
*/
|
||||
const moveGeneratorRegistry = new Map<PieceType, GetLegalMovesForPiece>();
|
||||
|
||||
/** Register (or replace) the move generator for a given PieceType. */
|
||||
export function registerMoveGenerator(
|
||||
type: PieceType,
|
||||
fn: GetLegalMovesForPiece,
|
||||
): void {
|
||||
moveGeneratorRegistry.set(type, fn);
|
||||
}
|
||||
|
||||
/** Remove all registered generators. Primarily for test isolation. */
|
||||
export function clearMoveGeneratorRegistry(): void {
|
||||
moveGeneratorRegistry.clear();
|
||||
}
|
||||
|
||||
/** Piece-level attributes retracted when an entity is captured. */
|
||||
const PIECE_ATTRS = [
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Hp",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get every legal move available to pieces of `color` given the current
|
||||
* board state. Pieces whose type has no registered generator contribute
|
||||
* nothing (see module docstring).
|
||||
*/
|
||||
export function getLegalMovesForColor(
|
||||
session: Session,
|
||||
color: PieceColor,
|
||||
): LegalMove[] {
|
||||
const facts = session.allFacts();
|
||||
const moves: LegalMove[] = [];
|
||||
|
||||
for (const f of facts) {
|
||||
// Skip game-level facts (GAME_ENTITY = 0) and non-type facts.
|
||||
if (f.attr !== "PieceType" || (f.id as number) <= 0) continue;
|
||||
|
||||
const pieceId = f.id;
|
||||
// Color lookup: direct session.get is O(1) — cheaper than another
|
||||
// facts.find() scan inside the outer loop.
|
||||
const pieceColor = session.get(pieceId, "Color");
|
||||
if (pieceColor !== color) continue;
|
||||
|
||||
const generator = moveGeneratorRegistry.get(f.value as PieceType);
|
||||
if (generator === undefined) continue;
|
||||
|
||||
moves.push(...generator(session, pieceId));
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
/** Get all legal moves for the color whose turn it currently is. */
|
||||
export function getLegalMovesForCurrentTurn(session: Session): LegalMove[] {
|
||||
const turn = session.get(GAME_ENTITY, "Turn");
|
||||
const color: PieceColor = (turn ?? "white") as PieceColor;
|
||||
return getLegalMovesForColor(session, color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `move` a currently-legal move?
|
||||
*
|
||||
* A move is legal iff it appears in the legal-move list for the mover's
|
||||
* color. We look up the mover's color from WM rather than trusting the
|
||||
* caller — this prevents cross-color "turn jumping" via forged moves.
|
||||
*/
|
||||
export function isLegalMove(session: Session, move: LegalMove): boolean {
|
||||
const color = getPieceColor(session, move.pieceId);
|
||||
if (color === null) return false;
|
||||
const legal = getLegalMovesForColor(session, color);
|
||||
return legal.some(
|
||||
(m) =>
|
||||
m.pieceId === move.pieceId && m.from === move.from && m.to === move.to,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `move` to `session` in-place.
|
||||
*
|
||||
* 1. If `isCapture`, retract all piece-level attrs of the victim at `to`.
|
||||
* 2. Update the mover's Position (upsert) and set HasMoved=true.
|
||||
* 3. Flip Turn.
|
||||
* 4. Increment HalfmoveClock (simplified — pawn-move / capture reset is
|
||||
* handled at the rule layer in a later phase).
|
||||
* 5. Increment FullmoveNumber after black's move (FIDE convention).
|
||||
*
|
||||
* Does NOT validate legality — pair with {@link isLegalMove} at the
|
||||
* boundary (e.g. network handler) before calling.
|
||||
*/
|
||||
export function applyMove(session: Session, move: LegalMove): void {
|
||||
// ─── 1. Capture ──────────────────────────────────────────────────────────
|
||||
if (move.isCapture) {
|
||||
const capturedId = getPieceAt(session, move.to);
|
||||
if (capturedId !== null && capturedId !== move.pieceId) {
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(capturedId, attr)) {
|
||||
session.retract(capturedId, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. Move ─────────────────────────────────────────────────────────────
|
||||
session.insert(move.pieceId, "Position", move.to);
|
||||
session.insert(move.pieceId, "HasMoved", true);
|
||||
|
||||
// ─── 3/4/5. Game-level bookkeeping ───────────────────────────────────────
|
||||
const currentTurn = (session.get(GAME_ENTITY, "Turn") ?? "white") as PieceColor;
|
||||
const nextTurn: PieceColor = currentTurn === "white" ? "black" : "white";
|
||||
session.insert(GAME_ENTITY, "Turn", nextTurn);
|
||||
|
||||
const halfmove =
|
||||
((session.get(GAME_ENTITY, "HalfmoveClock") as number | undefined) ?? 0) + 1;
|
||||
session.insert(GAME_ENTITY, "HalfmoveClock", halfmove);
|
||||
|
||||
if (currentTurn === "black") {
|
||||
const fullmove =
|
||||
((session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? 1) + 1;
|
||||
session.insert(GAME_ENTITY, "FullmoveNumber", fullmove);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue