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:
parent
653267daf0
commit
d93bcf6c81
12 changed files with 1319 additions and 26 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
16
packages/chess/src/layouts/classic.ts
Normal file
16
packages/chess/src/layouts/classic.ts
Normal 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 };
|
||||
27
packages/chess/src/layouts/empty.ts
Normal file
27
packages/chess/src/layouts/empty.ts
Normal 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);
|
||||
28
packages/chess/src/layouts/index.ts
Normal file
28
packages/chess/src/layouts/index.ts
Normal 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";
|
||||
65
packages/chess/src/layouts/registry.ts
Normal file
65
packages/chess/src/layouts/registry.ts
Normal 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();
|
||||
84
packages/chess/src/layouts/types.ts
Normal file
84
packages/chess/src/layouts/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue