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

@ -10,7 +10,12 @@
import { describe, it, expect } from "vitest";
import { ChessEngine } from "./engine.js";
import "./presets/index.js";
import "./layouts/index.js";
import { algebraicToSquare } from "./coord.js";
import { EMPTY_LAYOUT } from "./layouts/empty.js";
import { CLASSIC_LAYOUT } from "./layouts/classic.js";
import { ActivePresetSet } from "./presets/active-set.js";
import { GAME_ENTITY } from "./schema.js";
describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => {
it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => {
@ -229,3 +234,55 @@ describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => {
});
});
});
// ─────────────────────────────────────────────────────────────────────
// EngineOptions: layout + activePresets via options-bag constructor
// ─────────────────────────────────────────────────────────────────────
describe("ChessEngine({ layout, activePresets }) — options-bag constructor", () => {
it("{ layout: EMPTY_LAYOUT } produces an engine with zero piece facts", () => {
const engine = new ChessEngine({ layout: EMPTY_LAYOUT });
const pieceTypeFacts = engine.session
.allFacts()
.filter((f) => f.attr === "PieceType" && f.id !== GAME_ENTITY);
expect(pieceTypeFacts).toHaveLength(0);
});
it("{ layout: EMPTY_LAYOUT } still seeds game-level facts (Turn=white, etc.)", () => {
const engine = new ChessEngine({ layout: EMPTY_LAYOUT });
expect(engine.session.get(GAME_ENTITY, "Turn")).toBe("white");
expect(engine.session.get(GAME_ENTITY, "GameStatus")).toBe("active");
});
it("{ layout: CLASSIC_LAYOUT } matches the legacy new ChessEngine() output", () => {
const legacy = new ChessEngine();
const viaOpts = new ChessEngine({ layout: CLASSIC_LAYOUT });
// Compare piece-fact sets; entity ids should match since both
// sessions start from 0 and apply identical layouts in identical
// order.
const factKey = (f: { id: number; attr: string; value: unknown }) =>
`${f.id}|${f.attr}|${String(f.value)}`;
const a = new Set(legacy.session.allFacts().map(factKey));
const b = new Set(viaOpts.session.allFacts().map(factKey));
expect(a).toEqual(b);
});
it("{} (empty options bag) defaults to CLASSIC_LAYOUT + empty preset set", () => {
const engine = new ChessEngine({});
expect(engine.activePresets.list()).toHaveLength(0);
const pieces = engine.session
.allFacts()
.filter((f) => f.attr === "PieceType" && f.id !== GAME_ENTITY);
expect(pieces).toHaveLength(32); // standard FIDE
});
it("{ activePresets } via options bag works identically to positional form", () => {
const presets = new ActivePresetSet();
presets.replaceAll([
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
]);
const engine = new ChessEngine({ activePresets: presets });
expect(engine.activePresets.list()).toHaveLength(1);
expect(engine.activePresets.list()[0]?.id).toBe("pawns-move-backward");
});
});

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

View file

@ -0,0 +1,16 @@
/**
* Layout: Classic Chess (FIDE).
*
* Registers the standard FIDE starting position. The actual layout
* data lives in `starting-position.ts` as `CLASSIC_LAYOUT` — that
* module is the canonical source of truth for FIDE piece placement,
* shared with the back-compat `generateStartingPosition(session)`
* helper. This file is a one-line registration so the picker
* includes "Classic" in its dropdown without any duplication.
*/
import { CLASSIC_LAYOUT } from "../starting-position.js";
import { LAYOUT_REGISTRY } from "./registry.js";
LAYOUT_REGISTRY.register(CLASSIC_LAYOUT);
export { CLASSIC_LAYOUT };

View file

@ -0,0 +1,27 @@
/**
* Layout: Empty board (sandbox).
*
* Zero pieces. Useful as:
* - The "base" of the custom layout editor — open this and the
* board is blank; drag pieces from the palette to compose.
* - A test fixture for engine code that shouldn't assume any
* pieces exist.
*
* An empty layout will NOT validate for game-play (the validator
* requires one king per side). The lobby's Create Room button stays
* disabled when the editor commits an empty layout; the editor
* surfaces the validation error explicitly.
*/
import type { StartingLayout } from "./types.js";
import { LAYOUT_REGISTRY } from "./registry.js";
export const EMPTY_LAYOUT: StartingLayout = {
id: "empty",
name: "Empty Board",
description:
"A blank board for composing your own position from scratch.",
pieces: [],
source: "premade",
};
LAYOUT_REGISTRY.register(EMPTY_LAYOUT);

View file

@ -0,0 +1,28 @@
/**
* Starting Layouts barrel + registration side-effects.
*
* Importing this module guarantees every premade layout has been
* registered in `LAYOUT_REGISTRY`. Consumers (engine, lobby,
* server) should import `./index.js` rather than the registry
* directly so the side-effect imports fire in a known order.
*
* Registration order determines the order layouts appear in the
* LayoutPicker dropdown. "Classic" is first so the default
* selection shows at the top; "Empty" is last so the sandbox
* option sits at the bottom.
*/
// Core layouts — always registered.
// The order here determines LayoutPicker dropdown order.
import "./classic.js";
// Sandbox — last so it's visually grouped separately in the picker.
import "./empty.js";
export { LAYOUT_REGISTRY } from "./registry.js";
export { CLASSIC_LAYOUT } from "./classic.js";
export { EMPTY_LAYOUT } from "./empty.js";
export type {
PiecePlacement,
StartingLayout,
LayoutValidationResult,
} from "./types.js";

View file

@ -0,0 +1,65 @@
/**
* Starting Layout registry.
*
* Mirrors the shape of `PRESET_REGISTRY` and `PIECE_TYPE_REGISTRY`:
* layouts register themselves via side-effect imports from their own
* module files, and `./index.ts` is the barrel that imports every
* premade.
*
* Duplicate-id registration throws — this is the signal that two
* different modules are trying to claim the same layout id, which
* would silently overwrite in a Map. Throwing surfaces the collision
* at load time rather than at runtime.
*
* Layout definitions are read-only once registered; the registry
* exposes no mutation paths. User-authored ("custom") layouts travel
* by-value (in network payloads, editor state) and never enter the
* registry.
*/
import type { StartingLayout } from "./types.js";
class LayoutRegistryClass {
readonly #byId = new Map<string, StartingLayout>();
/**
* Register a layout under its `id`. Throws if the id is already
* taken — a defensive check because silently overwriting would
* create confusing debugging situations (the "last registered
* wins" race depends on module import order).
*/
register(layout: StartingLayout): void {
if (this.#byId.has(layout.id)) {
throw new Error(
`LayoutRegistry: duplicate layout id "${layout.id}". ` +
`Each starting layout must have a unique id.`,
);
}
this.#byId.set(layout.id, layout);
}
/**
* Look up a layout by id. Returns undefined for unknown ids — the
* caller decides whether that's an error (the server rejecting an
* unknown premade in `room.create`) or a benign miss (the lobby
* falling back to "classic").
*/
get(id: string): StartingLayout | undefined {
return this.#byId.get(id);
}
/**
* All registered layouts, in registration (= import) order. The
* LayoutPicker UI relies on this being a stable order so the
* dropdown doesn't shuffle between renders.
*/
list(): readonly StartingLayout[] {
return [...this.#byId.values()];
}
/** True if a layout with the given id is registered. */
has(id: string): boolean {
return this.#byId.has(id);
}
}
export const LAYOUT_REGISTRY = new LayoutRegistryClass();

View file

@ -0,0 +1,84 @@
/**
* Starting Layout types.
*
* A `StartingLayout` describes WHICH pieces start WHERE on the board
* at game construction time. It is ORTHOGONAL to rule presets:
*
* - Layouts control initial position (Dunsany, Monster, Chess960, …)
* - Presets control gameplay rules (HP, king-heals, cylinder, …)
*
* A single game has exactly one layout and zero or more active presets.
* Selecting a layout does NOT imply enabling any preset — premades
* publish `suggestedPresets` as an advisory hint only; the user decides
* whether to activate them.
*
* Layouts are registered in `LAYOUT_REGISTRY` (registry.ts) via
* side-effect imports, mirroring `PRESET_REGISTRY` and
* `PIECE_TYPE_REGISTRY`.
*/
import type { PieceType, PieceColor, Square } from "../schema.js";
/**
* A single piece placement in a starting layout.
*
* `hasMoved` is optional (defaults to false). Useful for layouts that
* want castling rights pre-revoked, or for pawns that shouldn't have
* their double-push option (e.g., a layout with pawns starting on
* their second move rank rather than home rank).
*/
export interface PiecePlacement {
readonly type: PieceType;
readonly color: PieceColor;
readonly square: Square;
readonly hasMoved?: boolean;
}
/**
* A complete starting layout.
*
* Identity:
* - `id` — stable kebab-case string used in URLs, protocol, storage.
* Never change an existing premade id; ship a new id instead.
* - `name` — human-readable display label.
* - `description` — one or two sentences of flavor text surfaced
* in the lobby picker.
*
* Content:
* - `pieces` — every piece placed on the board at game start. A
* layout with zero pieces is a valid "empty sandbox".
*
* Hints:
* - `suggestedPresets` — preset ids that the layout's authors
* recommend pairing. UI displays these as dimmed chips with a
* one-click enable; the user is never forced. A preset id here
* that doesn't exist in PRESET_REGISTRY is rendered as "coming
* soon" rather than an error — this lets layouts ship before
* their matching presets do.
*
* Provenance:
* - `source` — `"premade"` for layouts registered in LAYOUT_REGISTRY;
* `"custom"` for user-authored layouts passed by-value through the
* network (`room.create` payload) and the editor.
*/
export interface StartingLayout {
readonly id: string;
readonly name: string;
readonly description: string;
readonly pieces: readonly PiecePlacement[];
readonly suggestedPresets?: readonly string[];
readonly source: "premade" | "custom";
}
/**
* Outcome of `validateLayout(layout)`.
*
* `errors` block activation (the layout cannot be used). `warnings`
* are surfaced to the user but do not block — a pawn starting on rank
* 8 is legal, just unusual (and unable to move).
*
* Both arrays are empty for a layout the validator fully approves.
*/
export interface LayoutValidationResult {
readonly errors: readonly string[];
readonly warnings: readonly string[];
}

View file

@ -1,7 +1,13 @@
import { describe, it, expect } from "vitest";
import { generateStartingPosition, STARTING_PIECES_DEF } from "./starting-position.js";
import {
CLASSIC_LAYOUT,
applyLayout,
generateStartingPosition,
STARTING_PIECES_DEF,
} from "./starting-position.js";
import { Session } from "@paratype/rete";
import { GAME_ENTITY } from "./schema.js";
import type { StartingLayout } from "./layouts/types.js";
describe("generateStartingPosition()", () => {
it("inserts exactly 32 piece entities", () => {
@ -118,3 +124,110 @@ describe("generateStartingPosition()", () => {
expect(session.get(GAME_ENTITY, "Winner")).toBe(null);
});
});
// ─────────────────────────────────────────────────────────────────────
// applyLayout() — the parametrized spawn path
// ─────────────────────────────────────────────────────────────────────
describe("applyLayout()", () => {
it("applying CLASSIC_LAYOUT matches generateStartingPosition's piece count (32)", () => {
const a = new Session({ autoFire: false });
const b = new Session({ autoFire: false });
const idsA = applyLayout(a, CLASSIC_LAYOUT);
const idsB = generateStartingPosition(b);
expect(idsA).toHaveLength(32);
expect(idsB).toHaveLength(32);
expect(idsA).toEqual(idsB); // same EntityId sequence (sessions start fresh)
});
it("applying CLASSIC_LAYOUT produces the same piece facts as generateStartingPosition", () => {
const a = new Session({ autoFire: false });
const b = new Session({ autoFire: false });
applyLayout(a, CLASSIC_LAYOUT);
generateStartingPosition(b);
// Compare piece facts (ignore game-level ones; both paths insert
// identical game-level facts).
const factKey = (f: { id: number; attr: string; value: unknown }) =>
`${f.id}|${f.attr}|${String(f.value)}`;
const factsA = new Set(
a.allFacts().filter((f) => f.id !== GAME_ENTITY).map(factKey),
);
const factsB = new Set(
b.allFacts().filter((f) => f.id !== GAME_ENTITY).map(factKey),
);
expect(factsA).toEqual(factsB);
});
it("empty layout inserts zero piece facts but still seeds game-level facts", () => {
const empty: StartingLayout = {
id: "test-empty",
name: "Test Empty",
description: "zero pieces",
pieces: [],
source: "custom",
};
const session = new Session({ autoFire: false });
const ids = applyLayout(session, empty);
expect(ids).toHaveLength(0);
const pieceFacts = session
.allFacts()
.filter((f) => f.id !== GAME_ENTITY);
expect(pieceFacts).toHaveLength(0);
// Game facts still seeded.
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active");
});
it("placement.hasMoved=true is honored (pre-revokes castling rights)", () => {
const layout: StartingLayout = {
id: "test-moved",
name: "Test Moved King",
description: "single king with hasMoved=true",
pieces: [
{ type: "king", color: "white", square: 4, hasMoved: true },
{ type: "king", color: "black", square: 60 },
],
source: "custom",
};
const session = new Session({ autoFire: false });
const ids = applyLayout(session, layout);
expect(ids).toHaveLength(2);
// White king has HasMoved=true; black king defaults to false.
const whiteKingMoved = session.get(ids[0]!, "HasMoved");
const blackKingMoved = session.get(ids[1]!, "HasMoved");
expect(whiteKingMoved).toBe(true);
expect(blackKingMoved).toBe(false);
});
it("CLASSIC_LAYOUT is registered with source='premade' and id='classic'", () => {
expect(CLASSIC_LAYOUT.id).toBe("classic");
expect(CLASSIC_LAYOUT.source).toBe("premade");
expect(CLASSIC_LAYOUT.pieces).toHaveLength(32);
});
it("pieces are inserted in layout.pieces order (EntityId sequence matches)", () => {
const layout: StartingLayout = {
id: "test-order",
name: "Test Order",
description: "two pieces in specific order",
pieces: [
{ type: "rook", color: "white", square: 0 },
{ type: "king", color: "white", square: 4 },
],
source: "custom",
};
const session = new Session({ autoFire: false });
const ids = applyLayout(session, layout);
expect(ids).toHaveLength(2);
// First id → rook at square 0, second id → king at square 4.
expect(session.get(ids[0]!, "PieceType")).toBe("rook");
expect(session.get(ids[0]!, "Position")).toBe(0);
expect(session.get(ids[1]!, "PieceType")).toBe("king");
expect(session.get(ids[1]!, "Position")).toBe(4);
});
});

View file

@ -1,23 +1,52 @@
/**
* FIDE starting position generator.
* Inserts 32 piece entities + game-level facts into a Session.
* Per SPEC.md §ID Authority, pieces get incremental EntityIds; game uses GAME_ENTITY (0).
* Starting-position generator.
*
* Post-layouts-refactor, this module is a THIN wrapper around the
* layouts subsystem:
*
* - `CLASSIC_LAYOUT` — the FIDE starting layout, defined here as
* the canonical source of truth. Re-exported and registered by
* `layouts/classic.ts` so it participates in `LAYOUT_REGISTRY`.
* - `applyLayout(session, layout)` — inserts piece facts (one
* entity per placement) + game-level facts into an empty
* Session. The SHARED path for every starting layout — classic,
* custom, Dunsany, Chess960.
* - `generateStartingPosition(session)` — legacy entry point kept
* as a back-compat wrapper over `applyLayout(session, CLASSIC_LAYOUT)`.
* Called from older test sites that pre-date the layouts feature.
*
* `applyLayout` deliberately does NOT fire `onPieceSpawn` hooks — it
* runs at session-construction time, before any preset is active, so
* there's nothing to hook. Presets that need to tag initial pieces
* (piece-hp seeding `Hp`, etc.) do so via their `onActivate` scan,
* which the engine fires after the session is seeded. This keeps the
* pipeline's two "piece birth" paths distinct:
*
* 1. Starting pieces → inserted by applyLayout, tagged by each
* preset's onActivate when the preset set is applied.
* 2. Mid-game spawns (promotion, fission, summon) → created via
* `engine.spawnPiece` which DOES fire onPieceSpawn.
*
* Per SPEC.md §ID Authority, pieces get incremental EntityIds from
* `session.nextId()`; game-level facts live on `GAME_ENTITY` (0).
*/
import type { EntityId } from "@paratype/rete";
import type { Session } from "@paratype/rete";
import {
GAME_ENTITY,
type PieceType,
type PieceColor,
type Square,
} from "./schema.js";
import type { PiecePlacement, StartingLayout } from "./layouts/types.js";
interface PieceDef {
type: PieceType;
color: PieceColor;
color: "white" | "black";
square: Square;
}
// ── FIDE starting-position data (kept as arrays for readability) ──────
const WHITE_BACK_RANK: PieceType[] = [
"rook",
"knight",
@ -83,26 +112,73 @@ function buildStartingPieces(): PieceDef[] {
const STARTING_PIECES = buildStartingPieces();
/** The canonical FIDE starting piece definitions (for testing and reference). */
export const STARTING_PIECES_DEF: ReadonlyArray<Readonly<PieceDef>> =
STARTING_PIECES;
/**
* Insert all 32 FIDE starting pieces + game-level facts into a Session.
* Each piece gets 4 facts: PieceType, Color, Position, HasMoved.
* Game level gets: Turn, HalfmoveClock, FullmoveNumber, EnPassantTarget, GameStatus, Winner.
* Returns the array of EntityIds assigned to pieces (in STARTING_PIECES order).
* The FIDE starting layout exported as a `StartingLayout` value.
*
* Lives here (rather than in `layouts/classic.ts`) so that:
* - The back-compat `generateStartingPosition` can reach it without
* importing the layouts barrel (which would transitively import
* every premade and pull them into the test harness for files
* that just want "the FIDE setup").
* - `layouts/classic.ts` is a one-line registration file with no
* duplication.
*
* `source: "premade"` so the lobby can tell it apart from user-
* authored layouts.
*/
export function generateStartingPosition(session: Session): EntityId[] {
export const CLASSIC_LAYOUT: StartingLayout = {
id: "classic",
name: "Classic Chess",
description:
"The standard FIDE starting position. 32 pieces, symmetric setup.",
pieces: STARTING_PIECES.map((p) => ({
type: p.type,
color: p.color,
square: p.square,
})) satisfies PiecePlacement[],
source: "premade",
};
/**
* Insert every piece placement in `layout` into `session`, plus the
* standard game-level facts (Turn = white, clocks = 0, etc.).
*
* Returns the array of EntityIds assigned to the pieces, in the
* order they appear in `layout.pieces`. Callers that need to
* cross-reference piece ids with their placements (tests, save-state
* inspection) rely on this order being stable.
*
* Each piece gets four facts: `PieceType`, `Color`, `Position`,
* `HasMoved`. `HasMoved` defaults to `false` unless the placement
* explicitly sets it — layouts can pre-revoke castling / double-push
* rights by setting `hasMoved: true` on specific placements.
*
* Game-level facts (Turn, HalfmoveClock, FullmoveNumber,
* EnPassantTarget, GameStatus, Winner) are inserted identically
* regardless of layout — the layout controls WHERE pieces are, not
* the clock / turn state. Clock-related presets are free to adjust
* these via `onActivate` if they want custom initial values.
*/
export function applyLayout(
session: Session,
layout: StartingLayout,
): EntityId[] {
const pieceIds: EntityId[] = [];
// Insert all 32 pieces with their attributes
for (const piece of STARTING_PIECES) {
for (const placement of layout.pieces) {
const id = session.nextId();
session.insert(id, "PieceType", piece.type);
session.insert(id, "Color", piece.color);
session.insert(id, "Position", piece.square);
session.insert(id, "HasMoved", false);
session.insert(id, "PieceType", placement.type);
session.insert(id, "Color", placement.color);
session.insert(id, "Position", placement.square);
session.insert(id, "HasMoved", placement.hasMoved ?? false);
pieceIds.push(id);
}
// Game-level facts
// Game-level facts — uniform across every layout.
session.insert(GAME_ENTITY, "Turn", "white");
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
@ -113,6 +189,13 @@ export function generateStartingPosition(session: Session): EntityId[] {
return pieceIds;
}
/** The canonical FIDE starting piece definitions (for testing and reference). */
export const STARTING_PIECES_DEF: ReadonlyArray<Readonly<PieceDef>> =
STARTING_PIECES;
/**
* Back-compat wrapper: apply the FIDE starting layout to `session`.
*
* Pre-layouts-feature callers (tests mostly) use this. New code
* should call `applyLayout(session, layout)` directly or go through
* the engine constructor's `layout` option.
*/
export function generateStartingPosition(session: Session): EntityId[] {
return applyLayout(session, CLASSIC_LAYOUT);
}