feat(chess): starting-layout foundation (Phase A)

Introduces a pluggable StartingLayout abstraction so the engine can
open from positions other than FIDE without per-caller special casing.

- layouts/{types,registry,index}.ts: StartingLayout + LAYOUT_REGISTRY,
  mirroring the PRESET_REGISTRY / PIECE_TYPE_REGISTRY pattern.
- starting-position.ts: CLASSIC_LAYOUT + applyLayout(session, layout)
  as the parametrized spawn path. generateStartingPosition stays as
  a thin back-compat wrapper so no existing call site changes.
- layouts/{classic,empty}.ts: first two premades registered via
  side-effect imports from the barrel.
- engine.ts: ChessEngine constructor now accepts either legacy
  (activePresets) positional or new options-bag form
  ({ activePresets?, layout? }). Detection uses a method-shape
  probe rather than instanceof so both overload forms compose
  cleanly under strict TS.
- Tests: 11 new tests in starting-position.test.ts + engine-presets
  cover applyLayout ordering, hasMoved pre-revocation, empty
  layout, classic equivalence, options-bag equivalence with legacy.

Also lands the full execution plans for starting-layouts and
rule-variants under .sisyphus/plans/ (both Momus-reviewed OKAY).

941 tests passing; bun run check clean.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 19:44:01 -06:00
commit d93bcf6c81
No known key found for this signature in database
12 changed files with 1319 additions and 26 deletions

View file

@ -12,7 +12,8 @@ import {
type PieceType,
type PieceColor,
} from "./schema.js";
import { generateStartingPosition } from "./starting-position.js";
import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js";
import type { StartingLayout } from "./layouts/types.js";
import {
getCastlingMoves,
applyCastlingMove,
@ -282,6 +283,24 @@ class PresetStateImpl<T extends Record<string, unknown>>
}
}
/**
* Options bag for the `ChessEngine` constructor.
*
* All fields optional. Omitted fields use a sensible default:
* - `activePresets` → a fresh empty ActivePresetSet (no presets).
* - `layout` → `CLASSIC_LAYOUT` (standard FIDE starting position).
*
* Passing a `layout` is how callers start from a non-FIDE position
* (Dunsany, Monster, Horde, custom editor output, etc.). The engine
* applies the layout FIRST, then activates presets, so every
* preset's `onActivate` scans the layout's pieces — no per-layout
* special-casing inside presets.
*/
export interface EngineOptions {
readonly activePresets?: ActivePresetSet;
readonly layout?: StartingLayout;
}
export class ChessEngine {
public readonly session: Session;
/**
@ -360,11 +379,50 @@ export class ChessEngine {
};
}
constructor(activePresets?: ActivePresetSet) {
/**
* Construct a new engine.
*
* Two calling conventions are supported:
*
* 1. Legacy (no arg, or `activePresets` only):
* `new ChessEngine()`
* `new ChessEngine(activePresets)`
*
* 2. Options bag (preferred for new code):
* `new ChessEngine({ activePresets, layout })`
* `new ChessEngine({ layout: EMPTY_LAYOUT })`
*
* The options bag is how new callers specify a starting layout
* other than FIDE. When `layout` is omitted, defaults to
* `CLASSIC_LAYOUT` (the FIDE starting position), which keeps every
* pre-layouts call site working unchanged.
*
* `activePresets` is applied AFTER the layout, so every preset's
* `onActivate` hook sees the initial board state — piece-hp seeds
* `Hp` on every piece in the layout (FIDE, Dunsany, Horde, …) with
* no per-layout plumbing.
*/
constructor(activePresets?: ActivePresetSet);
constructor(opts: EngineOptions);
constructor(arg?: ActivePresetSet | EngineOptions) {
this.session = new Session({ autoFire: false });
generateStartingPosition(this.session);
// Normalize the argument: ActivePresetSet stays as-is (legacy
// form); an options bag destructures. We detect by checking for
// `replaceAll` — an internal ActivePresetSet method that isn't
// on plain option bags.
const isLegacyPresetArg =
arg !== undefined &&
arg !== null &&
typeof (arg as ActivePresetSet).replaceAll === "function";
const opts: EngineOptions = isLegacyPresetArg
? { activePresets: arg as ActivePresetSet }
: ((arg as EngineOptions | undefined) ?? {});
const layout = opts.layout ?? CLASSIC_LAYOUT;
applyLayout(this.session, layout);
recordPosition(this.session);
this.activePresets = activePresets ?? new ActivePresetSet();
this.activePresets = opts.activePresets ?? new ActivePresetSet();
}
/**