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:
Joey Yakimowich-Payne 2026-04-21 11:30:24 -06:00
commit 8220f1507e
No known key found for this signature in database
10 changed files with 1586 additions and 72 deletions

View 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;
}

View 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);
});
});

View file

@ -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:

View file

@ -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: [],

View file

@ -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";

View file

@ -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", () => {

View file

@ -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;
}
/**

View file

@ -151,6 +151,7 @@ PRESET_REGISTRY.register({
"coregal",
"dual-king",
"weak-dual-king",
"transferable-royalty",
],
requires: [],

View 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);
});
});

View 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;
},
});