diff --git a/packages/chess/src/modifiers/descriptors/promotion-override.test.ts b/packages/chess/src/modifiers/descriptors/promotion-override.test.ts new file mode 100644 index 0000000..b2819a0 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/promotion-override.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import "./promotion-override.js"; +import { PROMOTION_OVERRIDE_DESCRIPTOR } from "./promotion-override.js"; +import type { PieceType, PieceColor, Square } from "../../schema.js"; +import { getPromotionMoves, applyPromotion } from "../../rules/promotion.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function setupSession(): Session { + return new Session({ autoFire: false }); +} + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = id as EntityId; + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'promotion-override'", () => { + expect(MODIFIER_REGISTRY.has("promotion-override")).toBe(true); + expect(MODIFIER_REGISTRY.get("promotion-override")).toBe(PROMOTION_OVERRIDE_DESCRIPTOR); + }); + + it("has the correct id, attrName, and uiForm", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.id).toBe("promotion-override"); + expect(PROMOTION_OVERRIDE_DESCRIPTOR.attrName).toBe("PromotionOverride"); + expect(PROMOTION_OVERRIDE_DESCRIPTOR.uiForm).toBe("promotion-target"); + expect(PROMOTION_OVERRIDE_DESCRIPTOR.stackingRule).toBe("priority-wins"); + }); +}); + +// ─── describe() ────────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — describe()", () => { + it("returns 'Cannot promote' for 'disabled'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("disabled")).toBe("Cannot promote"); + }); + + it("returns 'Promotes to queen' for 'queen'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("queen")).toBe("Promotes to queen"); + }); + + it("returns 'Promotes to rook' for 'rook'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("rook")).toBe("Promotes to rook"); + }); + + it("returns 'Promotes to bishop' for 'bishop'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("bishop")).toBe("Promotes to bishop"); + }); + + it("returns 'Promotes to knight' for 'knight'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("knight")).toBe("Promotes to knight"); + }); +}); + +// ─── apply() ───────────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — apply()", () => { + it("seeds PromotionOverride='bishop' fact on piece entity", () => { + const session = new Session(); + const eid = session.nextId(); + session.insert(eid, "PieceType", "pawn"); + + PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "bishop"); + + expect(session.contains(eid, "PromotionOverride")).toBe(true); + expect(session.get(eid, "PromotionOverride")).toBe("bishop"); + }); + + it("seeds PromotionOverride='disabled' fact on piece entity", () => { + const session = new Session(); + const eid = session.nextId(); + session.insert(eid, "PieceType", "pawn"); + + PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "disabled"); + + expect(session.get(eid, "PromotionOverride")).toBe("disabled"); + }); +}); + +// ─── valueSchema ───────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — valueSchema", () => { + const schema = PROMOTION_OVERRIDE_DESCRIPTOR.valueSchema; + + it("accepts all 4 promotion piece types", () => { + expect(() => schema.parse("queen")).not.toThrow(); + expect(() => schema.parse("rook")).not.toThrow(); + expect(() => schema.parse("bishop")).not.toThrow(); + expect(() => schema.parse("knight")).not.toThrow(); + }); + + it("accepts 'disabled'", () => { + expect(() => schema.parse("disabled")).not.toThrow(); + }); + + it("rejects 'king' (not a valid promotion target)", () => { + expect(() => schema.parse("king")).toThrow(); + }); + + it("rejects 'pawn' (cannot promote to pawn)", () => { + expect(() => schema.parse("pawn")).toThrow(); + }); + + it("rejects arbitrary strings", () => { + expect(() => schema.parse("dragon")).toThrow(); + expect(() => schema.parse("")).toThrow(); + }); +}); + +// ─── Integration: getPromotionMoves ────────────────────────────────────────── + +describe("promotion-override — integration with getPromotionMoves", () => { + it("PromotionOverride='bishop': returns only bishop promotion move", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 → e8 + session.insert(pawn, "PromotionOverride", "bishop"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toHaveLength(1); + const move = moves[0]!; + expect(move.promoteTo).toBe("bishop"); + expect(move.to).toBe(60); // e8 + expect(move.isCapture).toBe(false); + }); + + it("PromotionOverride='disabled': returns empty array (pawn cannot promote)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + session.insert(pawn, "PromotionOverride", "disabled"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toEqual([]); + }); + + it("PromotionOverride='queen': returns only queen promotion move", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + session.insert(pawn, "PromotionOverride", "queen"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toHaveLength(1); + expect(moves[0]!.promoteTo).toBe("queen"); + }); + + it("PromotionOverride='disabled' also suppresses capture-promotions", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7 + insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy + session.insert(pawn, "PromotionOverride", "disabled"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toEqual([]); + }); + + it("PromotionOverride='knight': capture-promotion returns only knight captures", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7 + insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy + insertPiece(session, 3, "queen", "white", 59); // d8 — ally blocks push + session.insert(pawn, "PromotionOverride", "knight"); + + const moves = getPromotionMoves(session, pawn); + + // Only capture to e8 remains (d8 push blocked); override limits to knight + expect(moves).toHaveLength(1); + const knightMove = moves[0]!; + expect(knightMove.promoteTo).toBe("knight"); + expect(knightMove.isCapture).toBe(true); + }); + + it("no PromotionOverride: returns all 4 standard promotion variants (baseline)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toHaveLength(4); + const types = moves.map((m) => m.promoteTo).sort(); + expect(types).toEqual(["bishop", "knight", "queen", "rook"]); + }); +}); + +// ─── Integration: applyPromotion ───────────────────────────────────────────── + +describe("promotion-override — integration with applyPromotion", () => { + it("PromotionOverride='bishop': promotes to bishop regardless of promoteTo arg", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + session.insert(pawn, "PromotionOverride", "bishop"); + + applyPromotion(session, pawn, "queen"); // caller requests queen, override wins + + expect(session.get(pawn, "PieceType")).toBe("bishop"); + }); + + it("PromotionOverride='rook': promotes to rook regardless of default promoteTo", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + session.insert(pawn, "PromotionOverride", "rook"); + + applyPromotion(session, pawn); // no promoteTo arg — default is queen, override wins + + expect(session.get(pawn, "PieceType")).toBe("rook"); + }); + + it("PromotionOverride='disabled': falls back to promoteTo arg (pawn stays pawn only if not promoted)", () => { + // When override is "disabled", no promotion moves are generated so + // applyPromotion shouldn't be called. If it is called anyway, it falls + // back to the caller's promoteTo arg (normal behaviour). + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + session.insert(pawn, "PromotionOverride", "disabled"); + + applyPromotion(session, pawn, "knight"); + + expect(session.get(pawn, "PieceType")).toBe("knight"); + }); + + it("no PromotionOverride: applyPromotion uses promoteTo arg normally", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + + applyPromotion(session, pawn, "rook"); + + expect(session.get(pawn, "PieceType")).toBe("rook"); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/promotion-override.ts b/packages/chess/src/modifiers/descriptors/promotion-override.ts new file mode 100644 index 0000000..704ffb6 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/promotion-override.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; + +// Valid override: any standard promotion target, or "disabled" (no promotion). +// "king" and "pawn" are intentionally excluded — king is not a valid promotion +// target and pawn would be a no-op / non-sensical promotion. +const promotionTargetSchema = z.enum(["queen", "rook", "bishop", "knight", "disabled"]); +type Value = z.infer; + +export const PROMOTION_OVERRIDE_DESCRIPTOR: ModifierDescriptor = { + id: "promotion-override", + attrName: "PromotionOverride", + label: "Promotion Override", + valueSchema: promotionTargetSchema, + stackingRule: "priority-wins", + uiForm: "promotion-target", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "PromotionOverride", effectiveValue); + }, + describe(value: Value): string { + return value === "disabled" ? "Cannot promote" : `Promotes to ${value}`; + }, +}; + +MODIFIER_REGISTRY.register(PROMOTION_OVERRIDE_DESCRIPTOR); diff --git a/packages/chess/src/rules/promotion.ts b/packages/chess/src/rules/promotion.ts index c930652..9c81ac1 100644 --- a/packages/chess/src/rules/promotion.ts +++ b/packages/chess/src/rules/promotion.ts @@ -43,7 +43,10 @@ export function isPromotionMove(to: Square, color: PieceColor): boolean { /** * Enumerate all promotion moves available to `pieceId` from its current * square. Returns 4 LegalMove entries per reachable promotion-rank target - * (one per element of {@link PROMOTION_PIECES}). + * (one per element of {@link PROMOTION_PIECES}), unless a PromotionOverride + * modifier is active: + * - "disabled" → returns [] (pawn cannot promote) + * - a specific piece type → returns 1 entry per target (only that type) * * Does not verify that `pieceId` is a pawn — the caller is expected to * dispatch on PieceType. Returns [] if the piece has no Position/Color. @@ -53,6 +56,10 @@ export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMov const color = getPieceColor(session, pieceId); if (from === null || color === null) return []; + // Respect PromotionOverride modifier: "disabled" means no promotion moves. + const override = session.get(pieceId, "PromotionOverride") as PieceType | "disabled" | undefined; + if (override === "disabled") return []; + const candidates: { to: Square; isCapture: boolean }[] = []; // Single advance to the back rank (must be empty). @@ -71,9 +78,12 @@ export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMov if (candidates.length === 0) return []; // Fan each candidate target into one LegalMove per promotion piece type. + // If PromotionOverride specifies a piece type, offer only that single type. + const promotionChoices: readonly PieceType[] = + override !== undefined ? [override] : PROMOTION_PIECES; const moves: LegalMove[] = []; for (const { to, isCapture } of candidates) { - for (const promoteTo of PROMOTION_PIECES) { + for (const promoteTo of promotionChoices) { moves.push({ pieceId, from, to, isCapture, promoteTo }); } } @@ -85,18 +95,26 @@ export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMov * `promoteTo` (defaults to queen). Session.insert has update semantics, * so this retracts the old PieceType and inserts the new one atomically. * - * Throws if `promoteTo` is not in {@link PROMOTION_PIECES} (i.e. attempts - * to promote to pawn or king). + * If a PromotionOverride modifier is active on the piece (and is not + * "disabled"), the override value takes precedence over `promoteTo`. + * + * Throws if the final promotion target is not in {@link PROMOTION_PIECES} + * (i.e. attempts to promote to pawn or king). */ export function applyPromotion( session: Session, pieceId: EntityId, promoteTo: PieceType = "queen", ): void { - if (!PROMOTION_PIECES.includes(promoteTo)) { + // Respect PromotionOverride modifier: a non-disabled value overrides the + // caller's requested target so the piece always becomes the overridden type. + const override = session.get(pieceId, "PromotionOverride") as PieceType | "disabled" | undefined; + const target = override !== undefined && override !== "disabled" ? override : promoteTo; + + if (!PROMOTION_PIECES.includes(target)) { throw new Error( - `Invalid promotion target: ${promoteTo}. Cannot promote to king or pawn.`, + `Invalid promotion target: ${target}. Cannot promote to king or pawn.`, ); } - session.insert(pieceId, "PieceType", promoteTo); + session.insert(pieceId, "PieceType", target); }