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:
parent
174cad6ae7
commit
4b6306fb57
6 changed files with 404 additions and 24 deletions
|
|
@ -19,11 +19,14 @@ const WS_URL =
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||||
'ws://localhost:7357/ws';
|
'ws://localhost:7357/ws';
|
||||||
|
|
||||||
|
import type { ResolvedLayoutWire } from './types';
|
||||||
|
|
||||||
interface RoomPayload {
|
interface RoomPayload {
|
||||||
code?: string;
|
code?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
layout?: ResolvedLayoutWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerMsg {
|
interface ServerMsg {
|
||||||
|
|
@ -31,10 +34,22 @@ interface ServerMsg {
|
||||||
payload: RoomPayload;
|
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(
|
export function oneShotRoomRequest(
|
||||||
type: 'room.create' | 'room.join',
|
type: 'room.create' | 'room.join',
|
||||||
extraPayload: Record<string, unknown>,
|
extraPayload: Record<string, unknown>,
|
||||||
): Promise<{ code: string; token: string; color: string }> {
|
): Promise<OneShotRoomResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(WS_URL);
|
const ws = new WebSocket(WS_URL);
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
|
|
@ -65,11 +80,15 @@ export function oneShotRoomRequest(
|
||||||
) {
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
ws.close();
|
ws.close();
|
||||||
resolve({
|
const result: OneShotRoomResult = {
|
||||||
code: msg.payload.code,
|
code: msg.payload.code,
|
||||||
token: msg.payload.token,
|
token: msg.payload.token,
|
||||||
color: msg.payload.color,
|
color: msg.payload.color,
|
||||||
});
|
};
|
||||||
|
if (msg.payload.layout !== undefined) {
|
||||||
|
result.layout = msg.payload.layout;
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,38 @@ export type ErrorCode =
|
||||||
| "RATE_LIMIT"
|
| "RATE_LIMIT"
|
||||||
| "MSG_TOO_LARGE"
|
| "MSG_TOO_LARGE"
|
||||||
| "BAD_TOKEN"
|
| "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 {
|
export interface Fact {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -90,6 +121,9 @@ export interface RoomCreatedPayload {
|
||||||
code: string;
|
code: string;
|
||||||
token: string;
|
token: string;
|
||||||
color: Color;
|
color: Color;
|
||||||
|
/** Resolved starting layout. Optional on the wire for backward
|
||||||
|
* compat with pre-layouts servers. */
|
||||||
|
layout?: ResolvedLayoutWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomJoinedPayload {
|
export interface RoomJoinedPayload {
|
||||||
|
|
@ -97,6 +131,8 @@ export interface RoomJoinedPayload {
|
||||||
token: string;
|
token: string;
|
||||||
color: Color;
|
color: Color;
|
||||||
activeRules: string[];
|
activeRules: string[];
|
||||||
|
/** Resolved starting layout (see RoomCreatedPayload.layout). */
|
||||||
|
layout?: ResolvedLayoutWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorPayload {
|
export interface ErrorPayload {
|
||||||
|
|
@ -111,6 +147,9 @@ export interface ErrorPayload {
|
||||||
|
|
||||||
export interface RoomCreatePayload {
|
export interface RoomCreatePayload {
|
||||||
rulesetIds?: string[];
|
rulesetIds?: string[];
|
||||||
|
/** Optional starting-layout selector. When omitted the server
|
||||||
|
* opens the room with the FIDE classic layout. */
|
||||||
|
layout?: LayoutRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomJoinPayload {
|
export interface RoomJoinPayload {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,62 @@
|
||||||
import { exportGame, importGame } from './io.js';
|
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 {
|
function keyFor(layoutId: string): string {
|
||||||
try {
|
return `${AUTOSAVE_PREFIX}:${layoutId}`;
|
||||||
localStorage.setItem(AUTOSAVE_KEY, exportGame(facts));
|
|
||||||
} catch { /* ignore quota errors */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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;
|
if (!raw) return null;
|
||||||
return importGame(raw);
|
return importGame(raw);
|
||||||
} catch {
|
} 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 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,7 @@ function GameLayout({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
||||||
|
<LayoutBadge />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -456,3 +457,29 @@ function RoomShareBadge({ code }: { code: string }) {
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
130
packages/chess/src/ui/LayoutPicker.tsx
Normal file
130
packages/chess/src/ui/LayoutPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { pieceAssets } from '../assets/pieces';
|
import { pieceAssets } from '../assets/pieces';
|
||||||
import { ChessEngine } from '../engine';
|
import { ChessEngine } from '../engine';
|
||||||
import { clearAutoSave } from '../persist/autosave';
|
import { clearAllAutoSaves } from '../persist/autosave';
|
||||||
import { oneShotRoomRequest } from '../net/lobby-request';
|
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 {
|
interface LobbyProps {
|
||||||
/** Optional — when provided, create/join/solo flows reset the local
|
/** 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 [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
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 = () => {
|
const resetToFreshGame = () => {
|
||||||
// Wipe the autosave so a later full-page reload doesn't re-hydrate
|
// Wipe every layout's autosave so a later full-page reload doesn't
|
||||||
// the previous (finished) game, and replace the in-memory engine
|
// re-hydrate the previous (finished) game from any slot, and
|
||||||
// with a brand-new one now so the board shows the starting position
|
// replace the in-memory engine with a brand-new one opened from
|
||||||
// the instant the user lands on /game.
|
// the selected layout so the board shows the chosen starting
|
||||||
clearAutoSave();
|
// position the instant the user lands on /game.
|
||||||
chessState?.loadEngine(new ChessEngine());
|
clearAllAutoSaves();
|
||||||
|
chessState?.loadEngine(new ChessEngine({ layout: selectedLayout }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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-code', code);
|
||||||
sessionStorage.setItem('room-token', token);
|
sessionStorage.setItem('room-token', token);
|
||||||
sessionStorage.setItem('player-color', color);
|
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();
|
resetToFreshGame();
|
||||||
// Navigate straight to the canonical shareable URL — no
|
// Navigate straight to the canonical shareable URL — no
|
||||||
// intermediate "Room created" card. The GameView itself renders
|
// 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-code', result.code);
|
||||||
sessionStorage.setItem('room-token', result.token);
|
sessionStorage.setItem('room-token', result.token);
|
||||||
sessionStorage.setItem('player-color', result.color);
|
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();
|
resetToFreshGame();
|
||||||
navigate(`/game/${result.code}`);
|
navigate(`/game/${result.code}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -111,7 +201,12 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||||
Host Game
|
Host Game
|
||||||
</h2>
|
</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
|
<button
|
||||||
data-action="create-room"
|
data-action="create-room"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue