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:
Joey Yakimowich-Payne 2026-04-26 15:05:07 -06:00
commit db55f24ec8
No known key found for this signature in database
12 changed files with 1453 additions and 23 deletions

View file

@ -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"

View 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);
});
});

View file

@ -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;
}

View 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);
});
});

View file

@ -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 });
}
}

View 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]);
});
});

View file

@ -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;
}

View 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);
});
});

View 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 },
]);
});
});

View 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([]);
});
});

View file

@ -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);

View file

@ -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;
}