feat(engine): PlayerAction + transferable-royalty preset (solo)
Feature 4a of post-epic-deferrals. Introduces the PlayerAction
surface — a turn-consuming event orthogonal to LegalMove — and one
preset that uses it to transfer royalty between friendly pieces
once per game per color. Solo-only for v1 (F4b will add the WS
protocol; F4c will add the UI).
Engine surface:
- New module packages/chess/src/actions.ts exports PlayerAction
(discriminated union; starts with 'transfer-royalty' kind),
PlayerActionKind, and ActionResult {ok,error,reason}.
- ChessEngine.performAction(action): ActionResult runs a parallel
pipeline to applyMove: terminal-state guard → poll every
active preset's performAction hook (first non-undefined wins)
→ handler returns ok=false => no turn consumption → handler
returns ok=true => advanceTurnAfterMutation shared helper
(factored out of applyMove) which handles HalfMovesThisTurn
increment, shouldAdvanceTurn poll, onTurnStart fire, etc.
- ActionResult error codes: NO_HANDLER, REJECTED, INVALID_TARGET,
NOT_YOUR_TURN, GAME_OVER. Stable for future UI / protocol.
PresetDef additions:
- performAction(ctx): ActionResult | undefined — first non-
undefined wins. Handlers validate + mutate state + return.
- transformRoyalPieces(ctx, current): EntityId[] — a POST-union
transform on the accumulated royal set, letting a preset
reassign rather than append. Used by transferable-royalty to
swap transferredFrom -> transferredTo.
Preset: transferable-royalty
- category 'king'; incompat with suicide-chess + capture-all
(both empty the royal set).
- State: transferredFrom/To keyed by color — one-shot per color.
- performAction validates: fromPiece alive, currently royal,
toPiece alive, same color, not already royal, not already
transferred. Returns INVALID_TARGET / REJECTED on failure,
ok:true on success.
- transformRoyalPieces swaps old royal for new in the engine's
royal resolution; defensively drops dead ids so a transferred
royal that later died doesn't linger.
Tests:
- transferable-royalty.test.ts: 20 tests covering registration,
happy path, once-per-game cap, all INVALID_TARGET paths,
turn consumption, composition with knightmate-rules and
piece-hp.
- engine.performAction.test.ts: 7 tests covering NO_HANDLER,
GAME_OVER guard, first-match-wins, shouldAdvanceTurn veto,
performAction + applyMove interleaving.
- presets.test.ts: EXPECTED_IDS bumped; symmetry + dangling-ref
audits still green.
- capture-all.ts: reciprocated incompat with transferable-royalty
(symmetry audit).
Verification: 1699 tests passing (was 1671, +28). Typecheck + lint
clean. No regressions.
Plan: .sisyphus/plans/post-epic-deferrals.md F4a complete.
F4b (WS protocol) and F4c (UI) remain.
(Agent hit 200-tool-cap near the end; orchestrator reconciled
a missing defaultKingRoyals helper + 2 test setups that triggered
insufficient-material draws + symmetric incompat declaration.)
This commit is contained in:
parent
dfdc8aba63
commit
8220f1507e
10 changed files with 1586 additions and 72 deletions
106
packages/chess/src/actions.ts
Normal file
106
packages/chess/src/actions.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Player actions — turn-consuming events orthogonal to moves.
|
||||
*
|
||||
* A `LegalMove` resolves a move on the board; a `PlayerAction`
|
||||
* resolves a non-move gameplay change (e.g. transferring royalty
|
||||
* from one piece to another). Both consume the mover's turn via the
|
||||
* same engine turn-advance path, so action-heavy presets compose
|
||||
* cleanly with multi-move presets (double-move, monster) without
|
||||
* re-implementing the `HalfMovesThisTurn` / `shouldAdvanceTurn`
|
||||
* bookkeeping.
|
||||
*
|
||||
* Part of post-epic-deferrals Feature 4. Shipped solo-only in v1;
|
||||
* multiplayer sync (F4b, `game.action` WS message) and UI (F4c) are
|
||||
* separate deliverables. The shape of `ActionResult` is deliberately
|
||||
* flat + serializable so F4b can echo it verbatim as a server-
|
||||
* broadcast payload — no client-side reconstruction needed.
|
||||
*
|
||||
* Extension policy: new action kinds land as additional members of
|
||||
* the `PlayerAction` discriminated union. The engine dispatches on
|
||||
* `kind`; each preset that implements `performAction` reads
|
||||
* `ctx.action.kind` and returns `undefined` for kinds it doesn't own,
|
||||
* or an `ActionResult` for kinds it does. First non-undefined wins
|
||||
* (same semantics as other first-wins hooks like `overridePieceMoves`
|
||||
* and `onDamage`).
|
||||
*
|
||||
* This module is type-only and imports NOTHING from the chess
|
||||
* package other than `EntityId`. Keeping it free of engine/presets
|
||||
* imports prevents a circular dependency between `engine.ts` (which
|
||||
* imports `PlayerAction`) and preset files (which also import
|
||||
* `PlayerAction` via `PlayerActionContext`).
|
||||
*/
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
/**
|
||||
* Transfer the royal designation from one friendly piece to another.
|
||||
*
|
||||
* Used by the `transferable-royalty` preset to let a player promote
|
||||
* a non-royal piece to royal status (and simultaneously demote the
|
||||
* prior royal). Validation + "once per game per color" enforcement
|
||||
* live entirely in the preset — the engine only dispatches.
|
||||
*
|
||||
* `fromPieceId` must be ALIVE and CURRENTLY royal for the mover's
|
||||
* color; `toPieceId` must be ALIVE, SAME COLOR, and NOT already
|
||||
* royal. Failing any check yields `{ ok: false, error: "INVALID_TARGET" }`.
|
||||
*/
|
||||
export interface TransferRoyaltyAction {
|
||||
readonly kind: "transfer-royalty";
|
||||
readonly fromPieceId: EntityId;
|
||||
readonly toPieceId: EntityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of every known action kind. Additions land as
|
||||
* additional members; the engine dispatches on `kind` and presets
|
||||
* claim kinds by pattern-matching on the discriminant.
|
||||
*
|
||||
* Keep members small + JSON-serializable — multiplayer (F4b) echoes
|
||||
* actions across the wire verbatim.
|
||||
*/
|
||||
export type PlayerAction = TransferRoyaltyAction;
|
||||
|
||||
/** String literal union of every known action `kind`. Exported for
|
||||
* callers that want to switch on kind without importing the full
|
||||
* `PlayerAction` union. */
|
||||
export type PlayerActionKind = PlayerAction["kind"];
|
||||
|
||||
/**
|
||||
* Result returned by `engine.performAction` and by preset
|
||||
* `performAction` hooks.
|
||||
*
|
||||
* On success, `ok === true` and no other fields carry meaning —
|
||||
* the engine consumes the turn and the action is considered resolved.
|
||||
*
|
||||
* On failure, `ok === false` and `error` carries a stable code the
|
||||
* UI can switch on for i18n / iconography. `reason` is a human-
|
||||
* readable explanation suitable for a toast or a console log; it is
|
||||
* OPTIONAL because some errors (e.g. `NO_HANDLER`) have no
|
||||
* interesting preset-specific reason.
|
||||
*
|
||||
* Error codes:
|
||||
* - `NO_HANDLER` — no active preset claimed the action kind. Not
|
||||
* an error in the usual sense; indicates a configuration mismatch
|
||||
* (e.g. the client sent a transfer-royalty but the
|
||||
* transferable-royalty preset isn't active).
|
||||
* - `REJECTED` — a handler ran but declined. E.g. a "once per game"
|
||||
* cap already consumed, or an action phase predicate failed.
|
||||
* - `INVALID_TARGET` — target entity validation failed (dead,
|
||||
* wrong color, non-royal source, already-royal destination, …).
|
||||
* - `NOT_YOUR_TURN` — off-turn attempt. In solo play this is rare
|
||||
* (the engine always reports the current turn as yours), but
|
||||
* reserved here so multiplayer (F4b) can reuse the same shape.
|
||||
* - `GAME_OVER` — the game has already reached a terminal state.
|
||||
*
|
||||
* Intentionally flat + free of non-primitive fields so F4b can
|
||||
* forward it over WebSocket without transformation.
|
||||
*/
|
||||
export interface ActionResult {
|
||||
readonly ok: boolean;
|
||||
readonly error?:
|
||||
| "NO_HANDLER"
|
||||
| "REJECTED"
|
||||
| "INVALID_TARGET"
|
||||
| "NOT_YOUR_TURN"
|
||||
| "GAME_OVER";
|
||||
readonly reason?: string;
|
||||
}
|
||||
270
packages/chess/src/engine.performAction.test.ts
Normal file
270
packages/chess/src/engine.performAction.test.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* Engine-surface tests for `performAction` (post-epic-deferrals
|
||||
* Feature 4).
|
||||
*
|
||||
* These tests exercise the ENGINE's dispatch pipeline — NO_HANDLER
|
||||
* paths, terminal-state short-circuit, first-wins semantics when
|
||||
* multiple presets register, shouldAdvanceTurn veto on actions,
|
||||
* and interleaving with applyMove.
|
||||
*
|
||||
* Preset-specific behaviour (transferable-royalty validation, state
|
||||
* transitions) lives in `presets/transferable-royalty.test.ts` —
|
||||
* this file is about the engine contract.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import "./presets/index.js";
|
||||
import { ChessEngine } from "./engine.js";
|
||||
import { PRESET_REGISTRY } from "./presets/registry.js";
|
||||
import type { PresetDef } from "./presets/registry.js";
|
||||
import { clearBoard, placePiece, pieceAt } from "./presets/test-utils.js";
|
||||
import { GAME_ENTITY } from "./schema.js";
|
||||
import type { PlayerAction, TransferRoyaltyAction } from "./actions.js";
|
||||
import { TRANSFERABLE_ROYALTY_ID } from "./presets/transferable-royalty.js";
|
||||
|
||||
const XROY = {
|
||||
id: TRANSFERABLE_ROYALTY_ID,
|
||||
scope: "both" as const,
|
||||
turnsRemaining: null,
|
||||
};
|
||||
|
||||
// Helper: build an unknown-kind action payload without widening to
|
||||
// `any`. The engine treats the kind string as a pure discriminant.
|
||||
function unknownAction(): PlayerAction {
|
||||
return { kind: "not-a-real-kind" } as unknown as TransferRoyaltyAction;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Registration helpers. These tests register ad-hoc presets at
|
||||
// runtime; we clean up by restoring the registry after each test so
|
||||
// cross-test leakage is impossible.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const addedPresetIds: string[] = [];
|
||||
function registerTestPreset(def: PresetDef): void {
|
||||
PRESET_REGISTRY.register(def);
|
||||
addedPresetIds.push(def.id);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Remove any test-registered presets so subsequent suites see a
|
||||
// clean registry. The PresetRegistryClass doesn't expose a public
|
||||
// `delete`, so we reach through via a cast limited to this helper.
|
||||
const registry = PRESET_REGISTRY as unknown as {
|
||||
readonly presets: Map<string, PresetDef>;
|
||||
};
|
||||
for (const id of addedPresetIds) registry.presets.delete(id);
|
||||
addedPresetIds.length = 0;
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// NO_HANDLER paths
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("engine.performAction — NO_HANDLER", () => {
|
||||
it("returns NO_HANDLER on a fresh engine with no presets active", () => {
|
||||
const engine = new ChessEngine();
|
||||
const result = engine.performAction(unknownAction());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("NO_HANDLER");
|
||||
});
|
||||
|
||||
it("returns NO_HANDLER when active presets don't claim the kind", () => {
|
||||
const engine = new ChessEngine();
|
||||
// transferable-royalty only claims kind === "transfer-royalty".
|
||||
engine.setActivePresets([XROY]);
|
||||
const result = engine.performAction(unknownAction());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("NO_HANDLER");
|
||||
});
|
||||
|
||||
it("NO_HANDLER does NOT consume the turn", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([XROY]);
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
engine.performAction(unknownAction());
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// GAME_OVER short-circuit
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("engine.performAction — GAME_OVER", () => {
|
||||
it("returns GAME_OVER when the game is already terminal", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
placePiece(engine, "queen", "black", "e2");
|
||||
placePiece(engine, "rook", "black", "a1"); // covers e1-a1 rank
|
||||
// Force a checkmate-ish position: black queen on e2 covers king
|
||||
// on e1, rook on a1 covers the escape to d1 / f1 etc. Not a
|
||||
// perfect mate, but we can force it by just setting the Turn
|
||||
// and relying on a layout-free test. Simpler: force terminal via
|
||||
// a capture-to-win winner.
|
||||
engine.setActivePresets([
|
||||
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
// Fabricate a capture-to-win winner via preset-state.
|
||||
const state = engine.presetState<{ winner: "white" | "black" }>(
|
||||
"capture-to-win",
|
||||
);
|
||||
state.set("winner", "white");
|
||||
expect(engine.checkGameResult()).toBe("white-wins");
|
||||
|
||||
// Action dispatch must short-circuit.
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: wking,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("GAME_OVER");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// First-wins semantics when multiple presets register the hook
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("engine.performAction — first-wins registration order", () => {
|
||||
it("earlier-registered preset wins the dispatch for a given kind", () => {
|
||||
// Two ad-hoc presets both claim "transfer-royalty": the first
|
||||
// in the active list must win.
|
||||
const calls: string[] = [];
|
||||
registerTestPreset({
|
||||
id: "__test-first-wins-A__",
|
||||
name: "First Wins A",
|
||||
description: "test",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
performAction({ action }) {
|
||||
if (action.kind !== "transfer-royalty") return undefined;
|
||||
calls.push("A");
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
registerTestPreset({
|
||||
id: "__test-first-wins-B__",
|
||||
name: "First Wins B",
|
||||
description: "test",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
performAction({ action }) {
|
||||
if (action.kind !== "transfer-royalty") return undefined;
|
||||
calls.push("B");
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "__test-first-wins-A__", scope: "both", turnsRemaining: null },
|
||||
{ id: "__test-first-wins-B__", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: 1 as EntityId,
|
||||
toPieceId: 2 as EntityId,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
// Only A ran — B never saw the dispatch because A claimed first.
|
||||
expect(calls).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// shouldAdvanceTurn veto on actions
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("engine.performAction — shouldAdvanceTurn veto", () => {
|
||||
it("action does NOT flip the turn when a preset vetoes advance", () => {
|
||||
// Register a preset that (a) claims a custom action kind and
|
||||
// returns ok, and (b) vetoes turn advance. After
|
||||
// performAction the turn must NOT flip.
|
||||
registerTestPreset({
|
||||
id: "__test-no-flip-action__",
|
||||
name: "No-flip Action",
|
||||
description: "test",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
performAction({ action }) {
|
||||
// Claim ANY kind for test convenience.
|
||||
if (action.kind !== "transfer-royalty") return undefined;
|
||||
return { ok: true };
|
||||
},
|
||||
shouldAdvanceTurn() {
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{
|
||||
id: "__test-no-flip-action__",
|
||||
scope: "both",
|
||||
turnsRemaining: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: 1 as EntityId,
|
||||
toPieceId: 2 as EntityId,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
// Turn did NOT flip — same mover acts again.
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
// HalfMovesThisTurn incremented (post-increment the hook saw 1).
|
||||
const halfMoves = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"HalfMovesThisTurn",
|
||||
) as number;
|
||||
expect(halfMoves).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Integration: performAction + applyMove coexist
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("engine.performAction — integration with applyMove", () => {
|
||||
it("turn + halfmove + fullmove counters stay consistent across action/move interleave", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
const wqueen = placePiece(engine, "queen", "white", "d1");
|
||||
placePiece(engine, "king", "black", "e8");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
// Initial state: white to move, fullmove 1, halfmove 0.
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
||||
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn") ?? 0).toBe(0);
|
||||
|
||||
// White performs an action (transfer).
|
||||
const actionResult = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: wqueen,
|
||||
});
|
||||
expect(actionResult.ok).toBe(true);
|
||||
expect(engine.getCurrentTurn()).toBe("black");
|
||||
// HalfMovesThisTurn reset on flip.
|
||||
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||
// FullmoveNumber not yet bumped (only bumps after black).
|
||||
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
||||
|
||||
// Black plays a normal move.
|
||||
const blackMove = engine.getAllLegalMoves()[0];
|
||||
expect(blackMove).toBeDefined();
|
||||
engine.applyMove(blackMove!);
|
||||
|
||||
// Back to white; fullmove bumped to 2.
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -63,6 +63,8 @@ import type {
|
|||
} from "./presets/registry.js";
|
||||
import type { ChessAttrKey } from "./schema.js";
|
||||
import type { LegalMove } from "./rules/types.js";
|
||||
import type { ActionResult, PlayerAction } from "./actions.js";
|
||||
import type { PlayerActionContext } from "./presets/registry.js";
|
||||
import {
|
||||
ActivePresetSet,
|
||||
type ActivationRequest,
|
||||
|
|
@ -881,7 +883,7 @@ export class ChessEngine {
|
|||
* this result through so check/mate/stalemate all agree on what
|
||||
* counts as royal.
|
||||
*/
|
||||
private getActiveRoyalEntityIds(
|
||||
getActiveRoyalEntityIds(
|
||||
color: PieceColor,
|
||||
): readonly EntityId[] | undefined {
|
||||
const ctx: RoyalContext = { engine: this, color };
|
||||
|
|
@ -896,10 +898,59 @@ export class ChessEngine {
|
|||
for (const id of result) acc.add(id);
|
||||
}
|
||||
|
||||
if (!anyPresetContributed) return undefined;
|
||||
// Post-union transform pass. Transformers see the accumulated
|
||||
// set and return a new set — used by presets like
|
||||
// `transferable-royalty` that need to REASSIGN royalty within
|
||||
// the existing union (a pure union can't subtract an entry
|
||||
// another preset contributed).
|
||||
//
|
||||
// Any active transformer implies "somebody touched royalty",
|
||||
// even if `getRoyalPieces` contributions were all undefined.
|
||||
// That matters for a game with ONLY `transferable-royalty`
|
||||
// active: no one contributed a base royal set, so
|
||||
// `anyPresetContributed` is false. The transformer wants to
|
||||
// operate on the default king-only set in that case — we
|
||||
// materialize it here so the transformer always sees a defined
|
||||
// input.
|
||||
let anyTransformer = false;
|
||||
let working: EntityId[] = [...acc];
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
if (!def?.transformRoyalPieces) continue;
|
||||
if (!anyTransformer && !anyPresetContributed) {
|
||||
// First transformer + no base contribution: seed with the
|
||||
// default king-only set so the transformer has something to
|
||||
// work with.
|
||||
working = this.#defaultKingRoyals(color);
|
||||
}
|
||||
anyTransformer = true;
|
||||
working = [...def.transformRoyalPieces(ctx, working)];
|
||||
}
|
||||
|
||||
if (!anyPresetContributed && !anyTransformer) return undefined;
|
||||
if (anyTransformer) return working;
|
||||
return [...acc];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default royal-set fallback: every live `PieceType === "king"`
|
||||
* entity of `color`. Used when no preset contributes a royal
|
||||
* set but a transformer wants to operate on the baseline.
|
||||
*/
|
||||
#defaultKingRoyals(color: PieceColor): EntityId[] {
|
||||
const kingIds: EntityId[] = [];
|
||||
const colorById = new Map<EntityId, string>();
|
||||
for (const f of this.session.allFacts()) {
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (f.attr === "PieceType" && f.value === "king") {
|
||||
kingIds.push(f.id);
|
||||
} else if (f.attr === "Color") {
|
||||
colorById.set(f.id, f.value as string);
|
||||
}
|
||||
}
|
||||
return kingIds.filter((id) => colorById.get(id) === color);
|
||||
}
|
||||
|
||||
getAllLegalMoves(): LegalMove[] {
|
||||
const color = this.getCurrentTurn();
|
||||
const facts = this.session.allFacts();
|
||||
|
|
@ -1227,78 +1278,17 @@ export class ChessEngine {
|
|||
|
||||
// Update halfmove clock (50-move rule). This is the FIDE clock —
|
||||
// distinct from the rule-variants `HalfMovesThisTurn` counter
|
||||
// below, which tracks within-turn move count for presets like
|
||||
// double-move.
|
||||
// maintained by `advanceTurnAfterMutation` below, which tracks
|
||||
// within-turn move count for presets like double-move.
|
||||
updateHalfmoveClock(this.session, move, movingType === "pawn");
|
||||
|
||||
// Phase A.3: increment HalfMovesThisTurn BEFORE polling
|
||||
// `shouldAdvanceTurn`. The hook sees the POST-increment count so
|
||||
// a "play N half-moves before flipping" predicate reads as
|
||||
// `ctx.halfMovesThisTurn < N → false`.
|
||||
const prevHalfMovesThisTurn =
|
||||
(this.session.get(GAME_ENTITY, "HalfMovesThisTurn") as number) ?? 0;
|
||||
const nextHalfMovesThisTurn = prevHalfMovesThisTurn + 1;
|
||||
this.session.insert(
|
||||
GAME_ENTITY,
|
||||
"HalfMovesThisTurn",
|
||||
nextHalfMovesThisTurn,
|
||||
);
|
||||
|
||||
// Poll every active preset's shouldAdvanceTurn hook. FIRST FALSE
|
||||
// WINS — as soon as one veto lands, the engine stops polling and
|
||||
// skips the turn flip. Scope-unaware: a `scope=white` preset that
|
||||
// vetoes on black's moves is a bug the preset must guard against
|
||||
// (check `ctx.mover`).
|
||||
const turnAdvanceCtx: TurnAdvanceContext = {
|
||||
engine: this,
|
||||
mover: color,
|
||||
halfMovesThisTurn: nextHalfMovesThisTurn,
|
||||
};
|
||||
let shouldAdvance = true;
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
const verdict = def?.shouldAdvanceTurn?.(turnAdvanceCtx);
|
||||
if (verdict === false) {
|
||||
shouldAdvance = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Switch turn iff no preset vetoed. When skipped, `Turn` and
|
||||
// `HalfMovesThisTurn` are left as-is (HalfMovesThisTurn was
|
||||
// already incremented above; the next move's poll sees it grow).
|
||||
const nextColor: PieceColor = shouldAdvance
|
||||
? (color === "white" ? "black" : "white")
|
||||
: color;
|
||||
|
||||
if (shouldAdvance) {
|
||||
this.session.insert(GAME_ENTITY, "Turn", nextColor);
|
||||
// Reset within-turn counter on every actual flip.
|
||||
this.session.insert(GAME_ENTITY, "HalfMovesThisTurn", 0);
|
||||
|
||||
// Increment fullmove number after black's completed turn.
|
||||
if (color === "black") {
|
||||
const fn =
|
||||
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
|
||||
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
|
||||
}
|
||||
}
|
||||
|
||||
// Record position for threefold repetition
|
||||
recordPosition(this.session);
|
||||
|
||||
// Tick preset durations with the color that JUST moved. Player-local
|
||||
// turn counting: a `scope=white` preset with 3 turns remaining
|
||||
// ticks only when white plays; a `scope=both` ticks on every
|
||||
// half-move. Entries reaching 0 are removed AND fire onDeactivate
|
||||
// so they can tear down any board state they installed.
|
||||
const lifecycleCtx: LifecycleContext = { engine: this };
|
||||
const expired = this.activePresets.tickAfterMove(color);
|
||||
for (const id of expired) {
|
||||
const def = PRESET_REGISTRY.get(id);
|
||||
def?.onDeactivate?.(lifecycleCtx);
|
||||
this.clearPresetState(id);
|
||||
}
|
||||
// Shared turn-advance pipeline. Increments HalfMovesThisTurn,
|
||||
// polls shouldAdvanceTurn, flips Turn (+ resets halfmove counter,
|
||||
// bumps FullmoveNumber on black-to-white flip), records position,
|
||||
// ticks preset durations, and fires onTurnStart on flip. Returns
|
||||
// the resulting state so downstream move-specific hooks know
|
||||
// whether to treat this as "end of turn" for their purposes.
|
||||
const { shouldAdvance, nextColor } = this.advanceTurnAfterMutation(color);
|
||||
|
||||
// Post-move preset hooks: fire against EVERY still-active preset
|
||||
// (regardless of scope). Scope-aware behaviour is the preset's
|
||||
|
|
@ -1390,6 +1380,242 @@ export class ChessEngine {
|
|||
return gameResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared turn-advance pipeline used by `applyMove` and
|
||||
* `performAction`.
|
||||
*
|
||||
* Call AFTER the turn's mutation has committed (a move resolved
|
||||
* on the board, or an action's side effect recorded in preset
|
||||
* state). This function owns:
|
||||
*
|
||||
* 1. Increment `HalfMovesThisTurn` on `GAME_ENTITY`.
|
||||
* 2. Poll every active preset's `shouldAdvanceTurn` — first
|
||||
* `false` vetoes the flip (rest of the poll short-circuits).
|
||||
* 3. On flip: update `Turn`, reset `HalfMovesThisTurn`, bump
|
||||
* `FullmoveNumber` after black's completed turn.
|
||||
* 4. Record the position for threefold-repetition tracking.
|
||||
* 5. Tick preset durations for the mover's color via
|
||||
* `activePresets.tickAfterMove`, firing `onDeactivate` +
|
||||
* clearing preset-state for any preset whose timer expired.
|
||||
* 6. On flip: fire every scope-relevant preset's `onTurnStart`.
|
||||
*
|
||||
* Caller-specific hooks (`onAfterMove` for moves; UI effect
|
||||
* emits for actions) fire from `applyMove` / `performAction`
|
||||
* themselves — putting them here would force both callers to
|
||||
* share an identical suffix they don't actually share.
|
||||
*
|
||||
* Intentionally private: extending this signature across both
|
||||
* callers is the whole point of the helper. Tests that want to
|
||||
* drive turn-advance without a real move/action construct an
|
||||
* engine + preset and call the public surface.
|
||||
*/
|
||||
private advanceTurnAfterMutation(
|
||||
color: PieceColor,
|
||||
): { readonly shouldAdvance: boolean; readonly nextColor: PieceColor } {
|
||||
// Phase A.3: increment HalfMovesThisTurn BEFORE polling
|
||||
// `shouldAdvanceTurn`. The hook sees the POST-increment count so
|
||||
// a "play N half-moves before flipping" predicate reads as
|
||||
// `ctx.halfMovesThisTurn < N → false`.
|
||||
const prevHalfMovesThisTurn =
|
||||
(this.session.get(GAME_ENTITY, "HalfMovesThisTurn") as number) ?? 0;
|
||||
const nextHalfMovesThisTurn = prevHalfMovesThisTurn + 1;
|
||||
this.session.insert(
|
||||
GAME_ENTITY,
|
||||
"HalfMovesThisTurn",
|
||||
nextHalfMovesThisTurn,
|
||||
);
|
||||
|
||||
// Poll every active preset's shouldAdvanceTurn hook. FIRST FALSE
|
||||
// WINS — as soon as one veto lands, the engine stops polling and
|
||||
// skips the turn flip. Scope-unaware: a `scope=white` preset that
|
||||
// vetoes on black's moves is a bug the preset must guard against
|
||||
// (check `ctx.mover`).
|
||||
const turnAdvanceCtx: TurnAdvanceContext = {
|
||||
engine: this,
|
||||
mover: color,
|
||||
halfMovesThisTurn: nextHalfMovesThisTurn,
|
||||
};
|
||||
let shouldAdvance = true;
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
const verdict = def?.shouldAdvanceTurn?.(turnAdvanceCtx);
|
||||
if (verdict === false) {
|
||||
shouldAdvance = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Switch turn iff no preset vetoed. When skipped, `Turn` and
|
||||
// `HalfMovesThisTurn` are left as-is (HalfMovesThisTurn was
|
||||
// already incremented above; the next move's poll sees it grow).
|
||||
const nextColor: PieceColor = shouldAdvance
|
||||
? color === "white"
|
||||
? "black"
|
||||
: "white"
|
||||
: color;
|
||||
|
||||
if (shouldAdvance) {
|
||||
this.session.insert(GAME_ENTITY, "Turn", nextColor);
|
||||
// Reset within-turn counter on every actual flip.
|
||||
this.session.insert(GAME_ENTITY, "HalfMovesThisTurn", 0);
|
||||
|
||||
// Increment fullmove number after black's completed turn.
|
||||
if (color === "black") {
|
||||
const fn =
|
||||
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) +
|
||||
1;
|
||||
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
|
||||
}
|
||||
}
|
||||
|
||||
// Record position for threefold repetition. Runs on every
|
||||
// mutation (flipped or not) so a preset that artificially holds
|
||||
// the turn across multiple half-moves still yields a faithful
|
||||
// position history.
|
||||
recordPosition(this.session);
|
||||
|
||||
// Tick preset durations with the color that JUST moved. Player-
|
||||
// local turn counting: a `scope=white` preset with 3 turns
|
||||
// remaining ticks only when white plays; a `scope=both` ticks on
|
||||
// every half-move. Entries reaching 0 are removed AND fire
|
||||
// onDeactivate so they can tear down any board state they
|
||||
// installed.
|
||||
const lifecycleCtx: LifecycleContext = { engine: this };
|
||||
const expired = this.activePresets.tickAfterMove(color);
|
||||
for (const id of expired) {
|
||||
const def = PRESET_REGISTRY.get(id);
|
||||
def?.onDeactivate?.(lifecycleCtx);
|
||||
this.clearPresetState(id);
|
||||
}
|
||||
|
||||
return { shouldAdvance, nextColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a `PlayerAction` — the non-move counterpart to
|
||||
* `applyMove`.
|
||||
*
|
||||
* See `../actions.ts` for the `PlayerAction` discriminated union
|
||||
* and `ActionResult` shape. Algorithm:
|
||||
*
|
||||
* 1. If the game is already terminal → `{ ok: false, error:
|
||||
* "GAME_OVER" }`. Short-circuits before any hook fires.
|
||||
* 2. Resolve the mover via `getCurrentTurn`.
|
||||
* 3. Poll every active preset's `performAction` hook in
|
||||
* registration order. FIRST non-undefined return WINS — the
|
||||
* engine stops polling and uses that result.
|
||||
* 4. If no preset handled the action → `{ ok: false, error:
|
||||
* "NO_HANDLER" }`.
|
||||
* 5. If the handler returned `{ ok: false, … }` → propagate as-
|
||||
* is. The turn is NOT consumed (the mover retries).
|
||||
* 6. If the handler returned `{ ok: true }` → consume the turn
|
||||
* via `advanceTurnAfterMutation` (same pipeline as a
|
||||
* successful `applyMove`). Fire `onTurnStart` on flip.
|
||||
*
|
||||
* No `onAfterMove` fires — actions are NOT moves and the
|
||||
* `MoveHookContext` shape doesn't apply. Presets that want to
|
||||
* react to action resolution can implement `performAction`
|
||||
* themselves (own the dispatch) or poll session state from
|
||||
* `onTurnStart`.
|
||||
*
|
||||
* Solo-only in v1. Multiplayer sync lands in F4b (post-epic-
|
||||
* deferrals Feature 4b) as a `game.action` WS message echoing
|
||||
* this method's payload + result to every peer.
|
||||
*/
|
||||
performAction(action: PlayerAction): ActionResult {
|
||||
// Terminal-state short-circuit. We consult `checkGameResult`
|
||||
// (not an internal flag) so every preset's `onCheckGameResult`
|
||||
// contribution is respected — a piece-hp-suppressed mate still
|
||||
// reads as "ongoing" here, a capture-to-win winner reads as a
|
||||
// terminal variant.
|
||||
if (this.checkGameResult() !== "ongoing") {
|
||||
return { ok: false, error: "GAME_OVER" };
|
||||
}
|
||||
|
||||
const mover = this.getCurrentTurn();
|
||||
|
||||
const ctx: PlayerActionContext = { engine: this, mover, action };
|
||||
let handlerResult: ActionResult | undefined;
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
const result = def?.performAction?.(ctx);
|
||||
if (result !== undefined) {
|
||||
handlerResult = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handlerResult === undefined) {
|
||||
return { ok: false, error: "NO_HANDLER" };
|
||||
}
|
||||
|
||||
// A handler-reported failure propagates verbatim. The engine
|
||||
// does NOT consume the turn; the mover may retry or play a
|
||||
// move instead.
|
||||
if (handlerResult.ok === false) return handlerResult;
|
||||
|
||||
// Successful action — tick the turn via the shared pipeline
|
||||
// (same semantics as a successful move: HalfMovesThisTurn,
|
||||
// shouldAdvanceTurn poll, optional flip, recordPosition,
|
||||
// tickAfterMove, onTurnStart).
|
||||
const { shouldAdvance, nextColor } = this.advanceTurnAfterMutation(mover);
|
||||
|
||||
if (shouldAdvance) {
|
||||
const turnStartCtx: TurnStartContext = {
|
||||
engine: this,
|
||||
turn: nextColor,
|
||||
};
|
||||
for (const preset of this.activePresets.getForColor(nextColor)) {
|
||||
preset.onTurnStart?.(turnStartCtx);
|
||||
}
|
||||
}
|
||||
|
||||
return handlerResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of `getActiveRoyalEntityIds` that EXCLUDES a specific
|
||||
* preset's `getRoyalPieces` contribution from the union.
|
||||
*
|
||||
* Used by presets that TRANSFORM (rather than replace) the royal
|
||||
* set contributed by OTHER presets — notably `transferable-
|
||||
* royalty`, which reads the "upstream" royal set, swaps out the
|
||||
* transferred-from id, and injects the transferred-to id.
|
||||
*
|
||||
* Semantics mirror `getActiveRoyalEntityIds`:
|
||||
* - All non-excluded presets return `undefined` → returns
|
||||
* `undefined` (caller falls back to the default "every
|
||||
* PieceType===king" rule).
|
||||
* - At least one non-excluded preset contributed → returns the
|
||||
* UNION of their contributions (deduplicated).
|
||||
*
|
||||
* Exposed as a read-only helper on the engine so presets don't
|
||||
* need privileged access to `activePresets.list()` + the registry
|
||||
* to compute the same union. Called rarely (once per
|
||||
* `getRoyalPieces` invocation on presets that use it); perf is
|
||||
* not a concern.
|
||||
*/
|
||||
getRoyalEntityIdsExcluding(
|
||||
color: PieceColor,
|
||||
excludePresetId: string,
|
||||
): readonly EntityId[] | undefined {
|
||||
const ctx: RoyalContext = { engine: this, color };
|
||||
const acc = new Set<EntityId>();
|
||||
let anyPresetContributed = false;
|
||||
|
||||
for (const entry of this.activePresets.list()) {
|
||||
if (entry.id === excludePresetId) continue;
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
const result = def?.getRoyalPieces?.(ctx);
|
||||
if (result === undefined) continue;
|
||||
anyPresetContributed = true;
|
||||
for (const id of result) acc.add(id);
|
||||
}
|
||||
|
||||
if (!anyPresetContributed) return undefined;
|
||||
return [...acc];
|
||||
}
|
||||
|
||||
checkGameResult(): GameResult {
|
||||
// Preset override path: run onCheckGameResult on every active
|
||||
// preset in registration order. Semantics:
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ PRESET_REGISTRY.register({
|
|||
"dual-king",
|
||||
"weak-dual-king",
|
||||
"monster-rules",
|
||||
// Reciprocal: transferable-royalty's PlayerAction requires at
|
||||
// least one royal per color; capture-all empties the royal set.
|
||||
"transferable-royalty",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@ import "./berolina-pawns.js";
|
|||
import "./berolina-pawns-2.js";
|
||||
import "./bouncing-pieces.js";
|
||||
import "./bouncing-pieces-2.js";
|
||||
import "./transferable-royalty.js";
|
||||
|
||||
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ describe("Preset registry — all registered", () => {
|
|||
"berolina-pawns-2",
|
||||
"bouncing-pieces",
|
||||
"bouncing-pieces-2",
|
||||
"transferable-royalty",
|
||||
];
|
||||
|
||||
it("registry size matches the expected ID list", () => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import type { EntityId, Session } from "@paratype/rete";
|
|||
import type { ChessEngine, GameResult } from "../engine.js";
|
||||
import type { LegalMove } from "../rules/types.js";
|
||||
import type { ChessAttrKey } from "../schema.js";
|
||||
import type { ActionResult, PlayerAction } from "../actions.js";
|
||||
|
||||
/** Return shape for onBeforeCapture. `consume: true` skips the engine's
|
||||
* default capture path (no target retraction, no attacker move).
|
||||
|
|
@ -285,6 +286,22 @@ export interface TurnAdvanceContext extends HookContext {
|
|||
readonly halfMovesThisTurn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to the `performAction` hook. Carries the engine
|
||||
* handle, the color attempting the action, and the action payload.
|
||||
*
|
||||
* Fired from `engine.performAction` in preset-registration order;
|
||||
* the engine dispatches on `ctx.action.kind` by delegating to every
|
||||
* active preset and taking the FIRST non-undefined return.
|
||||
*
|
||||
* Phase — Feature 4 of the post-epic-deferrals epic (solo only in
|
||||
* v1; multiplayer sync lands in F4b via a `game.action` WS message).
|
||||
*/
|
||||
export interface PlayerActionContext extends HookContext {
|
||||
readonly mover: "white" | "black";
|
||||
readonly action: PlayerAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a piece is being spawned. Used by `onPieceSpawn` hooks to
|
||||
* decide whether to participate (e.g., a preset that resurrects
|
||||
|
|
@ -579,6 +596,40 @@ export interface PresetDef {
|
|||
ctx: RoyalContext,
|
||||
) => readonly EntityId[] | undefined;
|
||||
|
||||
/**
|
||||
* Post-union TRANSFORM of the royal-piece set.
|
||||
*
|
||||
* `getRoyalPieces` contributes ids via UNION semantics — every
|
||||
* active preset's contribution is added to the accumulating set.
|
||||
* Union is the right model for additive royalty (knightmate adds
|
||||
* the knight set, coregal adds queen + king, dual-king widens the
|
||||
* king set). It's the WRONG model for presets that need to
|
||||
* REASSIGN royalty within the existing set — transferring royalty
|
||||
* from a king to a queen means the king must leave the set, which
|
||||
* a union can't express (no contributor can subtract another
|
||||
* contributor's entry).
|
||||
*
|
||||
* `transformRoyalPieces` runs AFTER the union pass with the
|
||||
* accumulated set as input, in preset-registration order. Each
|
||||
* transformer returns the new set (may drop, add, or reorder
|
||||
* entries). The result becomes the input to the next transformer
|
||||
* and ultimately the value returned from
|
||||
* `engine.getActiveRoyalEntityIds`.
|
||||
*
|
||||
* Use sparingly — composing multiple transformers is the
|
||||
* caller's responsibility. If two presets both transform the
|
||||
* royal set, declare them `incompatibleWith` each other so a
|
||||
* user can't stack them and get silent order-dependent output.
|
||||
*
|
||||
* Canonical implementer: `transferable-royalty`, which reads the
|
||||
* input, drops the transferred-from id, and appends the
|
||||
* transferred-to id (per-color state lookup).
|
||||
*/
|
||||
readonly transformRoyalPieces?: (
|
||||
ctx: RoyalContext,
|
||||
current: readonly EntityId[],
|
||||
) => readonly EntityId[];
|
||||
|
||||
/**
|
||||
* Post-aggregation filter on the per-color legal-move list.
|
||||
*
|
||||
|
|
@ -708,6 +759,45 @@ export interface PresetDef {
|
|||
* color whose turn is beginning.
|
||||
*/
|
||||
readonly onTurnStart?: (ctx: TurnStartContext) => void;
|
||||
|
||||
/**
|
||||
* Handle a `PlayerAction` — a turn-consuming event that is NOT a
|
||||
* move (see `../actions.ts`).
|
||||
*
|
||||
* Semantics:
|
||||
* - `undefined` → this preset does not own `ctx.action.kind`.
|
||||
* The engine continues polling remaining presets.
|
||||
* - `ActionResult` (ok=true or ok=false) → FIRST NON-UNDEFINED
|
||||
* WINS. The engine stops polling, reports the result, and —
|
||||
* iff ok=true — consumes the mover's turn via the same
|
||||
* `shouldAdvanceTurn` path used by `applyMove`.
|
||||
*
|
||||
* Mutation contract: the handler MAY freely mutate the session
|
||||
* (e.g. set preset state marking a one-shot capability consumed,
|
||||
* retract an entity, seed a new fact). All mutations land BEFORE
|
||||
* the turn-advance poll runs. On an ok=false result the engine
|
||||
* does NOT consume the turn, so the handler SHOULD roll back any
|
||||
* partial mutation it performed (or — preferred — validate fully
|
||||
* before mutating).
|
||||
*
|
||||
* Turn consumption: the handler does NOT decide. Any `ok: true`
|
||||
* return causes the engine to tick the turn via
|
||||
* `advanceTurnAfterMutation` (same path as a successful move).
|
||||
* `shouldAdvanceTurn` vetoes still apply, so a preset that expects
|
||||
* N-action turns can gate the flip from there.
|
||||
*
|
||||
* First-wins rationale: the engine can't meaningfully combine two
|
||||
* handlers' returns (they'd conflict on turn-consumption, mutation
|
||||
* ownership, state). Presets that intend to own a given action
|
||||
* kind declare `incompatibleWith` against competing presets —
|
||||
* caught at activation time.
|
||||
*
|
||||
* Canonical implementer: `transferable-royalty` (handles
|
||||
* `kind === "transfer-royalty"`).
|
||||
*/
|
||||
readonly performAction?: (
|
||||
ctx: PlayerActionContext,
|
||||
) => ActionResult | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ PRESET_REGISTRY.register({
|
|||
"coregal",
|
||||
"dual-king",
|
||||
"weak-dual-king",
|
||||
"transferable-royalty",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
|
|
|
|||
537
packages/chess/src/presets/transferable-royalty.test.ts
Normal file
537
packages/chess/src/presets/transferable-royalty.test.ts
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
/**
|
||||
* Tests for `transferable-royalty` (post-epic-deferrals Feature 4).
|
||||
*
|
||||
* Exercises:
|
||||
* - Activation + state initialisation.
|
||||
* - Happy-path transfer: ok=true, state recorded, royal set
|
||||
* reflects the swap.
|
||||
* - Validation error codes for every failure mode documented in
|
||||
* the preset source (non-royal source, dead source/target,
|
||||
* wrong color, already-royal target, already transferred).
|
||||
* - Turn consumption (successful transfer ticks the turn;
|
||||
* failures do not).
|
||||
* - Composition with knightmate-rules (base royalty is knights;
|
||||
* transfer moves royalty within that set) and piece-hp
|
||||
* (transferred royal takes HP damage before dying).
|
||||
* - End-of-game behaviour after a transfer.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import "./index.js";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import { clearBoard, placePiece, pieceAt } from "./test-utils.js";
|
||||
import type { TransferRoyaltyAction } from "../actions.js";
|
||||
import { TRANSFERABLE_ROYALTY_ID } from "./transferable-royalty.js";
|
||||
import { isInCheck } from "../rules/check.js";
|
||||
import { GAME_ENTITY } from "../schema.js";
|
||||
|
||||
const XROY = {
|
||||
id: TRANSFERABLE_ROYALTY_ID,
|
||||
scope: "both" as const,
|
||||
turnsRemaining: null,
|
||||
};
|
||||
|
||||
const KNIGHTMATE = {
|
||||
id: "knightmate-rules",
|
||||
scope: "both" as const,
|
||||
turnsRemaining: null,
|
||||
};
|
||||
|
||||
const PIECE_HP = {
|
||||
id: "piece-hp",
|
||||
scope: "both" as const,
|
||||
turnsRemaining: null,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Registration / activation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — registration", () => {
|
||||
it("is registered with the expected id", () => {
|
||||
const def = PRESET_REGISTRY.get(TRANSFERABLE_ROYALTY_ID);
|
||||
expect(def).toBeDefined();
|
||||
expect(def?.name).toBe("Transferable Royalty");
|
||||
});
|
||||
|
||||
it("activates cleanly on a fresh engine (no side effects)", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([XROY]);
|
||||
// Default kings remain royal — preset is a no-op until a
|
||||
// transfer action is dispatched.
|
||||
const whiteRoyals = engine.getActiveRoyalEntityIds("white") ?? [];
|
||||
expect(whiteRoyals.length).toBe(1);
|
||||
});
|
||||
|
||||
it("state starts empty (no transfers recorded)", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([XROY]);
|
||||
const state = engine.presetState(TRANSFERABLE_ROYALTY_ID);
|
||||
expect(state.all()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Unknown-kind dispatch
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — action dispatch", () => {
|
||||
it("returns undefined for unknown action kinds (engine → NO_HANDLER)", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([XROY]);
|
||||
// Cast through `unknown` to build an action with an unknown kind
|
||||
// without `any`. This is the narrowest possible escape so the
|
||||
// engine's NO_HANDLER path is exercised.
|
||||
const unknownAction = {
|
||||
kind: "not-a-real-kind",
|
||||
} as unknown as TransferRoyaltyAction;
|
||||
const result = engine.performAction(unknownAction);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("NO_HANDLER");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Happy path
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — happy-path transfer", () => {
|
||||
function setupWhiteKingAndQueen(): {
|
||||
engine: ChessEngine;
|
||||
king: EntityId;
|
||||
queen: EntityId;
|
||||
} {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
// Kings are preserved by clearBoard; we keep the white king at
|
||||
// its FIDE square (e1=4). Add a white queen on d1=3. Black king
|
||||
// at h8=63 for isInCheck sanity.
|
||||
const king = pieceAt(engine, "e1")!;
|
||||
const queen = placePiece(engine, "queen", "white", "d1");
|
||||
// Remove black king if present and place at h8 for minimality.
|
||||
const bk = pieceAt(engine, "e8");
|
||||
if (bk !== null) {
|
||||
engine.session.retract(bk, "PieceType");
|
||||
engine.session.retract(bk, "Color");
|
||||
engine.session.retract(bk, "Position");
|
||||
engine.session.retract(bk, "HasMoved");
|
||||
}
|
||||
placePiece(engine, "king", "black", "h8");
|
||||
engine.setActivePresets([XROY]);
|
||||
return { engine, king, queen };
|
||||
}
|
||||
|
||||
it("ok=true when transferring king→queen (same color, valid)", () => {
|
||||
const { engine, king, queen } = setupWhiteKingAndQueen();
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("state records transferredFrom + transferredTo after success", () => {
|
||||
const { engine, king, queen } = setupWhiteKingAndQueen();
|
||||
engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
const state = engine.presetState(TRANSFERABLE_ROYALTY_ID);
|
||||
expect(state.get("transferredFrom:white")).toBe(king);
|
||||
expect(state.get("transferredTo:white")).toBe(queen);
|
||||
});
|
||||
|
||||
it("royal set after transfer contains new royal + excludes old", () => {
|
||||
const { engine, king, queen } = setupWhiteKingAndQueen();
|
||||
engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
const whiteRoyals = engine.getActiveRoyalEntityIds("white") ?? [];
|
||||
expect(whiteRoyals).toContain(queen);
|
||||
expect(whiteRoyals).not.toContain(king);
|
||||
});
|
||||
|
||||
it("isInCheck reflects new royal after transfer", () => {
|
||||
// Position the black rook so it attacks the transferred royal
|
||||
// (queen on d1) but NOT the former royal (king on e1). After
|
||||
// the transfer, white is IN check; before, white is NOT.
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const king = pieceAt(engine, "e1")!;
|
||||
const queen = placePiece(engine, "queen", "white", "d1");
|
||||
placePiece(engine, "rook", "black", "d8"); // d-file attacker
|
||||
const bk = pieceAt(engine, "e8");
|
||||
if (bk !== null) {
|
||||
engine.session.retract(bk, "PieceType");
|
||||
engine.session.retract(bk, "Color");
|
||||
engine.session.retract(bk, "Position");
|
||||
engine.session.retract(bk, "HasMoved");
|
||||
}
|
||||
placePiece(engine, "king", "black", "h8");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
// Pre-transfer: king on e1 not attacked by the d-file rook.
|
||||
expect(
|
||||
isInCheck(engine.session, "white", engine.getActiveRoyalEntityIds("white")),
|
||||
).toBe(false);
|
||||
|
||||
engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
|
||||
// Post-transfer: queen on d1 is attacked by the d-file rook.
|
||||
expect(
|
||||
isInCheck(engine.session, "white", engine.getActiveRoyalEntityIds("white")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Per-color once-per-game cap
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — once-per-game cap", () => {
|
||||
it("second transfer by same color → REJECTED", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const king = pieceAt(engine, "e1")!;
|
||||
const queen = placePiece(engine, "queen", "white", "d1");
|
||||
const rook = placePiece(engine, "rook", "white", "a1");
|
||||
const bk = pieceAt(engine, "e8");
|
||||
if (bk !== null) {
|
||||
engine.session.retract(bk, "PieceType");
|
||||
engine.session.retract(bk, "Color");
|
||||
engine.session.retract(bk, "Position");
|
||||
engine.session.retract(bk, "HasMoved");
|
||||
}
|
||||
placePiece(engine, "king", "black", "h8");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
const first = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
// First transfer consumed white's turn — it's black's turn now.
|
||||
// To test the per-color cap on white's second attempt we force
|
||||
// the turn fact back to white. The cap is on the state flag
|
||||
// (`transferredFrom:white`), not the current-turn read, but the
|
||||
// action dispatch uses the current turn as the mover so we need
|
||||
// to present as white.
|
||||
engine.session.insert(GAME_ENTITY, "Turn", "white");
|
||||
|
||||
const second = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: queen,
|
||||
toPieceId: rook,
|
||||
});
|
||||
expect(second.ok).toBe(false);
|
||||
expect(second.error).toBe("REJECTED");
|
||||
});
|
||||
|
||||
it("opposite color may still transfer after one side did", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
const wqueen = placePiece(engine, "queen", "white", "d1");
|
||||
const bking = pieceAt(engine, "e8")!;
|
||||
const bqueen = placePiece(engine, "queen", "black", "d8");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
// White transfers.
|
||||
const first = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: wqueen,
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
// Turn now belongs to black (applyMove-style flip via
|
||||
// advanceTurnAfterMutation).
|
||||
expect(engine.getCurrentTurn()).toBe("black");
|
||||
|
||||
// Black transfers — should succeed independently.
|
||||
const second = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: bking,
|
||||
toPieceId: bqueen,
|
||||
});
|
||||
expect(second.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Invalid targets
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — INVALID_TARGET errors", () => {
|
||||
it("transfer to enemy piece → INVALID_TARGET", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
const bqueen = placePiece(engine, "queen", "black", "d1");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: bqueen,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("INVALID_TARGET");
|
||||
});
|
||||
|
||||
it("transfer FROM non-royal piece → INVALID_TARGET", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wqueen = placePiece(engine, "queen", "white", "d1"); // not royal
|
||||
const wrook = placePiece(engine, "rook", "white", "a1");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wqueen,
|
||||
toPieceId: wrook,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("INVALID_TARGET");
|
||||
});
|
||||
|
||||
it("transfer to already-royal piece (dual-king) → INVALID_TARGET", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine, { preserveKings: false });
|
||||
const k1 = placePiece(engine, "king", "white", "d1");
|
||||
const k2 = placePiece(engine, "king", "white", "e1");
|
||||
placePiece(engine, "king", "black", "h8");
|
||||
// Avoid insufficient-material draw by keeping a rook alive on
|
||||
// each side — plenty of material → game is "ongoing" and the
|
||||
// performAction guard doesn't short-circuit to GAME_OVER.
|
||||
placePiece(engine, "rook", "white", "a1");
|
||||
placePiece(engine, "rook", "black", "a8");
|
||||
engine.setActivePresets([
|
||||
{ id: "dual-king", scope: "both", turnsRemaining: null },
|
||||
XROY,
|
||||
]);
|
||||
|
||||
// Both kings are royal under dual-king. Transferring k1→k2
|
||||
// lands on an already-royal target.
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: k1,
|
||||
toPieceId: k2,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("INVALID_TARGET");
|
||||
});
|
||||
|
||||
it("transfer to dead (captured) piece → INVALID_TARGET", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
const wqueen = placePiece(engine, "queen", "white", "d1");
|
||||
// Kill the queen via direct retract BEFORE the action.
|
||||
engine.session.retract(wqueen, "PieceType");
|
||||
engine.session.retract(wqueen, "Color");
|
||||
engine.session.retract(wqueen, "Position");
|
||||
engine.session.retract(wqueen, "HasMoved");
|
||||
// Keep enough material around to avoid insufficient-material
|
||||
// draw / stalemate before the action is evaluated. Both sides
|
||||
// get a rook.
|
||||
placePiece(engine, "rook", "white", "a1");
|
||||
placePiece(engine, "rook", "black", "a8");
|
||||
placePiece(engine, "king", "black", "h8");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: wqueen,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("INVALID_TARGET");
|
||||
});
|
||||
|
||||
it("transfer FROM dead piece → INVALID_TARGET", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
const wqueen = placePiece(engine, "queen", "white", "d1");
|
||||
// Retract king — note this is artificial; in play a dead king
|
||||
// would mean game over, but we're isolating the validator.
|
||||
engine.session.retract(wking, "PieceType");
|
||||
engine.session.retract(wking, "Color");
|
||||
engine.session.retract(wking, "Position");
|
||||
engine.session.retract(wking, "HasMoved");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: wqueen,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("INVALID_TARGET");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Turn consumption
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — turn consumption", () => {
|
||||
it("successful transfer ticks the turn (white → black)", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const king = pieceAt(engine, "e1")!;
|
||||
const queen = placePiece(engine, "queen", "white", "d1");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(engine.getCurrentTurn()).toBe("black");
|
||||
});
|
||||
|
||||
it("failed transfer does NOT tick the turn", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const king = pieceAt(engine, "e1")!;
|
||||
const bqueen = placePiece(engine, "queen", "black", "d1");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: bqueen,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
// Turn unchanged — the mover may try again.
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Composition with other presets
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — composition with knightmate-rules", () => {
|
||||
it("base royalty is the knight set; transfer moves royalty to a non-knight", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine, { preserveKings: false });
|
||||
// Knightmate: white has 2 knights (royals). Transfer one → queen.
|
||||
const kn1 = placePiece(engine, "knight", "white", "b1");
|
||||
const kn2 = placePiece(engine, "knight", "white", "g1");
|
||||
const queen = placePiece(engine, "queen", "white", "d1");
|
||||
placePiece(engine, "knight", "black", "b8");
|
||||
placePiece(engine, "knight", "black", "g8");
|
||||
engine.setActivePresets([KNIGHTMATE, XROY]);
|
||||
|
||||
// Pre-transfer: both knights are royal.
|
||||
const preRoyals = engine.getActiveRoyalEntityIds("white") ?? [];
|
||||
expect(preRoyals).toContain(kn1);
|
||||
expect(preRoyals).toContain(kn2);
|
||||
expect(preRoyals).not.toContain(queen);
|
||||
|
||||
const result = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: kn1,
|
||||
toPieceId: queen,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
// Post-transfer: kn1 no longer royal; queen IS royal; kn2
|
||||
// remains royal (the preset transforms only the transferredFrom
|
||||
// entry, leaving other upstream contributions intact).
|
||||
const postRoyals = engine.getActiveRoyalEntityIds("white") ?? [];
|
||||
expect(postRoyals).not.toContain(kn1);
|
||||
expect(postRoyals).toContain(kn2);
|
||||
expect(postRoyals).toContain(queen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferable-royalty — composition with piece-hp", () => {
|
||||
it("transferred royal has Hp and takes damage normally", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([PIECE_HP, XROY]);
|
||||
clearBoard(engine);
|
||||
const king = pieceAt(engine, "e1")!;
|
||||
// Spawn queen through the engine so onPieceSpawn seeds Hp.
|
||||
const queen = engine.spawnPiece("queen", "white", 3, {
|
||||
reason: "summon",
|
||||
});
|
||||
placePiece(engine, "king", "black", "h8");
|
||||
|
||||
const transfer = engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: king,
|
||||
toPieceId: queen,
|
||||
});
|
||||
expect(transfer.ok).toBe(true);
|
||||
|
||||
// Queen has HP (seeded by piece-hp on spawn).
|
||||
expect(engine.session.contains(queen, "Hp")).toBe(true);
|
||||
const hpBefore = engine.session.get(queen, "Hp") as number;
|
||||
|
||||
// Deal damage: the queen survives (HP absorbs the hit).
|
||||
const damage = engine.dealDamage(queen, 1, { kind: "capture" });
|
||||
expect(damage.died).toBe(false);
|
||||
const hpAfter = engine.session.get(queen, "Hp") as number;
|
||||
expect(hpAfter).toBe(hpBefore - 1);
|
||||
|
||||
// Queen is still royal — HP damage didn't remove it from the
|
||||
// royal set (transfer record still points at it).
|
||||
const royals = engine.getActiveRoyalEntityIds("white") ?? [];
|
||||
expect(royals).toContain(queen);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Sanity: transfer survives across a subsequent move
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transferable-royalty — post-transfer gameplay", () => {
|
||||
it("after transfer, making a regular move works + state persists", () => {
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
const wking = pieceAt(engine, "e1")!;
|
||||
const wqueen = placePiece(engine, "queen", "white", "d1");
|
||||
placePiece(engine, "king", "black", "e8");
|
||||
engine.setActivePresets([XROY]);
|
||||
|
||||
// White transfers (white's turn consumed).
|
||||
engine.performAction({
|
||||
kind: "transfer-royalty",
|
||||
fromPieceId: wking,
|
||||
toPieceId: wqueen,
|
||||
});
|
||||
expect(engine.getCurrentTurn()).toBe("black");
|
||||
|
||||
// Black plays a normal move.
|
||||
const blackMove = engine.getAllLegalMoves()[0];
|
||||
expect(blackMove).toBeDefined();
|
||||
engine.applyMove(blackMove!);
|
||||
|
||||
// State still reflects white's transfer.
|
||||
const state = engine.presetState(TRANSFERABLE_ROYALTY_ID);
|
||||
expect(state.get("transferredFrom:white")).toBe(wking);
|
||||
expect(state.get("transferredTo:white")).toBe(wqueen);
|
||||
// Royal set still shows queen as royal.
|
||||
const royals = engine.getActiveRoyalEntityIds("white") ?? [];
|
||||
expect(royals).toContain(wqueen);
|
||||
expect(royals).not.toContain(wking);
|
||||
});
|
||||
});
|
||||
279
packages/chess/src/presets/transferable-royalty.ts
Normal file
279
packages/chess/src/presets/transferable-royalty.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Preset: `transferable-royalty` (post-epic-deferrals Feature 4).
|
||||
*
|
||||
* Royal pieces may TRANSFER their royal designation once per game
|
||||
* to a friendly piece. Dispatched via the new `PlayerAction` engine
|
||||
* surface (see `../actions.ts`): the mover submits a
|
||||
* `{ kind: "transfer-royalty", fromPieceId, toPieceId }` action;
|
||||
* the engine consults every active preset's `performAction` hook
|
||||
* and this preset claims the kind.
|
||||
*
|
||||
* Design intent: royalty TRANSFORMATION rather than replacement.
|
||||
* This preset layers ON TOP of whatever other royalty-defining
|
||||
* preset is active (knightmate-rules, dual-king, coregal, weak-
|
||||
* dual-king, or the default king-only rule). It implements the
|
||||
* `transformRoyalPieces` hook — runs AFTER the engine has unioned
|
||||
* every other preset's `getRoyalPieces` contributions, so it sees
|
||||
* the full accumulated royal set and can swap out the transferred-
|
||||
* from id for the transferred-to id. When no other royalty preset
|
||||
* is active, the engine seeds the transformer with the default
|
||||
* "every PieceType=king of this color" set.
|
||||
*
|
||||
* Turn cost: a successful transfer CONSUMES the mover's turn (the
|
||||
* engine's `advanceTurnAfterMutation` path runs post-handler). This
|
||||
* is a balance decision — using royalty transfer as a "free" action
|
||||
* would let a player swap royalty and then also move on the same
|
||||
* turn, which reduces it to a defensive panic button with no
|
||||
* opportunity cost. Locked in before implementation per the task's
|
||||
* decision log.
|
||||
*
|
||||
* Per-color cap: each side may transfer ONCE PER GAME. The cap is
|
||||
* stored in preset-scoped state (see `PresetState`); state
|
||||
* auto-clears when the preset deactivates, so toggling the preset
|
||||
* off and back on resets the cap.
|
||||
*
|
||||
* Multiplayer: NOT WIRED in v1. Solo dispatch works end-to-end;
|
||||
* F4b lands the `game.action` WS message so the action round-trips
|
||||
* through the authoritative server. The preset itself is
|
||||
* multiplayer-ready — the state is plain facts, the handler is
|
||||
* deterministic given session state — F4b just needs to add the
|
||||
* protocol surface.
|
||||
*
|
||||
* UI: DEFERRED to F4c. The engine API is sufficient for scripted
|
||||
* driving (tests + console) in v1.
|
||||
*
|
||||
* Incompatibility:
|
||||
* - `suicide-chess`, `capture-all` — both presets declare EMPTY
|
||||
* royal sets (games end by annihilation / compulsory capture).
|
||||
* Transferring royalty has no meaning when no piece is royal.
|
||||
* - Compatible with knightmate-rules, dual-king, weak-dual-king,
|
||||
* coregal — those presets DEFINE the initial royal set;
|
||||
* transferable-royalty reassigns WITHIN that set.
|
||||
*
|
||||
* State shape: two maps (`transferredFrom`, `transferredTo`), one
|
||||
* entry per color. Absent entry = that color hasn't transferred.
|
||||
* Values are EntityIds pointing at the original / new royal piece
|
||||
* respectively — retained even if the piece dies, because the dead
|
||||
* piece's id is no longer in any royal contribution anyway (the
|
||||
* transferFrom set is a NO-OP in that case).
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { PieceColor } from "../schema.js";
|
||||
import type { ActionResult } from "../actions.js";
|
||||
|
||||
/** Preset id — exported as a const so tests can reference it
|
||||
* without risking a typo going unnoticed at refactor time. */
|
||||
export const TRANSFERABLE_ROYALTY_ID = "transferable-royalty";
|
||||
|
||||
/** State shape for this preset. Two maps, one entry per color. */
|
||||
interface TransferableRoyaltyState extends Record<string, unknown> {
|
||||
readonly transferredFrom: Record<PieceColor, EntityId>;
|
||||
readonly transferredTo: Record<PieceColor, EntityId>;
|
||||
}
|
||||
|
||||
/** Runtime accessor. Each field lives under its own state key so
|
||||
* the state bag serializes cleanly (no nested object plumbing). */
|
||||
interface StateKeys {
|
||||
readonly [K: string]: EntityId | undefined;
|
||||
}
|
||||
|
||||
// State key naming: `transferredFrom:<color>` / `transferredTo:<color>`.
|
||||
// Two keys per color; absence of the key means "has not transferred".
|
||||
const fromKey = (c: PieceColor): string => `transferredFrom:${c}`;
|
||||
const toKey = (c: PieceColor): string => `transferredTo:${c}`;
|
||||
|
||||
/** True iff `id` still has a PieceType fact (i.e. alive on the board). */
|
||||
function isAlive(engine: { session: { contains: (e: EntityId, a: string) => boolean } }, id: EntityId): boolean {
|
||||
return engine.session.contains(id, "PieceType");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback royal set when NO preset contributed to
|
||||
* `engine.getActiveRoyalEntityIds`. Mirrors the engine's own default
|
||||
* (every live PieceType=king of `color`). Duplicated here because
|
||||
* the engine keeps its equivalent helper private.
|
||||
*/
|
||||
function defaultKingRoyals(
|
||||
engine: { session: { allFacts: () => ReadonlyArray<{ id: EntityId; attr: string; value: unknown }> } },
|
||||
color: PieceColor,
|
||||
): EntityId[] {
|
||||
const out: EntityId[] = [];
|
||||
const facts = engine.session.allFacts();
|
||||
// First pass: find every king id.
|
||||
const kingIds: EntityId[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr === "PieceType" && f.value === "king") {
|
||||
kingIds.push(f.id);
|
||||
}
|
||||
}
|
||||
// Second pass: keep those whose Color matches AND who have a live
|
||||
// Position (dead kings have their facts retracted).
|
||||
for (const id of kingIds) {
|
||||
let colorOk = false;
|
||||
let alive = false;
|
||||
for (const f of facts) {
|
||||
if (f.id !== id) continue;
|
||||
if (f.attr === "Color" && f.value === color) colorOk = true;
|
||||
if (f.attr === "Position") alive = true;
|
||||
}
|
||||
if (colorOk && alive) out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "king",
|
||||
id: TRANSFERABLE_ROYALTY_ID,
|
||||
name: "Transferable Royalty",
|
||||
description:
|
||||
"Royal pieces may transfer their royalty once per game to a friendly piece. The transfer consumes the mover's turn.",
|
||||
incompatibleWith: [
|
||||
// Reciprocal: these presets empty the royal set entirely — no
|
||||
// royalty means nothing to transfer.
|
||||
"suicide-chess",
|
||||
"capture-all",
|
||||
],
|
||||
requires: [],
|
||||
|
||||
/**
|
||||
* Dispatch the `transfer-royalty` action kind. Returns `undefined`
|
||||
* (no-op) for every other kind, letting other presets claim those.
|
||||
*/
|
||||
performAction({ engine, mover, action }): ActionResult | undefined {
|
||||
if (action.kind !== "transfer-royalty") return undefined;
|
||||
|
||||
const state = engine.presetState<TransferableRoyaltyState & StateKeys>(
|
||||
TRANSFERABLE_ROYALTY_ID,
|
||||
);
|
||||
|
||||
// Per-color cap: the FROM key's presence is the "already
|
||||
// transferred" flag. Check it BEFORE target validation so a
|
||||
// second attempt gets a clear REJECTED result rather than a
|
||||
// misleading INVALID_TARGET (the from-piece might no longer be
|
||||
// royal after the first transfer).
|
||||
if (state.has(fromKey(mover) as never)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "REJECTED",
|
||||
reason: `${mover} has already transferred royalty this game`,
|
||||
};
|
||||
}
|
||||
|
||||
const { fromPieceId, toPieceId } = action;
|
||||
|
||||
// fromPieceId must be alive.
|
||||
if (!isAlive(engine, fromPieceId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "INVALID_TARGET",
|
||||
reason: "Source piece is no longer on the board",
|
||||
};
|
||||
}
|
||||
|
||||
// fromPieceId must currently be royal for mover. We consult the
|
||||
// engine's full royal resolver (which unions every preset's
|
||||
// contribution INCLUDING ours — but we haven't recorded the
|
||||
// transfer yet, so our own contribution is the "pre-transfer"
|
||||
// set, which is what we want for validation).
|
||||
const currentRoyals = engine.getActiveRoyalEntityIds(mover);
|
||||
const royalSet = new Set<EntityId>(
|
||||
currentRoyals ??
|
||||
// Default royalty path: every PieceType=king of this color.
|
||||
defaultKingRoyals(engine, mover),
|
||||
);
|
||||
if (!royalSet.has(fromPieceId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "INVALID_TARGET",
|
||||
reason: "Source piece is not currently royal",
|
||||
};
|
||||
}
|
||||
|
||||
// toPieceId must be alive.
|
||||
if (!isAlive(engine, toPieceId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "INVALID_TARGET",
|
||||
reason: "Target piece is no longer on the board",
|
||||
};
|
||||
}
|
||||
|
||||
// toPieceId must be SAME COLOR as mover.
|
||||
const toColor = engine.session.get(toPieceId, "Color") as
|
||||
| PieceColor
|
||||
| undefined;
|
||||
if (toColor !== mover) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "INVALID_TARGET",
|
||||
reason: "Target piece is not friendly",
|
||||
};
|
||||
}
|
||||
|
||||
// toPieceId must NOT already be royal — transferring TO an
|
||||
// already-royal piece would be a no-op on the royal set and
|
||||
// consume the one-shot capability for zero effect.
|
||||
if (royalSet.has(toPieceId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "INVALID_TARGET",
|
||||
reason: "Target piece is already royal",
|
||||
};
|
||||
}
|
||||
|
||||
// All checks passed — record the transfer. The engine consumes
|
||||
// the turn via `advanceTurnAfterMutation` after we return.
|
||||
state.set(fromKey(mover) as never, fromPieceId as never);
|
||||
state.set(toKey(mover) as never, toPieceId as never);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform the royal-set union AFTER other presets' `getRoyalPieces`
|
||||
* contributions have merged.
|
||||
*
|
||||
* Receives the accumulated royal set for `color` and, when this
|
||||
* color has transferred, swaps the transferredFrom id for the
|
||||
* transferredTo id. Dead entries (captured pieces that lost their
|
||||
* PieceType fact) are dropped defensively so a transferred royal
|
||||
* that later died doesn't linger.
|
||||
*
|
||||
* When no transfer is recorded for this color, the transformer
|
||||
* returns the input unchanged — a no-op that still keeps this
|
||||
* preset "participating" in the royal-set resolution (important
|
||||
* for the engine's "at least one preset transformed" bookkeeping
|
||||
* when this is the ONLY royalty-touching preset active; the
|
||||
* engine seeds the baseline with the default king-only set in
|
||||
* that case).
|
||||
*
|
||||
* Performance: O(N) over the input set (tiny — usually 1-3
|
||||
* ids). Called on every legal-move generation; negligible cost.
|
||||
*/
|
||||
transformRoyalPieces({ engine, color }, current): readonly EntityId[] {
|
||||
const state = engine.presetState<TransferableRoyaltyState & StateKeys>(
|
||||
TRANSFERABLE_ROYALTY_ID,
|
||||
);
|
||||
const transferredFrom = state.get(fromKey(color) as never) as
|
||||
| EntityId
|
||||
| undefined;
|
||||
const transferredTo = state.get(toKey(color) as never) as
|
||||
| EntityId
|
||||
| undefined;
|
||||
|
||||
if (transferredFrom === undefined || transferredTo === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const out: EntityId[] = [];
|
||||
for (const id of current) {
|
||||
if (id === transferredFrom) continue;
|
||||
if (!isAlive(engine, id)) continue;
|
||||
out.push(id);
|
||||
}
|
||||
if (isAlive(engine, transferredTo) && !out.includes(transferredTo)) {
|
||||
out.push(transferredTo);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue