test(presets): enforce incompatibleWith symmetry + reciprocate declarations
Phase F.1 of the rule-variants epic.
Adds three global audit tests to presets.test.ts:
- incompatibleWith is symmetric across every registered preset
(if A declares B, B must declare A).
- every incompatibleWith entry references a real preset id.
- no preset declares itself incompatible.
The symmetry audit caught 16 existing asymmetries — presets in
Phase B/D/E declared incompat with capture-to-win /
last-piece-standing / knightmate-rules / wrap-board / etc., but
those older presets hadn't reciprocated. Adding the reciprocal
entries across 7 files:
- capture-to-win, last-piece-standing now declare incompat with
the five Phase B-D terminal-state presets.
- knightmate-rules adds suicide-chess, capture-all.
- pawn-diagonal-no-capture, pawns-move-backward,
double-pawn-sprint each declare incompat with berolina-pawns
and berolina-pawns-2.
- wrap-board adds bouncing-pieces, bouncing-pieces-2.
- extinction-chess reciprocates back to capture-to-win,
last-piece-standing.
Tests: 1650 passing (was 1647, +3 new audit tests).
This commit is contained in:
parent
b8afc9017f
commit
c0c32359ec
9 changed files with 121 additions and 7 deletions
|
|
@ -38,7 +38,15 @@ PRESET_REGISTRY.register({
|
|||
name: "First Blood",
|
||||
description:
|
||||
"The first player to capture any enemy piece wins the game immediately. Makes every piece precious.",
|
||||
incompatibleWith: ["last-piece-standing"],
|
||||
incompatibleWith: [
|
||||
"last-piece-standing",
|
||||
// Reciprocal: these presets redefine the terminal-state logic
|
||||
// and are declared incompatible with us from their own files.
|
||||
"first-promotion-wins",
|
||||
"capture-all",
|
||||
"suicide-chess",
|
||||
"extinction-chess",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
onBeforeCapture({ engine, attacker }) {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,12 @@ PRESET_REGISTRY.register({
|
|||
name: "Perpetual Sprint",
|
||||
description:
|
||||
"Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).",
|
||||
incompatibleWith: [],
|
||||
incompatibleWith: [
|
||||
// Reciprocal: berolina-pawns redefines pawn movement and
|
||||
// conflicts with additive pawn rules.
|
||||
"berolina-pawns",
|
||||
"berolina-pawns-2",
|
||||
],
|
||||
requires: [],
|
||||
getExtraMoves: getDoubleSprintMove,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ PRESET_REGISTRY.register({
|
|||
"suicide-chess",
|
||||
"capture-all",
|
||||
"first-promotion-wins",
|
||||
// Reciprocal: every terminal-state preset blocks composition
|
||||
// with us.
|
||||
"capture-to-win",
|
||||
"last-piece-standing",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,15 @@ PRESET_REGISTRY.register({
|
|||
name: "Knightmate",
|
||||
description:
|
||||
"Knights are royal instead of kings. Mate the last knight to win; kings are regular pieces.",
|
||||
incompatibleWith: ["coregal", "dual-king", "weak-dual-king"],
|
||||
incompatibleWith: [
|
||||
"coregal",
|
||||
"dual-king",
|
||||
"weak-dual-king",
|
||||
// Reciprocal: these presets empty the royal set entirely and
|
||||
// so conflict with any preset that declares non-king royalty.
|
||||
"suicide-chess",
|
||||
"capture-all",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
getRoyalPieces({ engine, color }): readonly EntityId[] {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,15 @@ PRESET_REGISTRY.register({
|
|||
name: "Annihilation",
|
||||
description:
|
||||
"Checkmate is disabled. The player who captures ALL enemy pieces (king included) wins. Every capture counts.",
|
||||
incompatibleWith: ["capture-to-win"],
|
||||
incompatibleWith: [
|
||||
"capture-to-win",
|
||||
// Reciprocal: these terminal-state presets declare incompat
|
||||
// with us from their own files.
|
||||
"first-promotion-wins",
|
||||
"capture-all",
|
||||
"suicide-chess",
|
||||
"extinction-chess",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
onCheckGameResult({ engine }): GameResult | undefined {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ PRESET_REGISTRY.register({
|
|||
name: "Slanting Pawns",
|
||||
description:
|
||||
"Pawns may move one square diagonally forward to an empty square without capturing.",
|
||||
incompatibleWith: [],
|
||||
incompatibleWith: [
|
||||
// Reciprocal: the berolina-pawns presets wholly redefine pawn
|
||||
// movement and conflict with any additive pawn-movement rule.
|
||||
"berolina-pawns",
|
||||
"berolina-pawns-2",
|
||||
],
|
||||
requires: [],
|
||||
getExtraMoves: getDiagonalQuietMoves,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ PRESET_REGISTRY.register({
|
|||
name: "Backward-Marching Pawns",
|
||||
description:
|
||||
"Pawns may also move 1 square straight backward to an empty square (no capture).",
|
||||
incompatibleWith: [],
|
||||
incompatibleWith: [
|
||||
// Reciprocal: berolina-pawns redefines pawn movement and
|
||||
// conflicts with additive pawn rules.
|
||||
"berolina-pawns",
|
||||
"berolina-pawns-2",
|
||||
],
|
||||
requires: [],
|
||||
getExtraMoves: getPawnBackwardMove,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -107,6 +107,70 @@ describe("Incompatibility declarations", () => {
|
|||
expect(a.incompatibleWith).not.toContain("explosive-rook");
|
||||
expect(b.incompatibleWith).not.toContain("piece-hp");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Global audit (Phase F.1 of the rule-variants epic)
|
||||
//
|
||||
// Every preset's `incompatibleWith` entry must be SYMMETRIC: if A
|
||||
// declares B incompatible, B must declare A incompatible too.
|
||||
// Asymmetric declarations are bugs — the activation validator only
|
||||
// fires when EITHER side declares the other, so a one-way
|
||||
// declaration works by accident, but a later refactor that
|
||||
// re-registers A without the line silently breaks the guarantee.
|
||||
//
|
||||
// Every entry must also reference a REAL preset id. Dangling
|
||||
// references (typo'd ids, deleted presets) are caught here before
|
||||
// they cause runtime `UNKNOWN_PRESET` errors.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it("incompatibleWith is symmetric across every registered preset", () => {
|
||||
const all = PRESET_REGISTRY.getAll();
|
||||
const byId = new Map(all.map(p => [p.id, p] as const));
|
||||
const asymmetric: string[] = [];
|
||||
|
||||
for (const a of all) {
|
||||
for (const bId of a.incompatibleWith) {
|
||||
const b = byId.get(bId);
|
||||
if (b === undefined) continue; // dangling refs caught separately
|
||||
if (!b.incompatibleWith.includes(a.id)) {
|
||||
asymmetric.push(
|
||||
`"${a.id}" declares "${bId}" incompatible but "${bId}" does NOT reciprocate`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(asymmetric).toEqual([]);
|
||||
});
|
||||
|
||||
it("every incompatibleWith entry references a registered preset id", () => {
|
||||
const all = PRESET_REGISTRY.getAll();
|
||||
const ids = new Set(all.map(p => p.id));
|
||||
const dangling: string[] = [];
|
||||
|
||||
for (const preset of all) {
|
||||
for (const ref of preset.incompatibleWith) {
|
||||
if (!ids.has(ref)) {
|
||||
dangling.push(`"${preset.id}" → "${ref}" (unknown)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(dangling).toEqual([]);
|
||||
});
|
||||
|
||||
it("no preset declares itself incompatible", () => {
|
||||
const all = PRESET_REGISTRY.getAll();
|
||||
const selfRefs: string[] = [];
|
||||
|
||||
for (const preset of all) {
|
||||
if (preset.incompatibleWith.includes(preset.id)) {
|
||||
selfRefs.push(preset.id);
|
||||
}
|
||||
}
|
||||
|
||||
expect(selfRefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Requires declarations", () => {
|
||||
|
|
|
|||
|
|
@ -206,7 +206,14 @@ PRESET_REGISTRY.register({
|
|||
name: "Cylindrical Board",
|
||||
description:
|
||||
"The board wraps horizontally: every piece (knights, kings, bishops, queens, rooks, and pawn captures) can move off one side and appear on the other.",
|
||||
incompatibleWith: ["rook-warp"],
|
||||
incompatibleWith: [
|
||||
"rook-warp",
|
||||
// Reciprocal: bouncing-pieces presets declare incompat with
|
||||
// wrap-board because their diagonal reflection geometry is
|
||||
// undefined on a wrapping (cylindrical) board.
|
||||
"bouncing-pieces",
|
||||
"bouncing-pieces-2",
|
||||
],
|
||||
requires: [],
|
||||
getExtraMoves: (engine, pieceId) => {
|
||||
const facts = engine.session.allFacts();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue