diff --git a/packages/chess/src/modifiers/validate.test.ts b/packages/chess/src/modifiers/validate.test.ts new file mode 100644 index 0000000..dfece56 --- /dev/null +++ b/packages/chess/src/modifiers/validate.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { validateProfile } from "./validate.js"; +import { CLASSIC_LAYOUT } from "../layouts/classic.js"; +import { EMPTY_LAYOUT } from "../layouts/empty.js"; +import type { ModifierProfile, TypeModifier } from "./types.js"; +import type { StartingLayout } from "../layouts/types.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function emptyProfile(overrides: Partial = {}): ModifierProfile { + return { + id: "test", + name: "Test Profile", + description: "", + perType: [], + perInstance: [], + version: 1, + source: "custom", + ...overrides, + }; +} + +/** Minimal layout with one king of each color and nothing else. */ +const KINGS_ONLY_LAYOUT: StartingLayout = { + id: "kings-only", + name: "Kings Only", + description: "Two kings for validator tests.", + pieces: [ + { type: "king", color: "white", square: 4 }, // e1 + { type: "king", color: "black", square: 60 }, // e8 + ], + source: "custom", +}; + +/** Layout without any white king. */ +const NO_WHITE_KING_LAYOUT: StartingLayout = { + ...KINGS_ONLY_LAYOUT, + id: "no-white-king", + pieces: [{ type: "king", color: "black", square: 60 }], +}; + +/** Layout without any black king. */ +const NO_BLACK_KING_LAYOUT: StartingLayout = { + ...KINGS_ONLY_LAYOUT, + id: "no-black-king", + pieces: [{ type: "king", color: "white", square: 4 }], +}; + +// ─── valid baseline ─────────────────────────────────────────────────────────── + +describe("validateProfile — valid baseline", () => { + it("returns valid=true and no errors/warnings for an empty profile on CLASSIC_LAYOUT", () => { + const result = validateProfile(emptyProfile(), CLASSIC_LAYOUT); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("returns valid=true for a benign hp-bonus per-type modifier", () => { + const profile = emptyProfile({ + perType: [{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); +}); + +// ─── E_PROFILE_NO_KING ──────────────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_NO_KING", () => { + it("errors when layout has no white king", () => { + const result = validateProfile(emptyProfile(), NO_WHITE_KING_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/white king/i); + }); + + it("errors when layout has no black king", () => { + const result = validateProfile(emptyProfile(), NO_BLACK_KING_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/black king/i); + }); + + it("emits two E_PROFILE_NO_KING errors for EMPTY_LAYOUT (no kings of either color)", () => { + const result = validateProfile(emptyProfile(), EMPTY_LAYOUT); + + expect(result.valid).toBe(false); + const kingErrors = result.errors.filter((e) => e.code === "E_PROFILE_NO_KING"); + expect(kingErrors).toHaveLength(2); + }); +}); + +// ─── E_PROFILE_INVULN_KING ─────────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_INVULN_KING", () => { + it("errors when per-type modifier grants all kings CANNOT_BE_CAPTURED", () => { + const profile = emptyProfile({ + perType: [ + // value 2 = CANNOT_BE_CAPTURED + { kind: "capture-flags", pieceType: "king", color: "white", value: 2 }, + ], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/CANNOT_BE_CAPTURED/); + }); + + it("errors when per-instance modifier gives a king CANNOT_BE_CAPTURED (white king at e1)", () => { + // White king is at e1 = square 4 in classic layout + const profile = emptyProfile({ + perInstance: [{ kind: "capture-flags", square: "e1", value: 2 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(err).toBeDefined(); + expect(err?.square).toBe("e1"); + }); + + it("does NOT error when per-instance CANNOT_BE_CAPTURED targets a non-king (rook at a1)", () => { + // White rook is at a1 = square 0 — not a king, so invuln is allowed + const profile = emptyProfile({ + perInstance: [{ kind: "capture-flags", square: "a1", value: 2 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(invulnError).toBeUndefined(); + }); + + it("does NOT error for CAN_CAPTURE_OWN (flag=1) on a king", () => { + // CAN_CAPTURE_OWN (= 1) on a king is unusual but not a deadlock + const profile = emptyProfile({ + perType: [{ kind: "capture-flags", pieceType: "king", color: "white", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(invulnError).toBeUndefined(); + }); + + it("errors when combined flags include CANNOT_BE_CAPTURED on king (value=3)", () => { + // value 3 = CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED — still has the invuln bit + const profile = emptyProfile({ + perType: [{ kind: "capture-flags", pieceType: "king", color: "both", value: 3 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + expect(result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING")).toBeDefined(); + }); +}); + +// ─── E_PROFILE_ORPHAN_INSTANCE ─────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_ORPHAN_INSTANCE (warning)", () => { + it("warns when per-instance modifier targets an empty square (d4 is empty in classic)", () => { + // d4 = rank 3, file 3 → square 27 — empty in CLASSIC_LAYOUT + const profile = emptyProfile({ + perInstance: [{ kind: "hp-bonus", square: "d4", value: 2 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + // Warning, not error — profile is still valid + expect(result.valid).toBe(true); + const warn = result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE"); + expect(warn).toBeDefined(); + expect(warn?.square).toBe("d4"); + }); + + it("does NOT warn when per-instance modifier targets an occupied square (e2 pawn)", () => { + // e2 = rank 1, file 4 → square 12 — white pawn in CLASSIC_LAYOUT + const profile = emptyProfile({ + perInstance: [{ kind: "hp-bonus", square: "e2", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE")).toBeUndefined(); + }); + + it("emits multiple orphan warnings for multiple empty-square entries", () => { + const profile = emptyProfile({ + perInstance: [ + { kind: "hp-bonus", square: "d4", value: 1 }, + { kind: "range-bonus", square: "e5", value: 1 }, + ], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + const orphans = result.warnings.filter((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE"); + expect(orphans).toHaveLength(2); + expect(orphans.map((w) => w.square).sort()).toEqual(["d4", "e5"]); + }); +}); + +// ─── E_PROFILE_ATTR_LIMIT ───────────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_ATTR_LIMIT", () => { + it("does NOT error with all 6 current modifier kinds on one piece (6 < 12)", () => { + // A white pawn gets all 6 T1 modifier kinds — well within the 12-kind limit + const perType: TypeModifier[] = [ + { kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 }, + { kind: "range-bonus", pieceType: "pawn", color: "white", value: 1 }, + { kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward"] }, + { kind: "capture-flags", pieceType: "pawn", color: "white", value: 1 }, + { kind: "promotion-override", pieceType: "pawn", color: "white", value: "queen" }, + { kind: "damage-resistance", pieceType: "pawn", color: "white", value: 0.5 }, + ]; + const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT); + + expect(result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT")).toBeUndefined(); + }); + + it("errors when a single piece accumulates more than 12 distinct modifier kinds", () => { + // Inject 13 synthetic modifier kinds via type assertion (for test only). + // In practice this requires future modifier kinds beyond the 6 T1 set. + const perType = Array.from({ length: 13 }, (_, i) => ({ + kind: `synthetic-kind-${i}` as unknown as TypeModifier["kind"], + pieceType: "pawn" as const, + color: "white" as const, + value: i, + })) satisfies TypeModifier[]; + + const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/13/); + }); +}); + +// ─── valid field integrity ──────────────────────────────────────────────────── + +describe("validateProfile — valid field", () => { + it("valid=false when any error is present", () => { + const result = validateProfile(emptyProfile(), EMPTY_LAYOUT); + expect(result.valid).toBe(false); + }); + + it("valid=true even when warnings are present", () => { + const profile = emptyProfile({ + perInstance: [{ kind: "hp-bonus", square: "d4", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/chess/src/modifiers/validate.ts b/packages/chess/src/modifiers/validate.ts new file mode 100644 index 0000000..de83747 --- /dev/null +++ b/packages/chess/src/modifiers/validate.ts @@ -0,0 +1,197 @@ +/** + * Modifier profile legality validator. + * + * Validates a ModifierProfile against a StartingLayout for game-rule legality. + * + * Errors BLOCK profile activation (server rejects the profile; editor hides + * the "Apply" CTA). Warnings are surfaced to the user but don't prevent the + * profile from being applied — the user made a deliberate choice. + * + * ## Error rules + * + * 1. E_PROFILE_NO_KING: Layout must have ≥1 king per color. + * (Profiles cannot compensate for a king-less layout.) + * + * 2. E_PROFILE_INVULN_KING: King cannot have the CANNOT_BE_CAPTURED flag. + * An invulnerable king means the game can never end — instant deadlock. + * + * 3. E_PROFILE_ATTR_LIMIT: A single piece cannot accumulate > 12 distinct + * modifier kinds. The EAV ceiling is 16 attributes per entity; 4 are + * reserved for core piece facts (PieceType, Color, Position, HasMoved), + * leaving 12 slots for modifier attributes. + * + * 4. E_PROFILE_DEADLOCK: Reserved. Requires a session simulation to detect + * mutual-invulnerability loops; deferred to a future task. + * + * ## Warning rules + * + * - E_PROFILE_ORPHAN_INSTANCE: A per-instance modifier references a square + * that has no piece in the layout. The modifier will never apply, but it is + * harmless — the user may have changed the layout after crafting the profile. + */ +import type { ModifierProfile } from "./types.js"; +import type { StartingLayout } from "../layouts/types.js"; +import { CaptureFlag } from "../schema.js"; +import { squareToAlgebraic } from "../coord.js"; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export type ValidationErrorCode = + | "E_PROFILE_NO_KING" + | "E_PROFILE_INVULN_KING" + | "E_PROFILE_DEADLOCK" + | "E_PROFILE_ATTR_LIMIT"; + +export type ValidationWarningCode = "E_PROFILE_ORPHAN_INSTANCE"; + +export interface ValidationError { + readonly code: ValidationErrorCode; + readonly message: string; + /** Algebraic square, present when the error is tied to a specific square. */ + readonly square?: string; +} + +export interface ValidationWarning { + readonly code: ValidationWarningCode; + readonly message: string; + /** Algebraic square the warning refers to (always present for ORPHAN_INSTANCE). */ + readonly square: string; +} + +export interface ValidationResult { + readonly errors: readonly ValidationError[]; + readonly warnings: readonly ValidationWarning[]; + /** `true` iff `errors` is empty — profile can be activated. */ + readonly valid: boolean; +} + +// ─── Validator ──────────────────────────────────────────────────────────────── + +export function validateProfile( + profile: ModifierProfile, + layout: StartingLayout, +): ValidationResult { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + // ── Check 1: E_PROFILE_NO_KING ───────────────────────────────────────────── + // Each color must have at least one king in the layout; without one, there + // is no royal piece to checkmate/capture and the game has no terminal state. + for (const color of ["white", "black"] as const) { + const hasKing = layout.pieces.some( + (p) => p.type === "king" && p.color === color, + ); + if (!hasKing) { + errors.push({ + code: "E_PROFILE_NO_KING", + message: `Layout has no ${color} king — the game cannot reach a terminal condition.`, + }); + } + } + + // ── Check 2: E_PROFILE_INVULN_KING ──────────────────────────────────────── + // A king with CANNOT_BE_CAPTURED (= 2) can never be taken; the game would + // loop indefinitely with no win condition. + const INVULN = CaptureFlag.CANNOT_BE_CAPTURED; + + // Per-type: modifier applies to ALL kings of the given color. + for (const tm of profile.perType) { + if ( + tm.kind === "capture-flags" && + tm.pieceType === "king" && + typeof tm.value === "number" && + (tm.value & INVULN) !== 0 + ) { + errors.push({ + code: "E_PROFILE_INVULN_KING", + message: + `Per-type modifier grants all ${tm.color} kings CANNOT_BE_CAPTURED ` + + `— the game would have no terminal condition.`, + }); + } + } + + // Per-instance: modifier applies only if the piece at that square is a king. + for (const im of profile.perInstance) { + if (im.kind !== "capture-flags") continue; + if (typeof im.value !== "number") continue; + if ((im.value & INVULN) === 0) continue; + + const piece = layout.pieces.find( + (p) => squareToAlgebraic(p.square) === im.square, + ); + if (piece?.type === "king") { + errors.push({ + code: "E_PROFILE_INVULN_KING", + message: + `Per-instance modifier grants king at ${im.square} CANNOT_BE_CAPTURED ` + + `— the game would have no terminal condition.`, + square: im.square, + }); + } + } + + // ── Check 3: E_PROFILE_ORPHAN_INSTANCE (WARNING) ────────────────────────── + // A per-instance entry that targets an empty square will never fire. + // Warning only — the user may have intentionally left the slot as a draft, + // or changed the layout after building the profile. + for (const im of profile.perInstance) { + const piece = layout.pieces.find( + (p) => squareToAlgebraic(p.square) === im.square, + ); + if (!piece) { + warnings.push({ + code: "E_PROFILE_ORPHAN_INSTANCE", + message: + `Per-instance modifier at "${im.square}" references an empty square — ` + + `the modifier will never apply.`, + square: im.square, + }); + } + } + + // ── Check 4: E_PROFILE_ATTR_LIMIT ───────────────────────────────────────── + // For each piece in the layout, count the distinct modifier kinds that + // target it (from both perType and perInstance sources). More than 12 + // would overflow the EAV attribute ceiling (16 total − 4 core = 12 slots). + for (const piece of layout.pieces) { + const sq = squareToAlgebraic(piece.square); + const kinds = new Set(); + + for (const tm of profile.perType) { + if ( + tm.pieceType === piece.type && + (tm.color === piece.color || tm.color === "both") + ) { + kinds.add(tm.kind); + } + } + + for (const im of profile.perInstance) { + if (im.square === sq) { + kinds.add(im.kind); + } + } + + if (kinds.size > 12) { + errors.push({ + code: "E_PROFILE_ATTR_LIMIT", + message: + `Piece at ${sq} has ${kinds.size} distinct modifier kinds (max 12).`, + square: sq, + }); + } + } + + // ── Check 5: E_PROFILE_DEADLOCK ──────────────────────────────────────────── + // TODO: Detect modifier combinations that create irresolvable game states + // (e.g., both colors have kings with CANNOT_BE_CAPTURED, neither side can + // win). This check requires a session simulation and is deferred; the + // per-type INVULN_KING check above catches the most common case. + + return { + errors, + warnings, + valid: errors.length === 0, + }; +}