diff --git a/packages/chess/src/net/lobby-request.ts b/packages/chess/src/net/lobby-request.ts index ba7eae9..8ad917e 100644 --- a/packages/chess/src/net/lobby-request.ts +++ b/packages/chess/src/net/lobby-request.ts @@ -19,11 +19,14 @@ const WS_URL = (import.meta as { env?: Record }).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, -): Promise<{ code: string; token: string; color: string }> { +): Promise { 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(); diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 5423905..2efb4d6 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -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 { diff --git a/packages/chess/src/persist/autosave.ts b/packages/chess/src/persist/autosave.ts index eaeeb49..8e31387 100644 --- a/packages/chess/src/persist/autosave.ts +++ b/packages/chess/src/persist/autosave.ts @@ -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 */ + } } diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index cb41d5f..b0031b7 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -254,6 +254,7 @@ function GameLayout({ )} {roomCode !== null && } +
@@ -456,3 +457,29 @@ function RoomShareBadge({ code }: { code: string }) { ); } + +/** + * 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(() => { + if (typeof window === 'undefined') return null; + return sessionStorage.getItem('layout-name'); + }); + if (name === null) return null; + return ( + + {name} + + ); +} diff --git a/packages/chess/src/ui/LayoutPicker.tsx b/packages/chess/src/ui/LayoutPicker.tsx new file mode 100644 index 0000000..a1cf49a --- /dev/null +++ b/packages/chess/src/ui/LayoutPicker.tsx @@ -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) { + 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 ( +
+ +
+ +
+ + + +
+
+

+ {isCustom ? 'Custom layout loaded from your editor.' : value.description} +

+ {value.suggestedPresets !== undefined && + value.suggestedPresets.length > 0 && ( +

+ Suggested rules: {value.suggestedPresets.join(', ')} +

+ )} +
+ ); +} diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index f5cd02a..d1ce556 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -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(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(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 = {}) {

Host Game

-
+
+