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.
This commit is contained in:
parent
48a15a6d57
commit
db55f24ec8
12 changed files with 1453 additions and 23 deletions
|
|
@ -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"
|
||||
|
|
|
|||
110
packages/chess/src/rules/king.extrareach.test.ts
Normal file
110
packages/chess/src/rules/king.extrareach.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
178
packages/chess/src/rules/pawn.push.test.ts
Normal file
178
packages/chess/src/rules/pawn.push.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
packages/chess/src/rules/sliding.slidemax.test.ts
Normal file
126
packages/chess/src/rules/sliding.slidemax.test.ts
Normal file
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
259
packages/chess/src/rules/turn.blockall.test.ts
Normal file
259
packages/chess/src/rules/turn.blockall.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
187
packages/chess/src/rules/turn.moveclass.test.ts
Normal file
187
packages/chess/src/rules/turn.moveclass.test.ts
Normal file
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
253
packages/chess/src/rules/turn.movesas.test.ts
Normal file
253
packages/chess/src/rules/turn.movesas.test.ts
Normal file
|
|
@ -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<number> = [
|
||||
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<number> = [
|
||||
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<number> = [
|
||||
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<number> = [
|
||||
...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<number>(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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue