feat(engine): promotion-override modifier descriptor
- Add PromotionOverride descriptor (queen/rook/bishop/knight/disabled) - getPromotionMoves: returns [] when override='disabled'; single-type moves when override is a piece type - applyPromotion: uses override value instead of promoteTo arg when set - 24 tests: descriptor registry, describe(), apply(), valueSchema, getPromotionMoves integration (6 scenarios), applyPromotion integration
This commit is contained in:
parent
99a091509d
commit
ef93eb6101
3 changed files with 297 additions and 7 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof promotionTargetSchema>;
|
||||
|
||||
export const PROMOTION_OVERRIDE_DESCRIPTOR: ModifierDescriptor<Value> = {
|
||||
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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue