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'] ??
|
||||
'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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 { 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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue