feat(chess): lobby + URL-shareable starting layouts (Phase D)

Wires the starting-layout picker into the lobby UI. Users can now:
- Pick a premade (Classic / Dunsany / Monster / Pawns-Only / Horde /
  Knightmate / Chess960 / Empty) from the Host Game section.
- Paste a shareable link (?layoutId=dunsany or ?fen=<encoded>) to
  pre-select a layout when arriving at the lobby.
- See the selected layout's name as a purple badge on the game view
  header next to the room code (hidden for Classic, the default).

Components:
- ui/LayoutPicker.tsx: dropdown reading LAYOUT_REGISTRY. Chess960
  re-seeds on each selection. Custom... entry can be wired to the
  editor modal in Phase E.
- ui/Lobby.tsx: LayoutPicker above Create Room, URL-param reader
  for ?layoutId / ?fen / ?name (validated via validateLayout —
  malformed links silently fall back to Classic).
- ui/GameView.tsx: new LayoutBadge (inline) reads sessionStorage
  'layout-name' written by Lobby on create/join.

Networking:
- net/types.ts: LayoutRequest discriminated union, PiecePlacementWire,
  ResolvedLayoutWire added; RoomCreate/RoomCreated/RoomJoined
  payloads now carry optional 'layout' fields mirroring the server.
- net/lobby-request.ts: OneShotRoomResult returns the resolved
  layout from room.created / room.joined when the server echoes it.

Persistence:
- persist/autosave.ts: keyed-by-layout storage slots
  (paratype-chess:v2:autosave:${layoutId}). One-time migration moves
  the legacy v1 key into the classic slot so existing FIDE saves
  survive. New clearAllAutoSaves wipes every layout slot; used by
  Lobby when starting a fresh game.

1011 tests passing; bun run check clean. Manual smoke pending.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 20:07:25 -06:00
commit 4b6306fb57
No known key found for this signature in database
6 changed files with 404 additions and 24 deletions

View file

@ -19,11 +19,14 @@ const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
'ws://localhost:7357/ws';
import type { ResolvedLayoutWire } from './types';
interface RoomPayload {
code?: string;
token?: string;
color?: string;
message?: string;
layout?: ResolvedLayoutWire;
}
interface ServerMsg {
@ -31,10 +34,22 @@ interface ServerMsg {
payload: RoomPayload;
}
/**
* The shape resolved by oneShotRoomRequest on success. `layout` is
* optional for wire compat with older servers; new servers always
* populate it.
*/
export interface OneShotRoomResult {
code: string;
token: string;
color: string;
layout?: ResolvedLayoutWire;
}
export function oneShotRoomRequest(
type: 'room.create' | 'room.join',
extraPayload: Record<string, unknown>,
): Promise<{ code: string; token: string; color: string }> {
): Promise<OneShotRoomResult> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(WS_URL);
const timeout = window.setTimeout(() => {
@ -65,11 +80,15 @@ export function oneShotRoomRequest(
) {
clearTimeout(timeout);
ws.close();
resolve({
const result: OneShotRoomResult = {
code: msg.payload.code,
token: msg.payload.token,
color: msg.payload.color,
});
};
if (msg.payload.layout !== undefined) {
result.layout = msg.payload.layout;
}
resolve(result);
} else if (msg.type === 'error') {
clearTimeout(timeout);
ws.close();

View file

@ -27,7 +27,38 @@ export type ErrorCode =
| "RATE_LIMIT"
| "MSG_TOO_LARGE"
| "BAD_TOKEN"
| "INVALID_MESSAGE";
| "INVALID_MESSAGE"
| "LAYOUT_INVALID";
// ---------------------------------------------------------------------------
// Starting layout wire shapes (mirrors server/src/protocol.ts)
// ---------------------------------------------------------------------------
export type PieceType =
| "pawn"
| "knight"
| "bishop"
| "rook"
| "queen"
| "king";
export interface PiecePlacementWire {
type: PieceType;
color: Color;
square: number;
hasMoved?: boolean;
}
export type LayoutRequest =
| { kind: "premade"; id: string }
| { kind: "fen"; fen: string; name?: string }
| { kind: "custom"; pieces: PiecePlacementWire[]; name?: string };
export interface ResolvedLayoutWire {
id: string;
name: string;
pieces: PiecePlacementWire[];
}
export interface Fact {
id: number;
@ -90,6 +121,9 @@ export interface RoomCreatedPayload {
code: string;
token: string;
color: Color;
/** Resolved starting layout. Optional on the wire for backward
* compat with pre-layouts servers. */
layout?: ResolvedLayoutWire;
}
export interface RoomJoinedPayload {
@ -97,6 +131,8 @@ export interface RoomJoinedPayload {
token: string;
color: Color;
activeRules: string[];
/** Resolved starting layout (see RoomCreatedPayload.layout). */
layout?: ResolvedLayoutWire;
}
export interface ErrorPayload {
@ -111,6 +147,9 @@ export interface ErrorPayload {
export interface RoomCreatePayload {
rulesetIds?: string[];
/** Optional starting-layout selector. When omitted the server
* opens the room with the FIDE classic layout. */
layout?: LayoutRequest;
}
export interface RoomJoinPayload {

View file

@ -1,16 +1,62 @@
import { exportGame, importGame } from './io.js';
const AUTOSAVE_KEY = 'paratype-chess:v1:autosave';
/**
* Autosave storage key prefix. The stored key for a given game is
* `${AUTOSAVE_PREFIX}:${layoutId}` keyed by layout so starting a
* Dunsany game doesn't wipe the user's in-progress Classic game,
* and vice versa.
*
* The legacy (pre-layouts) single-key `paratype-chess:v1:autosave`
* is migrated on first access: if it exists and no keyed equivalent
* does, we move it to `${AUTOSAVE_PREFIX}:classic` so existing
* auto-saved FIDE games continue to load. The legacy key is then
* removed.
*/
const AUTOSAVE_PREFIX = 'paratype-chess:v2:autosave';
const LEGACY_AUTOSAVE_KEY = 'paratype-chess:v1:autosave';
export function saveAutoSave(facts: Array<{ id: number; attr: string; value: unknown }>): void {
try {
localStorage.setItem(AUTOSAVE_KEY, exportGame(facts));
} catch { /* ignore quota errors */ }
function keyFor(layoutId: string): string {
return `${AUTOSAVE_PREFIX}:${layoutId}`;
}
export function loadAutoSave(): Array<{ id: number; attr: string; value: unknown }> | null {
/**
* Best-effort migration of the pre-layouts global autosave into a
* classic-layout slot. Runs at most once per page load (the LEGACY
* key is removed after successful migration).
*/
function migrateLegacyAutosaveIfNeeded(): void {
try {
const raw = localStorage.getItem(AUTOSAVE_KEY);
const legacy = localStorage.getItem(LEGACY_AUTOSAVE_KEY);
if (legacy === null) return;
// Only migrate if the destination slot is empty — don't stomp a
// more recent classic save.
const classicKey = keyFor('classic');
if (localStorage.getItem(classicKey) === null) {
localStorage.setItem(classicKey, legacy);
}
localStorage.removeItem(LEGACY_AUTOSAVE_KEY);
} catch {
/* ignore quota / permission errors */
}
}
export function saveAutoSave(
facts: Array<{ id: number; attr: string; value: unknown }>,
layoutId: string = 'classic',
): void {
try {
localStorage.setItem(keyFor(layoutId), exportGame(facts));
} catch {
/* ignore quota errors */
}
}
export function loadAutoSave(
layoutId: string = 'classic',
): Array<{ id: number; attr: string; value: unknown }> | null {
migrateLegacyAutosaveIfNeeded();
try {
const raw = localStorage.getItem(keyFor(layoutId));
if (!raw) return null;
return importGame(raw);
} catch {
@ -18,6 +64,30 @@ export function loadAutoSave(): Array<{ id: number; attr: string; value: unknown
}
}
export function clearAutoSave(): void {
localStorage.removeItem(AUTOSAVE_KEY);
/**
* Clear the autosave for `layoutId`. Pass no arg to clear the
* classic slot (back-compat). Use `clearAllAutoSaves` to clear
* every layout's slot.
*/
export function clearAutoSave(layoutId: string = 'classic'): void {
localStorage.removeItem(keyFor(layoutId));
}
/** Remove autosaves for every layout. Use when starting a fresh
* game from the lobby so no stale slot resurrects later. */
export function clearAllAutoSaves(): void {
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null && key.startsWith(`${AUTOSAVE_PREFIX}:`)) {
keys.push(key);
}
}
for (const key of keys) localStorage.removeItem(key);
// Also clear the legacy key if it's still around.
localStorage.removeItem(LEGACY_AUTOSAVE_KEY);
} catch {
/* ignore */
}
}

View file

@ -254,6 +254,7 @@ function GameLayout({
</span>
)}
{roomCode !== null && <RoomShareBadge code={roomCode} />}
<LayoutBadge />
</div>
<div className="flex items-center gap-3">
@ -456,3 +457,29 @@ function RoomShareBadge({ code }: { code: string }) {
</button>
);
}
/**
* Badge showing the starting layout name when it's not the default
* Classic setup. Sourced from sessionStorage['layout-name'] set by
* the Lobby on create/join; cleared when the layout is Classic.
*
* Lives in local component state so edits to sessionStorage after
* mount (unlikely) don't cause stale display. For solo games with
* no layout selection the key is absent and the badge renders
* nothing.
*/
function LayoutBadge() {
const [name] = useState<string | null>(() => {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem('layout-name');
});
if (name === null) return null;
return (
<span
data-testid="layout-badge"
className="inline-flex items-center text-xs font-semibold uppercase tracking-wide text-purple-700 bg-purple-50 border border-purple-100 rounded px-2 py-1"
>
{name}
</span>
);
}

View file

@ -0,0 +1,130 @@
/**
* LayoutPicker lobby control for selecting a starting layout.
*
* Renders a dropdown populated from `LAYOUT_REGISTRY`. Selecting an
* entry calls `onChange(layout)` with the resolved `StartingLayout`.
*
* Special handling:
* - Chess960 is a SHIM in the registry (its `pieces` field is the
* FIDE default). When the user picks it, we call
* `buildChess960Layout(seed)` with a fresh random seed so every
* selection yields a different position.
* - "Custom…" is a synthetic trailing entry that opens the editor
* (the editor modal lives in a separate component; picker just
* emits an `onCustomRequested` callback).
*
* The component is intentionally UNCONTROLLED by layout ID parents
* pass the full resolved layout back in as `value`. This keeps the
* picker stateless and plays well with URL-driven pre-selection
* (e.g. `?layoutId=dunsany`): the App route sets the initial value,
* the picker renders it.
*/
import { useMemo } from 'react';
import {
LAYOUT_REGISTRY,
buildChess960Layout,
type StartingLayout,
} from '@paratype/chess';
export interface LayoutPickerProps {
/** Currently selected layout. Parents own the state. */
value: StartingLayout;
/** Called when the user picks a new premade. */
onChange: (layout: StartingLayout) => void;
/** Called when the user picks the "Custom…" entry. */
onCustomRequested?: () => void;
/** Disables the control (during network requests, etc). */
disabled?: boolean;
}
export function LayoutPicker({
value,
onChange,
onCustomRequested,
disabled = false,
}: LayoutPickerProps) {
// Memoize the registry list so the dropdown doesn't re-map on
// every render. Registry contents are immutable at runtime.
const premades = useMemo(() => LAYOUT_REGISTRY.list(), []);
// If the current value is a CUSTOM layout (source: "custom"), the
// dropdown should display "Custom…" as the selected option rather
// than trying to find a matching premade id.
const isCustom = value.source === 'custom';
const selectValue = isCustom ? '__custom__' : value.id;
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const picked = e.target.value;
if (picked === '__custom__') {
onCustomRequested?.();
return;
}
// Chess960 gets a fresh seed on every selection. Future layouts
// that want "re-randomize on pick" can follow this pattern.
if (picked === 'chess960') {
const seed = Math.floor(Math.random() * 960);
onChange(buildChess960Layout(seed));
return;
}
const layout = LAYOUT_REGISTRY.get(picked);
if (layout !== undefined) {
onChange(layout);
}
}
return (
<div className="space-y-2">
<label className="block text-xs font-bold text-neutral-500 uppercase tracking-widest">
Starting Layout
</label>
<div className="relative">
<select
data-testid="layout-picker"
value={selectValue}
onChange={handleChange}
disabled={disabled}
className="w-full appearance-none bg-white/80 border border-neutral-300 rounded-lg py-3 pl-4 pr-10 text-sm font-semibold text-neutral-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{premades.map((layout) => (
<option key={layout.id} value={layout.id}>
{layout.name}
{layout.pieces.length > 0
? `${String(layout.pieces.length)} pieces`
: ''}
</option>
))}
{onCustomRequested !== undefined && (
<option value="__custom__">Custom</option>
)}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
<p className="text-xs text-neutral-500 leading-relaxed">
{isCustom ? 'Custom layout loaded from your editor.' : value.description}
</p>
{value.suggestedPresets !== undefined &&
value.suggestedPresets.length > 0 && (
<p className="text-xs text-neutral-400 italic">
Suggested rules: {value.suggestedPresets.join(', ')}
</p>
)}
</div>
);
}

View file

@ -1,10 +1,19 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
import { ChessEngine } from '../engine';
import { clearAutoSave } from '../persist/autosave';
import { clearAllAutoSaves } from '../persist/autosave';
import { oneShotRoomRequest } from '../net/lobby-request';
import {
CLASSIC_LAYOUT,
LAYOUT_REGISTRY,
fromFen,
validateLayout,
type StartingLayout,
} from '@paratype/chess';
import type { LayoutRequest } from '../net/types';
import { LayoutPicker } from './LayoutPicker';
interface LobbyProps {
/** Optional when provided, create/join/solo flows reset the local
@ -20,24 +29,100 @@ export function Lobby({ chessState }: LobbyProps = {}) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Selected starting layout. Initialized from ?layoutId / ?fen
// query params when present (shareable-URL support) so pasting a
// link pre-selects the right layout.
const [selectedLayout, setSelectedLayout] =
useState<StartingLayout>(CLASSIC_LAYOUT);
useEffect(() => {
const layoutId = searchParams.get('layoutId');
const fen = searchParams.get('fen');
const name = searchParams.get('name') ?? undefined;
if (layoutId !== null) {
const layout = LAYOUT_REGISTRY.get(layoutId);
if (layout !== undefined) {
setSelectedLayout(layout);
}
return;
}
if (fen !== null) {
const { pieces, errors } = fromFen(fen);
if (errors.length === 0) {
const layout: StartingLayout = {
id: 'custom',
name: name ?? 'Shared Layout',
description: 'Loaded from a shared link.',
pieces,
source: 'custom',
};
// Only apply if it passes the validator — a malformed shared
// link shouldn't block the lobby's default behavior.
if (validateLayout(layout).errors.length === 0) {
setSelectedLayout(layout);
}
}
}
// Only depends on searchParams: we pre-select from query params
// on first mount or when the URL changes. selectedLayout is
// intentionally omitted so user picks after mount don't re-trigger
// this effect and overwrite their choice.
}, [searchParams]);
/**
* Convert the selected layout into the protocol's LayoutRequest
* shape. Premades travel by id (server re-resolves via registry);
* custom layouts travel by pieces (server just validates).
*/
function toLayoutRequest(layout: StartingLayout): LayoutRequest {
if (layout.source === 'premade') {
return { kind: 'premade', id: layout.id };
}
return {
kind: 'custom',
pieces: layout.pieces.map((p) => ({
type: p.type,
color: p.color,
square: p.square,
...(p.hasMoved !== undefined ? { hasMoved: p.hasMoved } : {}),
})),
name: layout.name,
};
}
const resetToFreshGame = () => {
// Wipe the autosave so a later full-page reload doesn't re-hydrate
// the previous (finished) game, and replace the in-memory engine
// with a brand-new one now so the board shows the starting position
// the instant the user lands on /game.
clearAutoSave();
chessState?.loadEngine(new ChessEngine());
// Wipe every layout's autosave so a later full-page reload doesn't
// re-hydrate the previous (finished) game from any slot, and
// replace the in-memory engine with a brand-new one opened from
// the selected layout so the board shows the chosen starting
// position the instant the user lands on /game.
clearAllAutoSaves();
chessState?.loadEngine(new ChessEngine({ layout: selectedLayout }));
};
const handleCreate = async () => {
setLoading(true);
setError(null);
try {
const { code, token, color } = await oneShotRoomRequest('room.create', {});
const { code, token, color, layout: resolvedLayout } =
await oneShotRoomRequest('room.create', {
layout: toLayoutRequest(selectedLayout),
});
sessionStorage.setItem('room-code', code);
sessionStorage.setItem('room-token', token);
sessionStorage.setItem('player-color', color);
// Save the resolved layout name for GameView's header badge.
// Strip "Classic Chess" since that's the implicit default and
// showing the label there would be noisy.
if (resolvedLayout && resolvedLayout.id !== 'classic') {
sessionStorage.setItem('layout-name', resolvedLayout.name);
} else {
sessionStorage.removeItem('layout-name');
}
resetToFreshGame();
// Navigate straight to the canonical shareable URL — no
// intermediate "Room created" card. The GameView itself renders
@ -63,6 +148,11 @@ export function Lobby({ chessState }: LobbyProps = {}) {
sessionStorage.setItem('room-code', result.code);
sessionStorage.setItem('room-token', result.token);
sessionStorage.setItem('player-color', result.color);
if (result.layout && result.layout.id !== 'classic') {
sessionStorage.setItem('layout-name', result.layout.name);
} else {
sessionStorage.removeItem('layout-name');
}
resetToFreshGame();
navigate(`/game/${result.code}`);
} catch (err) {
@ -111,7 +201,12 @@ export function Lobby({ chessState }: LobbyProps = {}) {
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Host Game
</h2>
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner space-y-4">
<LayoutPicker
value={selectedLayout}
onChange={setSelectedLayout}
disabled={loading}
/>
<button
data-action="create-room"
onClick={handleCreate}