feat(engine): profile legality validator

- validateProfile(profile, layout) checks 4 rules:
  - E_PROFILE_NO_KING: layout must have ≥1 king per color
  - E_PROFILE_INVULN_KING: king cannot have CANNOT_BE_CAPTURED flag (per-type and per-instance)
  - E_PROFILE_ORPHAN_INSTANCE (warning): per-instance entry targeting empty square
  - E_PROFILE_ATTR_LIMIT: >12 distinct modifier kinds on one piece
  - E_PROFILE_DEADLOCK: reserved TODO (requires session simulation)
- 17 tests covering all codes, both warning/error paths, and valid field integrity
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:33:09 -06:00
commit 29a5ecfd3f
No known key found for this signature in database
2 changed files with 458 additions and 0 deletions

View file

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

View file

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