From 8220f1507ec8414f90d2f8b4665f68f98ae31321 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 11:30:24 -0600 Subject: [PATCH] feat(engine): PlayerAction + transferable-royalty preset (solo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- packages/chess/src/actions.ts | 106 ++++ .../chess/src/engine.performAction.test.ts | 270 +++++++++ packages/chess/src/engine.ts | 370 +++++++++--- packages/chess/src/presets/capture-all.ts | 3 + packages/chess/src/presets/index.ts | 1 + packages/chess/src/presets/presets.test.ts | 1 + packages/chess/src/presets/registry.ts | 90 +++ packages/chess/src/presets/suicide-chess.ts | 1 + .../src/presets/transferable-royalty.test.ts | 537 ++++++++++++++++++ .../chess/src/presets/transferable-royalty.ts | 279 +++++++++ 10 files changed, 1586 insertions(+), 72 deletions(-) create mode 100644 packages/chess/src/actions.ts create mode 100644 packages/chess/src/engine.performAction.test.ts create mode 100644 packages/chess/src/presets/transferable-royalty.test.ts create mode 100644 packages/chess/src/presets/transferable-royalty.ts diff --git a/packages/chess/src/actions.ts b/packages/chess/src/actions.ts new file mode 100644 index 0000000..967edca --- /dev/null +++ b/packages/chess/src/actions.ts @@ -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; +} diff --git a/packages/chess/src/engine.performAction.test.ts b/packages/chess/src/engine.performAction.test.ts new file mode 100644 index 0000000..011589d --- /dev/null +++ b/packages/chess/src/engine.performAction.test.ts @@ -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; + }; + 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); + }); +}); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index fd64643..f072d4e 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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(); + 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(); + 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: diff --git a/packages/chess/src/presets/capture-all.ts b/packages/chess/src/presets/capture-all.ts index 55fff74..4c5dc7a 100644 --- a/packages/chess/src/presets/capture-all.ts +++ b/packages/chess/src/presets/capture-all.ts @@ -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: [], diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 5637e70..feae768 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index ee8345e..2ef8c56 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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", () => { diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 077d47d..04d6e5f 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -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; } /** diff --git a/packages/chess/src/presets/suicide-chess.ts b/packages/chess/src/presets/suicide-chess.ts index 7f41074..c5c9cf7 100644 --- a/packages/chess/src/presets/suicide-chess.ts +++ b/packages/chess/src/presets/suicide-chess.ts @@ -151,6 +151,7 @@ PRESET_REGISTRY.register({ "coregal", "dual-king", "weak-dual-king", + "transferable-royalty", ], requires: [], diff --git a/packages/chess/src/presets/transferable-royalty.test.ts b/packages/chess/src/presets/transferable-royalty.test.ts new file mode 100644 index 0000000..2bbe68f --- /dev/null +++ b/packages/chess/src/presets/transferable-royalty.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/presets/transferable-royalty.ts b/packages/chess/src/presets/transferable-royalty.ts new file mode 100644 index 0000000..55620e5 --- /dev/null +++ b/packages/chess/src/presets/transferable-royalty.ts @@ -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 { + readonly transferredFrom: Record; + readonly transferredTo: Record; +} + +/** 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:` / `transferredTo:`. +// 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( + 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( + 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( + 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; + }, +});