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:
parent
1402c7094b
commit
29a5ecfd3f
2 changed files with 458 additions and 0 deletions
261
packages/chess/src/modifiers/validate.test.ts
Normal file
261
packages/chess/src/modifiers/validate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
197
packages/chess/src/modifiers/validate.ts
Normal file
197
packages/chess/src/modifiers/validate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue