From db55f24ec8cdaab85e6af26d77c38e0fad20b7d1 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 26 Apr 2026 15:05:07 -0600 Subject: [PATCH] feat(thressgame-coverage): Wave 12 (8 move-gen attr readers) All 8 movement-replacement / restriction attrs now consumed by move generators in packages/chess/src/rules/. Previously they were silent infrastructure (seeded by primitives, ignored by rules). - T74 MovesAs / MovesAlsoAs (per-piece): turn.ts dispatcher substitutes/unions piece-type movesets via getLegalMovesAsType helper; deduped output - T75 SlideMustBeMaxDistance (per-piece + game-level): sliding.ts ice-physics mode emits ONLY the furthest legal step per ray - T75 KingExtraReach (per-piece): king.ts step-walker with configurable radius (1+N); blocked by allies/captures within radius - T76 BlockAllExceptKing + BlockedPieceTypes (game-level): turn.ts early-return filters BEFORE per-piece dispatch - T77 MoveClassRestriction (game-level): post-filter in turn.ts; class=capture/advance/move-to filtering - T77 PawnPushesPiecesEnabled (game-level): pawn.ts diagonal capture replaced by push semantics; LegalMove extended with isPawnPush + pushedPieceId + pushedTo Tests: 2774 -> 2824 (+50). bun run check exit 0. ice_physics, all_on_red, mr_freeze, must-class, block-by-piece-type, pawn-pushes-pieces descriptors now actually affect legal-move generation in production play. --- .sisyphus/boulder.json | 6 +- .../chess/src/rules/king.extrareach.test.ts | 110 ++++++++ packages/chess/src/rules/king.ts | 56 +++- packages/chess/src/rules/pawn.push.test.ts | 178 ++++++++++++ packages/chess/src/rules/pawn.ts | 43 ++- .../chess/src/rules/sliding.slidemax.test.ts | 126 +++++++++ packages/chess/src/rules/sliding.ts | 22 +- .../chess/src/rules/turn.blockall.test.ts | 259 ++++++++++++++++++ .../chess/src/rules/turn.moveclass.test.ts | 187 +++++++++++++ packages/chess/src/rules/turn.movesas.test.ts | 253 +++++++++++++++++ packages/chess/src/rules/turn.ts | 226 ++++++++++++++- packages/chess/src/rules/types.ts | 10 + 12 files changed, 1453 insertions(+), 23 deletions(-) create mode 100644 packages/chess/src/rules/king.extrareach.test.ts create mode 100644 packages/chess/src/rules/pawn.push.test.ts create mode 100644 packages/chess/src/rules/sliding.slidemax.test.ts create mode 100644 packages/chess/src/rules/turn.blockall.test.ts create mode 100644 packages/chess/src/rules/turn.moveclass.test.ts create mode 100644 packages/chess/src/rules/turn.movesas.test.ts diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 5a0b80d..1099ce5 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -83,7 +83,11 @@ "ses_23488f9dcffeFtCzBn3gxtN7KV", "ses_23487dd88ffeEDMNwvBDLByD9N", "ses_23488a4a2ffeG7nzsoF7nsvKHC", - "ses_234882ba1ffeMwDf2VAt2fGKaM" + "ses_234882ba1ffeMwDf2VAt2fGKaM", + "ses_23470e618ffeKuOqxTyN3WMJmm", + "ses_23470aeb5ffen8G71F1kKLXWE4", + "ses_234704ca2ffexGa7YYzkVJ7XQy", + "ses_234714154ffePk1XQUSvX1tcat" ], "plan_name": "thressgame-coverage", "agent": "atlas" diff --git a/packages/chess/src/rules/king.extrareach.test.ts b/packages/chess/src/rules/king.extrareach.test.ts new file mode 100644 index 0000000..5e21e66 --- /dev/null +++ b/packages/chess/src/rules/king.extrareach.test.ts @@ -0,0 +1,110 @@ +/** + * T75 — KingExtraReach behaviour for the king. + * + * When `KingExtraReach: N` is set on a king entity, its movement radius + * extends from 1 to `1 + N` squares along each of the 8 directions, walking + * outward like a sliding piece (stopping on ally, capturing enemies, etc.). + */ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { getLegalKingMoves } from "./king.js"; + +const mkId = (n: number) => n as EntityId; + +function insertPiece( + session: Session, + id: number, + type: string, + color: string, + square: number, +): EntityId { + const eid = mkId(id); + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +describe("KingExtraReach", () => { + it("baseline: king at e4 has 8 moves on empty board (no KingExtraReach)", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 28); // e4 + const moves = getLegalKingMoves(session, king); + expect(moves).toHaveLength(8); + }); + + it("KingExtraReach=0 behaves identically to absent attr (8 moves from center)", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 28); + session.insert(king, "KingExtraReach", 0); + expect(getLegalKingMoves(session, king)).toHaveLength(8); + }); + + it("KingExtraReach=1 yields up to 16 squares from e4 (8 dirs × 2 steps each)", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 28); // e4 + session.insert(king, "KingExtraReach", 1); + const moves = getLegalKingMoves(session, king); + // From e4 every direction has at least 2 squares before the edge. + expect(moves).toHaveLength(16); + // Spot-check: 1-step right is f4 (29), 2-step right is g4 (30). + expect(moves.some(m => m.to === 29)).toBe(true); + expect(moves.some(m => m.to === 30)).toBe(true); + // 2-step diagonal up-right: g6 (46). + expect(moves.some(m => m.to === 46)).toBe(true); + }); + + it("KingExtraReach=2 yields up to 24 squares from e4 (8 dirs × 3 steps each)", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 28); // e4 + session.insert(king, "KingExtraReach", 2); + const moves = getLegalKingMoves(session, king); + // From e4, every direction has at least 3 squares before the edge. + expect(moves).toHaveLength(24); + // 3-step right is h4 (31). + expect(moves.some(m => m.to === 31)).toBe(true); + // 3-step up: e7 (52). + expect(moves.some(m => m.to === 52)).toBe(true); + }); + + it("blocked by ally mid-ray: king stops just BEFORE the blocker", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 28); // e4 + session.insert(king, "KingExtraReach", 2); + // Ally on g4 (square 30) — king can step to f4 (29) but not g4 or h4. + insertPiece(session, 2, "pawn", "white", 30); + const moves = getLegalKingMoves(session, king); + expect(moves.some(m => m.to === 29)).toBe(true); + expect(moves.find(m => m.to === 30)).toBeUndefined(); + expect(moves.find(m => m.to === 31)).toBeUndefined(); + }); + + it("enemy mid-ray: capture is legal, but king cannot continue past it", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 28); // e4 + session.insert(king, "KingExtraReach", 2); + // Enemy on g4 (square 30) — capture is legal, but h4 (31) is not reachable. + insertPiece(session, 2, "pawn", "black", 30); + const moves = getLegalKingMoves(session, king); + const f4 = moves.find(m => m.to === 29); + const g4 = moves.find(m => m.to === 30); + expect(f4).toBeDefined(); + expect(f4!.isCapture).toBe(false); + expect(g4).toBeDefined(); + expect(g4!.isCapture).toBe(true); + expect(moves.find(m => m.to === 31)).toBeUndefined(); + }); + + it("near edge: KingExtraReach can't push past the board boundary", () => { + const session = new Session({ autoFire: false }); + const king = insertPiece(session, 1, "king", "white", 0); // a1 + session.insert(king, "KingExtraReach", 5); + const moves = getLegalKingMoves(session, king); + // From a1: 3 directions (right, up, up-right). Each clamped at 6 steps to the + // edge but limited by maxRadius = 1 + 5 = 6 squares per ray. + // Right: b1..g1 = 6 squares (squares 1..6) + // Up: a2..a7 = 6 squares (8, 16, 24, 32, 40, 48) + // Up-right: b2..g7 = 6 squares (9, 18, 27, 36, 45, 54) + expect(moves).toHaveLength(18); + }); +}); diff --git a/packages/chess/src/rules/king.ts b/packages/chess/src/rules/king.ts index cfc7565..8b6eb39 100644 --- a/packages/chess/src/rules/king.ts +++ b/packages/chess/src/rules/king.ts @@ -2,31 +2,63 @@ * King movement rules for @paratype/chess. * * Basic king moves: 1 square in any of 8 directions. + * + * T75 — KingExtraReach (per-piece): extends the king's reach to + * `1 + KingExtraReach` squares along each of the 8 directions, walking + * outward like a sliding piece. The walker stops on: + * - off-board step + * - ally on target (excluded) + * - enemy on target (capture, then stop) + * * Excludes: * - Castling (P2.15) * - Check-aware rejection / king-in-check filter (P2.18) - * - * Rules: - * - empty target square → legal quiet move - * - enemy on target → legal capture - * - ally on target → illegal */ import type { Session, EntityId } from "@paratype/rete"; -import { kingCandidates } from "./primitives.js"; import type { LegalMove } from "./types.js"; -import { getPiecePosition, getPieceColor, isAllyAt, isPieceAt } from "./board-queries.js"; +import { getPiecePosition, getPieceColor, isAllyAt, isEnemyAt } from "./board-queries.js"; +import { fileOf, rankOf, isOnBoard, squareOf } from "../coord.js"; +import type { Square } from "../schema.js"; -/** Get all legal basic king moves (1 square, any direction). */ +/** 8 unit-vector directions for king movement. */ +const KING_DIRS: readonly (readonly [number, number])[] = [ + [1, 0], [-1, 0], [0, 1], [0, -1], + [1, 1], [1, -1], [-1, 1], [-1, -1], +]; + +/** + * Step one square from `from` in direction (df, dr). Returns null if off-board. + */ +function stepOne(from: Square, df: number, dr: number): Square | null { + const nf = fileOf(from) + df; + const nr = rankOf(from) + dr; + if (!isOnBoard(nf, nr)) return null; + return squareOf(nf, nr); +} + +/** Get all legal basic king moves (1 square + KingExtraReach in any of 8 directions). */ export function getLegalKingMoves(session: Session, pieceId: EntityId): LegalMove[] { const from = getPiecePosition(session, pieceId); const color = getPieceColor(session, pieceId); if (from === null || color === null) return []; + // T75: per-piece extra king reach. Default 0 → standard 1-square king. + const rawReach = session.get(pieceId, "KingExtraReach"); + const extraReach = typeof rawReach === "number" ? Math.max(0, rawReach) : 0; + const maxRadius = 1 + extraReach; + const moves: LegalMove[] = []; - for (const to of kingCandidates(from)) { - if (isAllyAt(session, to, color)) continue; - const isCapture = isPieceAt(session, to); - moves.push({ pieceId, from, to, isCapture }); + for (const [df, dr] of KING_DIRS) { + let cur: Square = from; + for (let r = 0; r < maxRadius; r++) { + const next = stepOne(cur, df, dr); + if (next === null) break; // off board + if (isAllyAt(session, next, color)) break; + const isCapture = isEnemyAt(session, next, color); + moves.push({ pieceId, from, to: next, isCapture }); + if (isCapture) break; // can't go past a capture + cur = next; + } } return moves; } diff --git a/packages/chess/src/rules/pawn.push.test.ts b/packages/chess/src/rules/pawn.push.test.ts new file mode 100644 index 0000000..74e23cb --- /dev/null +++ b/packages/chess/src/rules/pawn.push.test.ts @@ -0,0 +1,178 @@ +/** + * T77 — `PawnPushesPiecesEnabled` (game-level) move-gen + applyMove tests. + * + * When the flag is `true` on `GAME_ENTITY`, pawn diagonal "capture" + * squares become PUSH targets: + * - the pawn relocates to the diagonal square (no capture); + * - the target piece is shoved one square further forward. + * + * A push is illegal when the push-destination is off-board or already + * occupied (by anyone). When the flag is absent / `false`, the move + * generator falls back to the FIDE diagonal-capture semantics. + * + * applyMove integration: the engine recognises `isPawnPush` and uses + * the alternate "shove" branch instead of the capture branch. + */ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { getLegalPawnMoves } from "./pawn.js"; +import { applyMove } from "./turn.js"; +import { + GAME_ENTITY, + type PieceColor, + type PieceType, + type Square, +} from "../schema.js"; + +const mkId = (n: number) => n as EntityId; + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = mkId(id); + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +describe("T77 — pawn push-mode move generation", () => { + it("WITHOUT PawnPushesPiecesEnabled → diagonal enemies are normal captures", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + insertPiece(session, 2, "pawn", "black", 35); // d5 + insertPiece(session, 3, "pawn", "black", 37); // f5 + + const moves = getLegalPawnMoves(session, pawn); + const captures = moves.filter((m) => m.isCapture); + expect(captures).toHaveLength(2); + expect(captures.every((m) => m.isPawnPush !== true)).toBe(true); + // No push metadata on capture moves. + expect(captures.every((m) => m.pushedPieceId === undefined)).toBe(true); + }); + + it("WITH PawnPushesPiecesEnabled=true → diagonal enemies become push moves", () => { + const session = new Session({ autoFire: false }); + session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + const left = insertPiece(session, 2, "pawn", "black", 35); // d5 + const right = insertPiece(session, 3, "pawn", "black", 37); // f5 + + const moves = getLegalPawnMoves(session, pawn); + // Both diagonal pushes available + forward advance to e5(36). + const pushes = moves.filter((m) => m.isPawnPush === true); + expect(pushes).toHaveLength(2); + // Pushes are NOT captures. + expect(pushes.every((m) => m.isCapture === false)).toBe(true); + // Push to d5: pawn ends on 35, target d-pawn pushed to 43 (d6). + const leftPush = pushes.find((m) => m.to === 35); + expect(leftPush).toBeDefined(); + expect(leftPush!.pushedPieceId).toBe(left); + expect(leftPush!.pushedTo).toBe(43); + // Push to f5: pawn ends on 37, target f-pawn pushed to 45 (f6). + const rightPush = pushes.find((m) => m.to === 37); + expect(rightPush).toBeDefined(); + expect(rightPush!.pushedPieceId).toBe(right); + expect(rightPush!.pushedTo).toBe(45); + }); + + it("push blocked: enemy on capSq + ally on pushTarget → push is illegal", () => { + const session = new Session({ autoFire: false }); + session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + insertPiece(session, 2, "pawn", "black", 35); // d5 (target) + insertPiece(session, 3, "pawn", "white", 43); // d6 (BLOCKER, ally) + + const moves = getLegalPawnMoves(session, pawn); + // No push, no capture for d-side. Only forward advance to e5(36) + // remains (no piece there). + expect(moves.some((m) => m.to === 35)).toBe(false); + }); + + it("push blocked: enemy on capSq + enemy on pushTarget → push is illegal", () => { + // Two pieces cannot occupy one square; the shove is blocked + // regardless of the blocker's color. + const session = new Session({ autoFire: false }); + session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + insertPiece(session, 2, "pawn", "black", 35); // d5 + insertPiece(session, 3, "pawn", "black", 43); // d6 + + const moves = getLegalPawnMoves(session, pawn); + expect(moves.some((m) => m.to === 35)).toBe(false); + }); + + it("push off-board: black pawn pushing piece off rank 1 → illegal", () => { + // Black pawn at b2 (9), enemy white pawn at a1 (0). Pushing the + // a-pawn would require a square with rank -1 (off-board) → reject. + // Use an unconventional position to set this up. + const session = new Session({ autoFire: false }); + session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true); + const pawn = insertPiece(session, 1, "pawn", "black", 9); // b2 + insertPiece(session, 2, "pawn", "white", 0); // a1 (enemy on diagonal) + + const moves = getLegalPawnMoves(session, pawn); + // No push to a1 because pushing a1 forward (for black pusher = -8 + // → -8, off-board) is invalid. + expect(moves.some((m) => m.to === 0 && m.isPawnPush === true)).toBe(false); + }); +}); + +describe("T77 — applyMove with isPawnPush", () => { + it("applyMove of a push: pawn ends at capSq, target ends at pushTarget", () => { + const session = new Session({ autoFire: false }); + session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true); + session.insert(GAME_ENTITY, "Turn", "white"); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + const target = insertPiece(session, 2, "pawn", "black", 35); // d5 + + applyMove(session, { + pieceId: pawn, + from: 28, + to: 35, + isCapture: false, + isPawnPush: true, + pushedPieceId: target, + pushedTo: 43, // d6 + }); + + expect(session.get(pawn, "Position")).toBe(35); + expect(session.get(target, "Position")).toBe(43); + // Target is NOT retracted (push is not a capture). + expect(session.get(target, "PieceType")).toBe("pawn"); + expect(session.get(target, "Color")).toBe("black"); + // Pawn HasMoved set; turn flipped. + expect(session.get(pawn, "HasMoved")).toBe(true); + expect(session.get(GAME_ENTITY, "Turn")).toBe("black"); + // No LastCaptureSnapshot written (the push is not a capture). + expect(session.get(GAME_ENTITY, "LastCaptureSnapshot")).toBeUndefined(); + }); + + it("applyMove of a push (black) increments fullmove number", () => { + const session = new Session({ autoFire: false }); + session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true); + session.insert(GAME_ENTITY, "Turn", "black"); + session.insert(GAME_ENTITY, "FullmoveNumber", 1); + const pawn = insertPiece(session, 1, "pawn", "black", 35); // d5 + const target = insertPiece(session, 2, "pawn", "white", 28); // e4 + + applyMove(session, { + pieceId: pawn, + from: 35, + to: 28, + isCapture: false, + isPawnPush: true, + pushedPieceId: target, + pushedTo: 20, // e3 + }); + + expect(session.get(pawn, "Position")).toBe(28); + expect(session.get(target, "Position")).toBe(20); + expect(session.get(GAME_ENTITY, "Turn")).toBe("white"); + expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2); + }); +}); diff --git a/packages/chess/src/rules/pawn.ts b/packages/chess/src/rules/pawn.ts index f572b63..6c40b9f 100644 --- a/packages/chess/src/rules/pawn.ts +++ b/packages/chess/src/rules/pawn.ts @@ -24,7 +24,22 @@ import { isEnemyAt, getPiecePosition, getPieceColor, + getPieceAt, } from "./board-queries.js"; +import { GAME_ENTITY, type PieceColor, type Square } from "../schema.js"; + +/** + * T77 — one-step forward for a pawn-push target. A pushed piece moves + * the same direction the pawn is going (white = +8, black = -8). Off- + * board pushes are illegal (returned as `null` so the caller can drop + * the move). This mirrors {@link pawnSingleAdvance} but operates on the + * TARGET square rather than the pawn's own square. + */ +function stepForward(square: Square, color: PieceColor): Square | null { + const next = color === "white" ? square + 8 : square - 8; + if (next < 0 || next > 63) return null; + return next as Square; +} /** * Get all legal pawn moves for a given pawn entity. @@ -57,8 +72,34 @@ export function getLegalPawnMoves(session: Session, pieceId: EntityId): LegalMov } // ─── Diagonal captures ─────────────────────────────────────────────────── + // T77 — `PawnPushesPiecesEnabled` (game-level) re-purposes the + // diagonal capture squares as PUSH targets: the pawn moves to the + // diagonal square (without capturing) and the target piece is shoved + // one square further forward. The push is illegal if either: + // - the push-destination is off-board, OR + // - the push-destination is already occupied (by anyone — ally, + // enemy, or marker), since two pieces cannot occupy one square. + // When the flag is OFF (default / FIDE), the original capture + // semantics apply. + const pushMode = session.get(GAME_ENTITY, "PawnPushesPiecesEnabled") === true; for (const capSq of pawnCaptureSqares(from, color)) { - if (isEnemyAt(session, capSq, color)) { + if (!isEnemyAt(session, capSq, color)) continue; + if (pushMode) { + const pushTarget = stepForward(capSq, color); + if (pushTarget === null) continue; + if (isPieceAt(session, pushTarget)) continue; + const pushedPieceId = getPieceAt(session, capSq); + if (pushedPieceId === null) continue; // defensive — isEnemyAt said yes + moves.push({ + pieceId, + from, + to: capSq, + isCapture: false, + isPawnPush: true, + pushedPieceId, + pushedTo: pushTarget, + }); + } else { moves.push({ pieceId, from, to: capSq, isCapture: true }); } } diff --git a/packages/chess/src/rules/sliding.slidemax.test.ts b/packages/chess/src/rules/sliding.slidemax.test.ts new file mode 100644 index 0000000..e0aa09b --- /dev/null +++ b/packages/chess/src/rules/sliding.slidemax.test.ts @@ -0,0 +1,126 @@ +/** + * T75 — SlideMustBeMaxDistance behaviour for sliding pieces. + * + * When a slider has the per-piece flag (or the game-level flag is set on + * GAME_ENTITY), every ray must commit to its maximum legal step or be + * stuck (no move emitted along that ray). + */ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { getLegalBishopMoves, getLegalRookMoves } from "./sliding.js"; +import { GAME_ENTITY, type PieceColor, type PieceType, type Square } from "../schema.js"; + +const mkId = (n: number) => n as EntityId; + +function setupSession(): Session { + return new Session({ autoFire: false }); +} + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = mkId(id); + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +describe("SlideMustBeMaxDistance — bishop", () => { + it("baseline: bishop on a1 has 7 moves along the a1-h8 diagonal on empty board", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + const moves = getLegalBishopMoves(session, bishop); + expect(moves).toHaveLength(7); + expect(moves.map(m => m.to).sort((a, b) => a - b)).toEqual([9, 18, 27, 36, 45, 54, 63]); + }); + + it("per-piece flag: bishop on a1 emits ONLY the furthest legal square (h8) along the diagonal", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + session.insert(bishop, "SlideMustBeMaxDistance", true); + const moves = getLegalBishopMoves(session, bishop); + // Bishop at a1 has 4 rays — only the up-right ray has empty squares; the + // other 3 are off-board immediately. So exactly 1 move: h8. + expect(moves).toHaveLength(1); + expect(moves[0]!.to).toBe(63); // h8 + expect(moves[0]!.isCapture).toBe(false); + // Intermediate squares MUST NOT be present. + for (const sq of [9, 18, 27, 36, 45, 54]) { + expect(moves.find(m => m.to === sq)).toBeUndefined(); + } + }); + + it("game-level flag on GAME_ENTITY: any slider behaves as if SlideMustBeMaxDistance=true", () => { + const session = setupSession(); + session.insert(GAME_ENTITY, "SlideMustBeMaxDistance", true); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + const moves = getLegalBishopMoves(session, bishop); + expect(moves).toHaveLength(1); + expect(moves[0]!.to).toBe(63); + }); + + it("blocked by ally: bishop stops at the square just before the ally (and ONLY emits that square)", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + session.insert(bishop, "SlideMustBeMaxDistance", true); + // Ally on c3 (square 18) blocks the diagonal — furthest legal step is b2 (9). + insertPiece(session, 2, "pawn", "white", 18); + const moves = getLegalBishopMoves(session, bishop); + expect(moves).toHaveLength(1); + expect(moves[0]!.to).toBe(9); // b2 + expect(moves[0]!.isCapture).toBe(false); + }); + + it("ally directly adjacent: slider is stuck along that ray (no move emitted)", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + session.insert(bishop, "SlideMustBeMaxDistance", true); + // Ally on b2 (square 9) — bishop can't even take a single step on this ray. + insertPiece(session, 2, "pawn", "white", 9); + const moves = getLegalBishopMoves(session, bishop); + // All 4 rays blocked: 3 off-board, 1 by ally → 0 moves total. + expect(moves).toHaveLength(0); + }); + + it("blocked by enemy: capture is the chosen (and only) move on that ray", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + session.insert(bishop, "SlideMustBeMaxDistance", true); + // Enemy on d4 (square 27) — capture is the chosen furthest step. + insertPiece(session, 2, "pawn", "black", 27); + const moves = getLegalBishopMoves(session, bishop); + expect(moves).toHaveLength(1); + expect(moves[0]!.to).toBe(27); + expect(moves[0]!.isCapture).toBe(true); + // Intermediate empty squares (b2=9, c3=18) MUST NOT appear. + expect(moves.find(m => m.to === 9)).toBeUndefined(); + expect(moves.find(m => m.to === 18)).toBeUndefined(); + }); +}); + +describe("SlideMustBeMaxDistance — rook", () => { + it("rook on a1 with flag: emits one furthest square per ray (h1 + a8)", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 0); // a1 + session.insert(rook, "SlideMustBeMaxDistance", true); + const moves = getLegalRookMoves(session, rook); + // 2 rays open from a1 (right and up), each emits exactly 1 furthest move. + expect(moves).toHaveLength(2); + const targets = moves.map(m => m.to).sort((a, b) => a - b); + expect(targets).toEqual([7, 56]); // h1 and a8 + }); + + it("game-level flag also affects rooks", () => { + const session = setupSession(); + session.insert(GAME_ENTITY, "SlideMustBeMaxDistance", true); + const rook = insertPiece(session, 1, "rook", "white", 0); // a1 + const moves = getLegalRookMoves(session, rook); + expect(moves).toHaveLength(2); + expect(moves.map(m => m.to).sort((a, b) => a - b)).toEqual([7, 56]); + }); +}); diff --git a/packages/chess/src/rules/sliding.ts b/packages/chess/src/rules/sliding.ts index 48a186d..8fcc4ed 100644 --- a/packages/chess/src/rules/sliding.ts +++ b/packages/chess/src/rules/sliding.ts @@ -32,7 +32,7 @@ import { isAllyAt, isEnemyAt, } from "./board-queries.js"; -import type { PieceColor, Square } from "../schema.js"; +import { GAME_ENTITY, type PieceColor, type Square } from "../schema.js"; import { getEffectiveNumericAttr } from "../modifiers/effective-attr.js"; const BASE_RANGE = 7; @@ -64,9 +64,17 @@ function getSlidingMoves( rays: Square[][], maxSteps: number, ): LegalMove[] { + // T75: per-piece OR game-level "must travel max distance" mode (ice_physics). + // When enabled, a slider MUST travel as far as legally possible along each ray + // (or be stuck if no step is legal). Only the furthest legal step on each ray + // is emitted — intermediate squares are pruned. + const mustMax = (session.get(pieceId, "SlideMustBeMaxDistance") === true) + || (session.get(GAME_ENTITY, "SlideMustBeMaxDistance") === true); + const moves: LegalMove[] = []; for (const ray of rays) { let steps = 0; + let lastValid: LegalMove | undefined; for (const to of ray) { if (steps >= maxSteps) break; steps++; @@ -76,12 +84,20 @@ function getSlidingMoves( } if (isEnemyAt(session, to, color)) { // Enemy piece — legal capture, then stop. - moves.push({ pieceId, from, to, isCapture: true }); + lastValid = { pieceId, from, to, isCapture: true }; + if (!mustMax) moves.push(lastValid); break; } // Empty square — legal non-capture move, keep walking. - moves.push({ pieceId, from, to, isCapture: false }); + lastValid = { pieceId, from, to, isCapture: false }; + if (!mustMax) moves.push(lastValid); } + if (mustMax && lastValid !== undefined) { + // Ice mode: only the furthest legal step is emitted on this ray. + moves.push(lastValid); + } + // If mustMax && lastValid === undefined, the slider is "stuck" along + // this direction (e.g. immediately blocked by an ally) — no move emitted. } return moves; } diff --git a/packages/chess/src/rules/turn.blockall.test.ts b/packages/chess/src/rules/turn.blockall.test.ts new file mode 100644 index 0000000..752a975 --- /dev/null +++ b/packages/chess/src/rules/turn.blockall.test.ts @@ -0,0 +1,259 @@ +/** + * T76 — game-level move-gen filters wired into `getLegalMovesForPiece`. + * + * Two GAME_ENTITY-level attrs gate move generation BEFORE any + * native (or Wave-7-T74-substituted) per-piece generator runs: + * + * - `BlockAllExceptKing: boolean` (T8 schema slot, T62 all_on_red) + * When true, only kings produce moves. + * + * - `BlockedPieceTypes: readonly PieceType[]` (T8 schema, T39 primitive) + * Pieces whose `PieceType` is in this list produce no moves. + * + * ## Composition + * + * Both filters are AND-composed (set-intersection): a piece moves + * iff it passes BOTH gates. In particular, `BlockedPieceTypes` + * containing `"king"` blocks the king even when + * `BlockAllExceptKing = true`. This is the natural "ANDed" reading + * that the T76 task spec called out. + * + * ## Test isolation + * + * `getLegalMovesForPiece` consults the move-generator registry + * keyed by `PieceType`. We register minimal one-square-forward + * stubs for every type involved (pawn, knight, king) so each test + * is independent of the live pawn.ts/knight.ts/king.ts modules. + * `clearMoveGeneratorRegistry` runs in `beforeEach` to scrub + * cross-test leakage. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { + clearMoveGeneratorRegistry, + getLegalMovesForPiece, + registerMoveGenerator, +} from "./turn.js"; +import { + GAME_ENTITY, + type PieceColor, + type PieceType, +} from "../schema.js"; +const mkId = (n: number) => n as EntityId; + +/** + * Register a trivial single-step generator for `type`. The move + * goes to `from + 1` and is non-capturing — the actual geometry + * is irrelevant to T76 (we only assert empty vs non-empty). + */ +function registerStubGenerator(type: PieceType): void { + registerMoveGenerator(type, (session, pieceId) => { + const fromRaw = session.get(pieceId, "Position"); + if (fromRaw === undefined) return []; + const from = fromRaw as number; + const to = from + 1; + if (to > 63) return []; + return [{ pieceId, from, to, isCapture: false }]; + }); +} + +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; +} + +describe("getLegalMovesForPiece — T76 game-level filters", () => { + beforeEach(() => { + clearMoveGeneratorRegistry(); + registerStubGenerator("pawn"); + registerStubGenerator("knight"); + registerStubGenerator("king"); + registerStubGenerator("bishop"); + registerStubGenerator("rook"); + registerStubGenerator("queen"); + }); + + // ─── Baseline ───────────────────────────────────────────────────────────── + + it("with neither flag set, every piece moves normally", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + const knight = insertPiece(session, 2, "knight", "white", 1); + const king = insertPiece(session, 3, "king", "white", 4); + + expect(getLegalMovesForPiece(session, pawn)).toHaveLength(1); + expect(getLegalMovesForPiece(session, knight)).toHaveLength(1); + expect(getLegalMovesForPiece(session, king)).toHaveLength(1); + }); + + // ─── BlockAllExceptKing ─────────────────────────────────────────────────── + + it("BlockAllExceptKing=true: only the king produces moves", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + const knight = insertPiece(session, 2, "knight", "white", 1); + const king = insertPiece(session, 3, "king", "white", 4); + session.insert(GAME_ENTITY, "BlockAllExceptKing", true); + + expect(getLegalMovesForPiece(session, pawn)).toEqual([]); + expect(getLegalMovesForPiece(session, knight)).toEqual([]); + expect(getLegalMovesForPiece(session, king)).toHaveLength(1); + }); + + it("BlockAllExceptKing=false is treated as 'no flag' (pieces move)", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + session.insert(GAME_ENTITY, "BlockAllExceptKing", false); + + expect(getLegalMovesForPiece(session, pawn)).toHaveLength(1); + }); + + it("BlockAllExceptKing=true: scan of all 16 starting white pieces — only king is non-empty", () => { + // Manually seed a 16-piece white army (mirror of starting position + // for a single colour). This exercises the scan/early-return + // path that getLegalMovesForColor relies on. + const session = new Session({ autoFire: false }); + let nextId = 1; + const ids: { type: PieceType; id: EntityId }[] = []; + const army: { type: PieceType; sq: number }[] = [ + { type: "rook", sq: 0 }, + { type: "knight", sq: 1 }, + { type: "bishop", sq: 2 }, + { type: "queen", sq: 3 }, + { type: "king", sq: 4 }, + { type: "bishop", sq: 5 }, + { type: "knight", sq: 6 }, + { type: "rook", sq: 7 }, + ...Array.from({ length: 8 }, (_, i) => ({ + type: "pawn" as PieceType, + sq: 8 + i, + })), + ]; + for (const p of army) { + ids.push({ + type: p.type, + id: insertPiece(session, nextId++, p.type, "white", p.sq), + }); + } + expect(ids).toHaveLength(16); + + session.insert(GAME_ENTITY, "BlockAllExceptKing", true); + + let nonEmptyCount = 0; + let kingNonEmpty = false; + for (const { type, id } of ids) { + const moves = getLegalMovesForPiece(session, id); + if (moves.length > 0) { + nonEmptyCount++; + if (type === "king") kingNonEmpty = true; + } else { + expect(type).not.toBe("king"); + } + } + expect(nonEmptyCount).toBe(1); + expect(kingNonEmpty).toBe(true); + }); + + // ─── BlockedPieceTypes ──────────────────────────────────────────────────── + + it("BlockedPieceTypes=['pawn']: pawns return [], other types unaffected", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + const knight = insertPiece(session, 2, "knight", "white", 1); + const king = insertPiece(session, 3, "king", "white", 4); + session.insert(GAME_ENTITY, "BlockedPieceTypes", ["pawn"] as const); + + expect(getLegalMovesForPiece(session, pawn)).toEqual([]); + expect(getLegalMovesForPiece(session, knight)).toHaveLength(1); + expect(getLegalMovesForPiece(session, king)).toHaveLength(1); + }); + + it("BlockedPieceTypes=[] (empty list) leaves every piece movable", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + session.insert(GAME_ENTITY, "BlockedPieceTypes", [] as const); + + expect(getLegalMovesForPiece(session, pawn)).toHaveLength(1); + }); + + it("BlockedPieceTypes with multiple types: each listed type is paralysed", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + const knight = insertPiece(session, 2, "knight", "white", 1); + const bishop = insertPiece(session, 3, "bishop", "white", 2); + const king = insertPiece(session, 4, "king", "white", 4); + session.insert(GAME_ENTITY, "BlockedPieceTypes", [ + "pawn", + "knight", + ] as const); + + expect(getLegalMovesForPiece(session, pawn)).toEqual([]); + expect(getLegalMovesForPiece(session, knight)).toEqual([]); + expect(getLegalMovesForPiece(session, bishop)).toHaveLength(1); + expect(getLegalMovesForPiece(session, king)).toHaveLength(1); + }); + + // ─── Composition ────────────────────────────────────────────────────────── + + it("both flags ANDed: BlockAllExceptKing=true + BlockedPieceTypes=['king'] blocks even the king", () => { + // The composition contract: a piece moves iff it passes BOTH + // gates. BlockAllExceptKing alone would allow the king; + // explicitly blocking "king" via BlockedPieceTypes overrides + // that allowance. + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + const king = insertPiece(session, 2, "king", "white", 4); + session.insert(GAME_ENTITY, "BlockAllExceptKing", true); + session.insert(GAME_ENTITY, "BlockedPieceTypes", ["king"] as const); + + expect(getLegalMovesForPiece(session, pawn)).toEqual([]); + expect(getLegalMovesForPiece(session, king)).toEqual([]); + }); + + it("BlockAllExceptKing=true + BlockedPieceTypes=['pawn']: still only king moves", () => { + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + const knight = insertPiece(session, 2, "knight", "white", 1); + const king = insertPiece(session, 3, "king", "white", 4); + session.insert(GAME_ENTITY, "BlockAllExceptKing", true); + session.insert(GAME_ENTITY, "BlockedPieceTypes", ["pawn"] as const); + + expect(getLegalMovesForPiece(session, pawn)).toEqual([]); + expect(getLegalMovesForPiece(session, knight)).toEqual([]); // BlockAll + expect(getLegalMovesForPiece(session, king)).toHaveLength(1); + }); + + // ─── Defensive paths ────────────────────────────────────────────────────── + + it("entity without a PieceType fact returns []", () => { + const session = new Session({ autoFire: false }); + expect(getLegalMovesForPiece(session, mkId(99))).toEqual([]); + }); + + it("malformed BlockedPieceTypes (non-array) is ignored, not thrown", () => { + // Defensive: a corrupt session restore could surface a non-array + // value here. The filter falls back to "no block" rather than + // crashing the move-gen pipeline. + const session = new Session({ autoFire: false }); + const pawn = insertPiece(session, 1, "pawn", "white", 8); + // Cast through unknown to bypass the schema's typed-insert guard + // — we are deliberately simulating a bad-shape fact. + session.insert( + GAME_ENTITY, + "BlockedPieceTypes", + "not-an-array" as unknown as readonly PieceType[], + ); + + expect(getLegalMovesForPiece(session, pawn)).toHaveLength(1); + }); +}); diff --git a/packages/chess/src/rules/turn.moveclass.test.ts b/packages/chess/src/rules/turn.moveclass.test.ts new file mode 100644 index 0000000..9c5989d --- /dev/null +++ b/packages/chess/src/rules/turn.moveclass.test.ts @@ -0,0 +1,187 @@ +/** + * T77 — MoveClassRestriction post-filter tests. + * + * The `must-class` primitive (T38) seeds a {@link MoveClassRestrictionValue} + * on `GAME_ENTITY` to constrain the next-generated moves. This file + * locks the post-filter behaviour inside `getLegalMovesForPiece`: + * + * - No restriction → all native moves pass through unchanged. + * - `class: "capture"` → only capture moves survive. + * - `class: "advance"` → only non-capture moves survive. + * - `class: "move-to" + square` → only moves whose `to === square` survive. + * + * V1 semantic note (locked here): the restriction is applied + * UNCONDITIONALLY. "Must capture if POSSIBLE" (i.e., relax to all + * moves when no capture exists) is intentionally NOT implemented — + * the descriptor that seeds the restriction is responsible for + * setting/clearing it under the right conditions. See T77's spec + * docstring in turn.ts. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { + registerMoveGenerator, + clearMoveGeneratorRegistry, + getLegalMovesForPiece, + applyMoveClassRestriction, +} from "./turn.js"; +import { + GAME_ENTITY, + type PieceColor, + type PieceType, + type MoveClassRestrictionValue, +} from "../schema.js"; +import type { LegalMove } from "./types.js"; + +const mkId = (n: number) => n as EntityId; + +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); + return eid; +} + +/** + * Stub knight generator that emits a fixed advance to square 18 and a + * fixed capture at 28 (when an enemy is present there). Lets us + * deterministically craft a mixed move list without reaching into the + * real knight-rule module. + */ +function registerStubGenerator(): void { + registerMoveGenerator("knight", (session, pieceId) => { + const fromRaw = session.get(pieceId, "Position"); + if (fromRaw === undefined) return []; + const from = fromRaw as number; + const moves: LegalMove[] = [ + { pieceId, from, to: 18, isCapture: false }, + ]; + // Capture iff there's any piece at 28 that isn't us. + const facts = session.allFacts(); + const occupant = facts.find( + (f) => f.attr === "Position" && f.value === 28 && f.id !== pieceId, + ); + if (occupant !== undefined) { + moves.push({ pieceId, from, to: 28, isCapture: true }); + } + // Extra advance to square 35 (used by move-to filter test). + moves.push({ pieceId, from, to: 35, isCapture: false }); + return moves; + }); +} + +describe("T77 — MoveClassRestriction post-filter", () => { + beforeEach(() => { + clearMoveGeneratorRegistry(); + registerStubGenerator(); + }); + + it("no restriction set → all candidate moves pass through", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); + insertPiece(session, 2, "pawn", "black", 28); // enables capture + const moves = getLegalMovesForPiece(session, knight); + // Stub emits 3 candidates (advance@18, capture@28, advance@35). + expect(moves).toHaveLength(3); + expect(moves.map((m) => m.to).sort((a, b) => a - b)).toEqual([18, 28, 35]); + }); + + it("class: 'capture' → only capture moves survive", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); + insertPiece(session, 2, "pawn", "black", 28); + const restriction: MoveClassRestrictionValue = { + class: "capture", + descriptorId: "test:must-capture", + }; + session.insert(GAME_ENTITY, "MoveClassRestriction", restriction); + + const moves = getLegalMovesForPiece(session, knight); + expect(moves).toHaveLength(1); + expect(moves[0]!.to).toBe(28); + expect(moves[0]!.isCapture).toBe(true); + }); + + it("class: 'advance' → only non-capture moves survive", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); + insertPiece(session, 2, "pawn", "black", 28); + const restriction: MoveClassRestrictionValue = { + class: "advance", + descriptorId: "test:must-advance", + }; + session.insert(GAME_ENTITY, "MoveClassRestriction", restriction); + + const moves = getLegalMovesForPiece(session, knight); + expect(moves.every((m) => !m.isCapture)).toBe(true); + expect(moves.map((m) => m.to).sort((a, b) => a - b)).toEqual([18, 35]); + }); + + it("class: 'move-to', square=28 → only moves landing on 28 survive", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); + insertPiece(session, 2, "pawn", "black", 28); + const restriction: MoveClassRestrictionValue = { + class: "move-to", + square: 28, + descriptorId: "test:must-move-to-e4", + }; + session.insert(GAME_ENTITY, "MoveClassRestriction", restriction); + + const moves = getLegalMovesForPiece(session, knight); + expect(moves).toHaveLength(1); + expect(moves[0]!.to).toBe(28); + }); + + it("V1 semantic: 'must capture' with no captures available → empty list (NOT relaxed)", () => { + // Locks the spec note: the restriction is unconditional. There is + // no enemy at 28, so the stub emits two non-capture moves; the + // capture filter then strips them ALL — the player has no legal + // moves. The descriptor that seeded the restriction would need to + // detect "no captures possible" and clear the fact itself; the + // engine does NOT auto-relax. + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); + const restriction: MoveClassRestrictionValue = { + class: "capture", + descriptorId: "test:must-capture", + }; + session.insert(GAME_ENTITY, "MoveClassRestriction", restriction); + + const moves = getLegalMovesForPiece(session, knight); + expect(moves).toHaveLength(0); + }); + + it("null restriction is treated identically to 'absent' (round-trip with the schema's nullable type)", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); + insertPiece(session, 2, "pawn", "black", 28); + session.insert(GAME_ENTITY, "MoveClassRestriction", null); + + const moves = getLegalMovesForPiece(session, knight); + expect(moves).toHaveLength(3); + }); + + it("applyMoveClassRestriction (helper) is a pure filter over the list", () => { + const session = new Session({ autoFire: false }); + const restriction: MoveClassRestrictionValue = { + class: "capture", + descriptorId: "t", + }; + session.insert(GAME_ENTITY, "MoveClassRestriction", restriction); + const moves: LegalMove[] = [ + { pieceId: mkId(1), from: 0, to: 1, isCapture: false }, + { pieceId: mkId(1), from: 0, to: 2, isCapture: true }, + ]; + expect(applyMoveClassRestriction(session, moves)).toEqual([ + { pieceId: mkId(1), from: 0, to: 2, isCapture: true }, + ]); + }); +}); diff --git a/packages/chess/src/rules/turn.movesas.test.ts b/packages/chess/src/rules/turn.movesas.test.ts new file mode 100644 index 0000000..bf31f82 --- /dev/null +++ b/packages/chess/src/rules/turn.movesas.test.ts @@ -0,0 +1,253 @@ +/** + * T74 — MovesAs / MovesAlsoAs dispatcher integration tests. + * + * Verifies that the per-piece dispatcher in `turn.ts` consumes the + * Wave-12 movement-replacement attrs: + * + * - `MovesAs` — substitutes the entire pattern. + * - `MovesAlsoAs` — additive on top of the (possibly substituted) base. + * + * The tests register the real per-piece generators (knight, bishop, rook + * from `rules/`) into the in-module `moveGeneratorRegistry` so the + * dispatcher has something to dispatch to. They place a single piece on + * an otherwise-empty board so the move sets are deterministic and easy + * to enumerate by hand. + * + * Coordinate convention (matches schema.ts §Square): `square = rank * 8 + * + file`. e4 = rank 3 (0-indexed), file 4 → 28. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { + registerMoveGenerator, + clearMoveGeneratorRegistry, + getLegalMovesForPiece, + getLegalMovesAsType, +} from "./turn.js"; +import { getLegalKnightMoves } from "./knight.js"; +import { + getLegalBishopMoves, + getLegalRookMoves, + getLegalQueenMoves, +} from "./sliding.js"; +import type { PieceColor, PieceType } from "../schema.js"; +import type { LegalMove } from "./types.js"; + +const mkId = (n: number) => n as EntityId; + +/** Insert a minimal piece (PieceType + Color + Position + HasMoved). */ +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; +} + +/** Sort a move list by `to` for stable equality assertions. */ +function targetsOf(moves: LegalMove[]): number[] { + return moves.map((m) => m.to).sort((a, b) => a - b); +} + +// ────────────────────────────────────────────────────────────────────────── +// Reference move sets — computed once for an empty board with the piece +// on e4 (square 28). Captured by hand from the candidate-square helpers +// in `primitives.ts` to keep tests independent from any individual +// generator's internal layout. +// +// e4 = rank 3, file 4. All eight knight L-shapes fit inside the board. +// ────────────────────────────────────────────────────────────────────────── + +/** Knight L-shapes from e4 — 8 squares. */ +const KNIGHT_FROM_E4: ReadonlyArray = [ + 11, // f2 + 13, // d2 (file 3 + rank 1*8) — wait: rank*8 + file. d2 = 1*8+3 = 11; f2 = 1*8+5 = 13. + 18, // c3 = 2*8+2 + 22, // g3 = 2*8+6 + 34, // c5 = 4*8+2 + 38, // g5 = 4*8+6 + 43, // d6 = 5*8+3 + 45, // f6 = 5*8+5 +].slice().sort((a, b) => a - b); + +/** + * Bishop diagonals from e4 (empty board). Four rays — each running until + * a board edge. + * + * NE: f5(37), g6(46), h7(55) + * NW: d5(35), c6(42), b7(49), a8(56) + * SE: f3(21), g2(14), h1(7) + * SW: d3(19), c2(10), b1(1) + */ +const BISHOP_FROM_E4: ReadonlyArray = [ + 1, 7, 10, 14, 19, 21, 35, 37, 42, 46, 49, 55, 56, +].slice().sort((a, b) => a - b); + +/** + * Rook orthogonals from e4 (empty board). Four rays. + * + * N: e5(36), e6(44), e7(52), e8(60) + * S: e3(20), e2(12), e1(4) + * E: f4(29), g4(30), h4(31) + * W: d4(27), c4(26), b4(25), a4(24) + */ +const ROOK_FROM_E4: ReadonlyArray = [ + 4, 12, 20, 24, 25, 26, 27, 29, 30, 31, 36, 44, 52, 60, +].slice().sort((a, b) => a - b); + +/** Queen = bishop ∪ rook. */ +const QUEEN_FROM_E4: ReadonlyArray = [ + ...new Set([...BISHOP_FROM_E4, ...ROOK_FROM_E4]), +].sort((a, b) => a - b); + +// ────────────────────────────────────────────────────────────────────────── + +describe("getLegalMovesForPiece — T74 MovesAs / MovesAlsoAs", () => { + beforeEach(() => { + clearMoveGeneratorRegistry(); + registerMoveGenerator("knight", getLegalKnightMoves); + registerMoveGenerator("bishop", getLegalBishopMoves); + registerMoveGenerator("rook", getLegalRookMoves); + registerMoveGenerator("queen", getLegalQueenMoves); + }); + + it("native dispatch — knight on e4 returns the 8 standard L-shapes", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); // e4 + + const moves = getLegalMovesForPiece(session, knight); + + expect(moves).toHaveLength(8); + expect(targetsOf(moves)).toEqual(KNIGHT_FROM_E4); + // Every move should report the correct piece id and source. + for (const m of moves) { + expect(m.pieceId).toBe(knight); + expect(m.from).toBe(28); + expect(m.isCapture).toBe(false); + } + }); + + it("MovesAs='bishop' substitutes the entire pattern — knight slides diagonally, no L-shapes", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAs", "bishop"); + + const moves = getLegalMovesForPiece(session, knight); + + // Native knight L-shapes are GONE; bishop diagonals are present. + expect(targetsOf(moves)).toEqual(BISHOP_FROM_E4); + // Sanity: none of the knight L-targets overlap the bishop diagonals + // from e4, so an intersection check pins down "no L-shapes leaked". + const bishopSet = new Set(BISHOP_FROM_E4); + for (const sq of KNIGHT_FROM_E4) { + expect(bishopSet.has(sq)).toBe(false); + } + }); + + it("MovesAs='bishop' + MovesAlsoAs='rook' = queen-like move set", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAs", "bishop"); + session.insert(knight, "MovesAlsoAs", "rook"); + + const moves = getLegalMovesForPiece(session, knight); + + expect(targetsOf(moves)).toEqual(QUEEN_FROM_E4); + // Cross-check: ought to equal the registered queen generator's own + // output for the same entity (since queen = bishop ∪ rook). + const queenMoves = getLegalMovesAsType(session, knight, "queen"); + expect(targetsOf(moves)).toEqual(targetsOf(queenMoves)); + }); + + it("MovesAlsoAs alone (no MovesAs) = native ∪ also-as", () => { + // Native = knight; also = bishop. Result: deduped union = 8 + 13 = 21 + // squares (bishop and knight from e4 are disjoint). + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAlsoAs", "bishop"); + + const moves = getLegalMovesForPiece(session, knight); + + const expected = [...new Set([...KNIGHT_FROM_E4, ...BISHOP_FROM_E4])].sort( + (a, b) => a - b, + ); + expect(targetsOf(moves)).toEqual(expected); + expect(moves).toHaveLength(21); + }); + + it("MovesAs='knight' + MovesAlsoAs='knight' dedupes to a single knight set", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAs", "knight"); + session.insert(knight, "MovesAlsoAs", "knight"); + + const moves = getLegalMovesForPiece(session, knight); + + expect(moves).toHaveLength(8); + expect(targetsOf(moves)).toEqual(KNIGHT_FROM_E4); + }); + + it("MovesAlsoAs equal to native PieceType is a no-op (no duplicates)", () => { + // Native knight + MovesAlsoAs=knight should NOT double the set. + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAlsoAs", "knight"); + + const moves = getLegalMovesForPiece(session, knight); + + expect(moves).toHaveLength(8); + expect(targetsOf(moves)).toEqual(KNIGHT_FROM_E4); + }); + + it("MovesAs and MovesAlsoAs are disjoint piece types — counts add cleanly", () => { + // bishop ∪ rook from e4 on an empty board: 13 + 14 = 27 squares. + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAs", "bishop"); + session.insert(knight, "MovesAlsoAs", "rook"); + + const moves = getLegalMovesForPiece(session, knight); + + expect(moves).toHaveLength(BISHOP_FROM_E4.length + ROOK_FROM_E4.length); + // No diagonal-orthogonal overlap from e4 on an empty board. + expect(moves).toHaveLength(27); + }); + + it("getLegalMovesAsType ignores the piece's actual PieceType", () => { + // A knight, asked to move 'as bishop', should produce bishop moves. + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + + const asBishop = getLegalMovesAsType(session, knight, "bishop"); + const asRook = getLegalMovesAsType(session, knight, "rook"); + + expect(targetsOf(asBishop)).toEqual(BISHOP_FROM_E4); + expect(targetsOf(asRook)).toEqual(ROOK_FROM_E4); + }); + + it("getLegalMovesAsType returns [] when the asType has no registered generator", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + + // 'pawn' is intentionally NOT registered in this describe block. + expect(getLegalMovesAsType(session, knight, "pawn")).toEqual([]); + }); + + it("MovesAs pointing to an unregistered type yields [] (with no MovesAlsoAs fallback)", () => { + // Substitution is total: when the substituted type has no generator, + // the piece contributes nothing — the native pattern is NOT used as + // a fallback (that would silently mask configuration bugs). + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); + session.insert(knight, "MovesAs", "pawn"); // pawn unregistered + + expect(getLegalMovesForPiece(session, knight)).toEqual([]); + }); +}); diff --git a/packages/chess/src/rules/turn.ts b/packages/chess/src/rules/turn.ts index 5b60527..7681d33 100644 --- a/packages/chess/src/rules/turn.ts +++ b/packages/chess/src/rules/turn.ts @@ -32,7 +32,12 @@ * - check filtering → P2.18 */ import type { Session, EntityId } from "@paratype/rete"; -import type { PieceColor, PieceType, LastCaptureSnapshotValue } from "../schema.js"; +import type { + PieceColor, + PieceType, + LastCaptureSnapshotValue, + MoveClassRestrictionValue, +} from "../schema.js"; import { GAME_ENTITY } from "../schema.js"; import type { LegalMove } from "./types.js"; import { getPieceColor, getPieceAt } from "./board-queries.js"; @@ -70,10 +75,191 @@ export function clearMoveGeneratorRegistry(): void { // See capture.ts for the "not game-level" rationale. import { isPieceAttr } from "./capture.js"; +/** + * Invoke the registered move generator for `asType` against `pieceId`, + * regardless of the piece's actual `PieceType` fact. Returns an empty + * array when no generator is registered for `asType`. + * + * T74 — used by {@link getLegalMovesForPiece} to honor the `MovesAs` / + * `MovesAlsoAs` overrides without per-generator awareness. Safe because + * every registered generator reads `Position` and `Color` from the + * session via `pieceId` — they do NOT consult the piece's `PieceType` + * fact — so we can ask the bishop generator to produce moves for a + * knight entity. + * + * NOTE: this helper deliberately does NOT apply the T76 game-level + * filters or the T77 `MoveClassRestriction` post-filter. Those wrap + * around it inside {@link getLegalMovesForPiece}. + */ +export function getLegalMovesAsType( + session: Session, + pieceId: EntityId, + asType: PieceType, +): LegalMove[] { + const generator = moveGeneratorRegistry.get(asType); + if (generator === undefined) return []; + return generator(session, pieceId); +} + +/** + * Dedup move list on `from-to-isCapture` — preserves first occurrence. + * `pieceId` is intentionally excluded from the key because every move in + * a single-piece list shares the same id by construction. + * + * T74 — used to collapse overlap between the (possibly substituted) base + * set and the additive `MovesAlsoAs` set. Example: a piece with + * `MovesAs="knight"` + `MovesAlsoAs="knight"` reports the standard + * knight set once, not twice. + */ +function dedupeLegalMoves(moves: LegalMove[]): LegalMove[] { + const seen = new Set(); + const out: LegalMove[] = []; + for (const m of moves) { + const key = `${m.from}-${m.to}-${m.isCapture ? "1" : "0"}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(m); + } + return out; +} + +/** + * Get every legal move available to a single piece given the current + * board state. + * + * This is the per-piece dispatcher. Pipeline (in order): + * + * 1. T76 game-level gates (`BlockAllExceptKing`, `BlockedPieceTypes`) + * — early-exit to `[]` if the piece is paralysed. + * 2. T74 movement-substitution (`MovesAs` / `MovesAlsoAs`) — choose + * which template generator(s) to invoke. `MovesAs` substitutes + * the entire pattern; `MovesAlsoAs` is additive (deduped union). + * 3. T77 `MoveClassRestriction` post-filter — narrows the candidate + * set to a single move class (capture / advance / move-to). + * + * Substitution semantics (T74): + * - `MovesAs` — substitutes the entire pattern. The piece moves + * like `MovesAs` *instead of* its native `PieceType`. + * - `MovesAlsoAs` — additive on top of the (possibly substituted) + * base. Final set is the deduped union, keyed on + * `from-to-isCapture`. + * + * Returns `[]` (not undefined) for any of: + * - piece has no `PieceType` fact (defensive — unknown entity); + * - GAME_ENTITY has `BlockAllExceptKing = true` and the piece is + * not a king (T8 + T76); + * - GAME_ENTITY has `BlockedPieceTypes` containing the piece's + * native type (T39 + T76) — note this composes ON TOP of + * `BlockAllExceptKing`: blocking `"king"` via `BlockedPieceTypes` + * blocks the king regardless of the all-except-king flag. The + * filter intentionally gates by *native* PieceType, not the + * substituted template — a knight with `MovesAs="bishop"` is + * still a knight for paralysis purposes; + * - the effective movement type(s) have no registered generator. + */ +export function getLegalMovesForPiece( + session: Session, + pieceId: EntityId, +): LegalMove[] { + const pieceTypeRaw = session.get(pieceId, "PieceType"); + if (pieceTypeRaw === undefined) return []; + const pieceType = pieceTypeRaw as PieceType; + + // ─── T76 game-level filters ───────────────────────────────────────────── + // These run BEFORE any movement-template substitution (T74) so a + // blocked piece short-circuits to `[]` without paying the cost of + // the substitution machinery — and so substitution cannot + // accidentally bypass a paralysis flag. + + // BlockAllExceptKing: when true on GAME_ENTITY, only kings move. + if ( + session.get(GAME_ENTITY, "BlockAllExceptKing") === true && + pieceType !== "king" + ) { + return []; + } + + // BlockedPieceTypes: deduped list of forbidden mover types. + // Composes on top of BlockAllExceptKing — including `"king"` here + // blocks the king even if `BlockAllExceptKing` would otherwise + // permit it (set-intersection semantics: a piece moves iff it + // passes BOTH gates). + const blockedRaw = session.get(GAME_ENTITY, "BlockedPieceTypes"); + if (Array.isArray(blockedRaw)) { + const blocked = blockedRaw as readonly PieceType[]; + if (blocked.includes(pieceType)) return []; + } + + // ─── T74 movement-substitution + native dispatch ──────────────────────── + // `MovesAs` substitutes the entire pattern; `MovesAlsoAs` is additive. + // We compute the deduped union of the (substituted-or-native) base + // set and the also-as set. When `MovesAlsoAs` would simply duplicate + // the effective base type we skip the second invocation — the dedup + // would erase the duplicates anyway, but skipping avoids the wasted + // generator call. + const movesAs = session.get(pieceId, "MovesAs") as PieceType | undefined; + const effectiveType: PieceType = movesAs ?? pieceType; + + let moves: LegalMove[] = getLegalMovesAsType( + session, + pieceId, + effectiveType, + ); + + const movesAlsoAs = session.get(pieceId, "MovesAlsoAs") as + | PieceType + | undefined; + if (movesAlsoAs !== undefined && movesAlsoAs !== effectiveType) { + const additional = getLegalMovesAsType(session, pieceId, movesAlsoAs); + moves = dedupeLegalMoves([...moves, ...additional]); + } + + // ─── T77 — MoveClassRestriction post-filter ───────────────────────────── + // Game-level fact (set by the `must-class` primitive) that + // constrains the player's NEXT move to a specific class. We apply + // this AFTER every per-piece generator + T74/T76 filters so the + // restriction sees the final candidate set rather than a partial + // pre-filter pool. + // + // V1 semantics (intentionally simple): the restriction is a HARD + // unconditional filter applied to every piece's legal moves. If the + // restriction is "must capture" and a piece has no captures, that + // piece returns []. If NO piece in the player's army has a capture, + // the player has no legal moves — the descriptor that seeded the + // restriction is responsible for clearing it (or seeding it only + // when at least one capture exists). The "must capture if POSSIBLE" + // policy (i.e., relax to all moves when no capture exists anywhere) + // is a higher-level decision left to the descriptor author. + return applyMoveClassRestriction(session, moves); +} + +/** + * T77 — apply the {@link MoveClassRestrictionValue} on `GAME_ENTITY` + * (if any) to a candidate move list. Pure helper; exported for tests + * that want to verify the filter in isolation. When the fact is + * absent or `null`, returns `moves` unchanged. + */ +export function applyMoveClassRestriction( + session: Session, + moves: LegalMove[], +): LegalMove[] { + const restrictionRaw = session.get(GAME_ENTITY, "MoveClassRestriction"); + if (restrictionRaw === undefined || restrictionRaw === null) return moves; + const restriction = restrictionRaw as MoveClassRestrictionValue; + + return moves.filter((m) => { + if (restriction.class === "capture") return m.isCapture; + if (restriction.class === "advance") return !m.isCapture; + if (restriction.class === "move-to") return m.to === restriction.square; + return true; + }); +} + /** * 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). + * nothing (see module docstring). Filters and dispatch are delegated + * per-piece to {@link getLegalMovesForPiece}. */ export function getLegalMovesForColor( session: Session, @@ -92,10 +278,7 @@ export function getLegalMovesForColor( 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)); + moves.push(...getLegalMovesForPiece(session, pieceId)); } return moves; @@ -139,6 +322,37 @@ export function isLegalMove(session: Session, move: LegalMove): boolean { * boundary (e.g. network handler) before calling. */ export function applyMove(session: Session, move: LegalMove): void { + // ─── T77 — pawn-pushes-pieces variant ──────────────────────────────────── + // A push is mutually exclusive with a capture: instead of retracting + // the target, we shove it forward by one rank. The pawn ends on + // `move.to` (the square the target previously occupied). The target + // ends on `move.pushedTo`. This branch returns early after handling + // the move + game-level bookkeeping so the FIDE capture/move logic + // below is bypassed cleanly. + if (move.isPawnPush === true && move.pushedPieceId !== undefined && move.pushedTo !== undefined) { + // Move target piece forward. + session.insert(move.pushedPieceId, "Position", move.pushedTo); + // Move pawn to capSq. + session.insert(move.pieceId, "Position", move.to); + session.insert(move.pieceId, "HasMoved", true); + + // Game-level bookkeeping (mirrors the FIDE branch below). + const currentTurnPush = (session.get(GAME_ENTITY, "Turn") ?? "white") as PieceColor; + const nextTurnPush: PieceColor = currentTurnPush === "white" ? "black" : "white"; + session.insert(GAME_ENTITY, "Turn", nextTurnPush); + + const halfmovePush = + ((session.get(GAME_ENTITY, "HalfmoveClock") as number | undefined) ?? 0) + 1; + session.insert(GAME_ENTITY, "HalfmoveClock", halfmovePush); + + if (currentTurnPush === "black") { + const fullmovePush = + ((session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? 1) + 1; + session.insert(GAME_ENTITY, "FullmoveNumber", fullmovePush); + } + return; + } + // ─── 1. Capture ────────────────────────────────────────────────────────── if (move.isCapture) { const capturedId = getPieceAt(session, move.to); diff --git a/packages/chess/src/rules/types.ts b/packages/chess/src/rules/types.ts index c4c1501..cc29558 100644 --- a/packages/chess/src/rules/types.ts +++ b/packages/chess/src/rules/types.ts @@ -21,4 +21,14 @@ export interface LegalMove { readonly isCapture: boolean; /** Set for pawn moves that reach the last rank (populated in P2.17). */ readonly promoteTo?: PieceType; + /** + * T77 — pawn-pushes-pieces variant. When `true`, the move's `to` + * square holds the (formerly enemy) target piece which is shoved one + * step further forward; the pawn ends on `to` (not capturing). + * `pushedPieceId` and `pushedTo` carry the shove target. Mutually + * exclusive with `isCapture` (a push never captures). + */ + readonly isPawnPush?: boolean; + readonly pushedPieceId?: EntityId; + readonly pushedTo?: Square; }