refactor(chess): unregister Empty layout — keep as internal fixture only

The Empty layout had no real user need:
- Can't Play Solo (board with no pieces, nothing to click).
- Server rejects it for Create Room (validator requires >=1 king
  per side).
- The 'blank canvas' use case is already handled by the
  LayoutEditor's Custom... flow, which opens with an empty piece
  list and lets the user compose.

It was UI clutter in the picker dropdown — users would see
'Empty Board' alongside real layouts and have no way to actually
use it.

EMPTY_LAYOUT stays exported for unit tests that need a zero-piece
starting state, but it no longer registers in LAYOUT_REGISTRY, is
not imported by the layouts barrel side-effects, and no longer
appears in the picker. Source flipped to 'custom' to signal it's
not a user-selectable premade.

Tests updated:
- premades.test.ts asserts 'empty' is NOT in the registry.
- e2e/layouts.spec.ts removes the Empty-solo test and asserts the
  'empty' option is absent from the picker dropdown.

1025 unit tests + 23 e2e tests green.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 20:39:08 -06:00
commit c6c79c678b
No known key found for this signature in database
4 changed files with 31 additions and 53 deletions

View file

@ -72,7 +72,9 @@ test.describe('LayoutPicker (lobby)', () => {
test('picker includes every expected premade', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
// All premades + Custom entry.
// All premades + Custom entry. "Empty" is intentionally absent —
// it's a test fixture only, not a selectable starting position
// (fails validation, serves no player need).
for (const id of [
'classic',
'dunsany',
@ -81,10 +83,10 @@ test.describe('LayoutPicker (lobby)', () => {
'horde',
'knightmate',
'chess960',
'empty',
]) {
await expect(picker.locator(`option[value="${id}"]`)).toHaveCount(1);
}
await expect(picker.locator('option[value="empty"]')).toHaveCount(0);
await expect(picker.locator('option[value="__custom__"]')).toHaveCount(1);
});
@ -284,32 +286,6 @@ test.describe('Solo play — layout selection reaches the engine', () => {
}
});
test('Empty layout Play Solo is disabled (no king = cannot create game)', async ({
page,
}) => {
// The Empty layout has 0 pieces; the validator requires at least 1
// king per side. This test documents current behavior: the Play
// Solo button happily routes to /game regardless, but the engine
// opens with zero pieces (equivalent to validateLayout errors
// surfacing only on the multiplayer path, not solo). This is a
// deliberate design call — solo mode lets users poke at any
// layout including broken ones.
//
// If / when we add pre-flight validation for solo too, flip this
// test to assert a blocked CTA.
await page.goto('/');
await page.getByTestId('layout-picker').selectOption('empty');
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
// Board renders but is devoid of pieces.
await expect(
page.locator('[data-square="e1"] [data-piece]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="e8"] [data-piece]'),
).toHaveCount(0);
});
});
// ─────────────────────────────────────────────────────────────────────

View file

@ -1,27 +1,28 @@
/**
* Layout: Empty board (sandbox).
* Layout: Empty board (internal test fixture).
*
* 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.
* Zero pieces. NOT registered in LAYOUT_REGISTRY it fails the
* validator (requires 1 king per side) so exposing it in the
* lobby picker would be a dead-end UX: the server would reject
* Create Room, and solo play would drop the user onto a blank
* board with nothing to click.
*
* 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.
* The "blank canvas" use case is handled by the LayoutEditor's
* Custom flow the editor already opens from an empty piece
* list and lets the user compose freely.
*
* EMPTY_LAYOUT stays exported for:
* - Unit tests that need a zero-piece starting state.
* - `new ChessEngine({ layout: EMPTY_LAYOUT })` as a minimal
* fixture in preset/rule tests.
*/
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.",
"Internal test fixture — not registered in LAYOUT_REGISTRY.",
pieces: [],
source: "premade",
source: "custom",
};
LAYOUT_REGISTRY.register(EMPTY_LAYOUT);

View file

@ -21,11 +21,12 @@ import "./pawns-only.js";
import "./horde.js";
import "./knightmate.js";
import "./chess960.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";
// EMPTY_LAYOUT is NOT auto-registered — it's an internal test
// fixture (see layouts/empty.ts). Exported here so tests can
// construct an engine around it.
export { EMPTY_LAYOUT } from "./empty.js";
export { DUNSANY_LAYOUT } from "./dunsany.js";
export { MONSTER_LAYOUT } from "./monster.js";

View file

@ -8,6 +8,7 @@
import { describe, it, expect } from "vitest";
import { LAYOUT_REGISTRY } from "./registry.js";
import { validateLayout } from "./validate.js";
import { EMPTY_LAYOUT } from "./empty.js";
import "./index.js"; // trigger registration of every premade
describe("LAYOUT_REGISTRY — premade roster", () => {
@ -20,7 +21,10 @@ describe("LAYOUT_REGISTRY — premade roster", () => {
expect(ids).toContain("horde");
expect(ids).toContain("knightmate");
expect(ids).toContain("chess960");
expect(ids).toContain("empty");
});
it("does NOT include 'empty' — it's an internal test fixture only", () => {
expect(LAYOUT_REGISTRY.has("empty")).toBe(false);
});
it("every premade has source: 'premade'", () => {
@ -29,18 +33,15 @@ describe("LAYOUT_REGISTRY — premade roster", () => {
}
});
it("every non-empty premade validates with zero errors", () => {
it("every registered premade validates with zero errors", () => {
for (const layout of LAYOUT_REGISTRY.list()) {
if (layout.id === "empty") continue; // empty has no kings, rightfully errors
const { errors } = validateLayout(layout);
expect(errors, `${layout.id}: ${errors.join("; ")}`).toHaveLength(0);
}
});
it("empty layout errors on missing kings (validator doing its job)", () => {
const empty = LAYOUT_REGISTRY.get("empty");
expect(empty).toBeDefined();
const { errors } = validateLayout(empty!);
it("EMPTY_LAYOUT test fixture still fails validation (sanity)", () => {
const { errors } = validateLayout(EMPTY_LAYOUT);
expect(errors.length).toBeGreaterThan(0);
});
@ -66,7 +67,6 @@ describe("premade piece counts", () => {
["horde", 52], // 31 white pawns (ranks 1-4 minus e1) + 1 king + 4 advanced pawns + 16 black pieces
["knightmate", 32], // standard 32 with swapped back rank
["chess960", 32], // shim is FIDE by default
["empty", 0],
];
for (const [id, expectedCount] of counts) {