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:
Joey Yakimowich-Payne 2026-04-21 09:03:26 -06:00
commit c0c32359ec
No known key found for this signature in database
9 changed files with 121 additions and 7 deletions

View file

@ -38,7 +38,15 @@ PRESET_REGISTRY.register({
name: "First Blood", name: "First Blood",
description: description:
"The first player to capture any enemy piece wins the game immediately. Makes every piece precious.", "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: [], requires: [],
onBeforeCapture({ engine, attacker }) { onBeforeCapture({ engine, attacker }) {

View file

@ -58,7 +58,12 @@ PRESET_REGISTRY.register({
name: "Perpetual Sprint", name: "Perpetual Sprint",
description: description:
"Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).", "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: [], requires: [],
getExtraMoves: getDoubleSprintMove, getExtraMoves: getDoubleSprintMove,
}); });

View file

@ -152,6 +152,10 @@ PRESET_REGISTRY.register({
"suicide-chess", "suicide-chess",
"capture-all", "capture-all",
"first-promotion-wins", "first-promotion-wins",
// Reciprocal: every terminal-state preset blocks composition
// with us.
"capture-to-win",
"last-piece-standing",
], ],
requires: [], requires: [],

View file

@ -50,7 +50,15 @@ PRESET_REGISTRY.register({
name: "Knightmate", name: "Knightmate",
description: description:
"Knights are royal instead of kings. Mate the last knight to win; kings are regular pieces.", "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: [], requires: [],
getRoyalPieces({ engine, color }): readonly EntityId[] { getRoyalPieces({ engine, color }): readonly EntityId[] {

View file

@ -33,7 +33,15 @@ PRESET_REGISTRY.register({
name: "Annihilation", name: "Annihilation",
description: description:
"Checkmate is disabled. The player who captures ALL enemy pieces (king included) wins. Every capture counts.", "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: [], requires: [],
onCheckGameResult({ engine }): GameResult | undefined { onCheckGameResult({ engine }): GameResult | undefined {

View file

@ -44,7 +44,12 @@ PRESET_REGISTRY.register({
name: "Slanting Pawns", name: "Slanting Pawns",
description: description:
"Pawns may move one square diagonally forward to an empty square without capturing.", "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: [], requires: [],
getExtraMoves: getDiagonalQuietMoves, getExtraMoves: getDiagonalQuietMoves,
}); });

View file

@ -44,7 +44,12 @@ PRESET_REGISTRY.register({
name: "Backward-Marching Pawns", name: "Backward-Marching Pawns",
description: description:
"Pawns may also move 1 square straight backward to an empty square (no capture).", "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: [], requires: [],
getExtraMoves: getPawnBackwardMove, getExtraMoves: getPawnBackwardMove,
}); });

View file

@ -107,6 +107,70 @@ describe("Incompatibility declarations", () => {
expect(a.incompatibleWith).not.toContain("explosive-rook"); expect(a.incompatibleWith).not.toContain("explosive-rook");
expect(b.incompatibleWith).not.toContain("piece-hp"); 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", () => { describe("Requires declarations", () => {

View file

@ -206,7 +206,14 @@ PRESET_REGISTRY.register({
name: "Cylindrical Board", name: "Cylindrical Board",
description: 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.", "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: [], requires: [],
getExtraMoves: (engine, pieceId) => { getExtraMoves: (engine, pieceId) => {
const facts = engine.session.allFacts(); const facts = engine.session.allFacts();