From dbf3b4551d6b54d90c9081ecbbbed53491cb9300 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 21:32:21 -0600 Subject: [PATCH] fix(chess): repair lobby create/join flow and connectAndCreate default --- packages/chess/src/net/client.ts | 5 +- packages/chess/src/ui/Lobby.tsx | 240 ++++++++++++++++++------------- 2 files changed, 145 insertions(+), 100 deletions(-) diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index 2c7322c..c8b9be8 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -203,14 +203,15 @@ export class GameClient { * `room.created` event will carry the server-issued token; consumers should * listen for that to capture it (or use `connect()` once they have one). */ - async connectAndCreate(rulesetIds?: string[]): Promise { + async connectAndCreate(rulesetIds: string[] = []): Promise { this.code = null; this.token = null; this.closed = false; this.reconnectAttempts = 0; // Use a one-shot bootstrap that fires room.create instead of room.join // after the socket opens. We keep the bootstrap local to avoid leaking - // create-only state onto the instance. + // create-only state onto the instance. Default to [] (not undefined) so + // `openConnection`'s `autoCreate !== undefined` gate fires on empty-args. return this.openConnection({ autoCreate: rulesetIds }); } diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index b84dd2a..40d6200 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -1,8 +1,88 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { GameClient } from '../net/client.js'; -const WS_URL = (import.meta as { env?: Record }).env?.["VITE_WS_URL"] ?? "ws://localhost:7357/ws"; +const WS_URL = + (import.meta as { env?: Record }).env?.['VITE_WS_URL'] ?? + 'ws://localhost:7357/ws'; + +interface RoomPayload { + code?: string; + token?: string; + color?: string; + message?: string; +} + +interface ServerMsg { + type: string; + payload: RoomPayload; +} + +/** + * Opens a fresh WebSocket, sends one envelope, then returns a promise that + * resolves on the first room.created / room.joined reply or rejects on error. + * + * We use a raw WebSocket (not GameClient) because the Lobby's create/join + * flow is a single request/response pair — no reconnect, no persistent state. + * GameClient is wired up downstream by the game view once we have a token. + */ +function oneShotRoomRequest( + type: 'room.create' | 'room.join', + extraPayload: Record, +): Promise<{ code: string; token: string; color: string }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(WS_URL); + const timeout = window.setTimeout(() => { + ws.close(); + reject(new Error('Could not connect to server')); + }, 5000); + + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type, + payload: extraPayload, + }), + ); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string) as ServerMsg; + if ( + (msg.type === 'room.created' || msg.type === 'room.joined') && + typeof msg.payload.code === 'string' && + typeof msg.payload.token === 'string' && + typeof msg.payload.color === 'string' + ) { + clearTimeout(timeout); + ws.close(); + resolve({ + code: msg.payload.code, + token: msg.payload.token, + color: msg.payload.color, + }); + } else if (msg.type === 'error') { + clearTimeout(timeout); + ws.close(); + reject(new Error(msg.payload.message ?? 'Server error')); + } + } catch { + /* ignore malformed frames */ + } + }; + + ws.onerror = () => { + clearTimeout(timeout); + reject(new Error('Could not connect to server')); + }; + ws.onclose = () => { + clearTimeout(timeout); + }; + }); +} export function Lobby() { const [codeInput, setCodeInput] = useState(''); @@ -12,111 +92,69 @@ export function Lobby() { const navigate = useNavigate(); const handleCreate = async () => { - setLoading(true); + setLoading(true); setError(null); - const client = new GameClient(WS_URL); - - client.on("room.created", (e) => { - setRoomCode(e.payload.code); - sessionStorage.setItem("room-code", e.payload.code); - sessionStorage.setItem("room-token", e.payload.token); - sessionStorage.setItem("player-color", e.payload.color); - setLoading(false); - }); - - client.on("error", (e) => { - setError(e.payload.message); - setLoading(false); - }); - try { - if (typeof client.connectAndCreate === 'function') { - await client.connectAndCreate(); - } else { - // Fallback approach if connectAndCreate doesn't exist - const ws = new WebSocket(WS_URL); - ws.onopen = () => { - ws.send(JSON.stringify({ v: 1, seq: 1, ts: Date.now(), type: "room.create", payload: {} })); - }; - ws.onmessage = (e) => { - const msg = JSON.parse(e.data); - if (msg.type === "room.created") { - setRoomCode(msg.payload.code); - sessionStorage.setItem("room-code", msg.payload.code); - sessionStorage.setItem("room-token", msg.payload.token); - sessionStorage.setItem("player-color", msg.payload.color); - setLoading(false); - } - if (msg.type === "error") { - setError(msg.payload.message); - setLoading(false); - } - }; - ws.onerror = (e) => { - console.error("WebSocket error:", e); - setError("Could not connect to server"); - setLoading(false); - }; - ws.onclose = (e) => { - console.error("WebSocket closed:", e.code, e.reason); - if (loading) { - setError("Could not connect to server"); - setLoading(false); - } - }; - } - } catch { - setError("Could not connect to server"); - setLoading(false); + const { code, token, color } = await oneShotRoomRequest('room.create', {}); + sessionStorage.setItem('room-code', code); + sessionStorage.setItem('room-token', token); + sessionStorage.setItem('player-color', color); + setRoomCode(code); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not connect to server'); + } finally { + setLoading(false); } }; const handleJoin = async () => { - if (!codeInput.trim()) { - setError("Enter a room code"); - return; + const code = codeInput.trim().toUpperCase(); + if (!code) { + setError('Enter a room code'); + return; } - setLoading(true); + setLoading(true); setError(null); - - const client = new GameClient(WS_URL); - client.on("room.joined", (e) => { - sessionStorage.setItem("room-code", e.payload.code); - sessionStorage.setItem("room-token", e.payload.token); - sessionStorage.setItem("player-color", e.payload.color); - navigate("/game"); - }); - - client.on("error", (e) => { - setError(e.payload.message || "Invalid room code"); - setLoading(false); - }); - try { - await client.connect(codeInput.trim().toUpperCase(), ""); - } catch { - setError("Could not connect"); - setLoading(false); + const result = await oneShotRoomRequest('room.join', { code }); + sessionStorage.setItem('room-code', result.code); + sessionStorage.setItem('room-token', result.token); + sessionStorage.setItem('player-color', result.color); + navigate('/game'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid room code'); + } finally { + setLoading(false); } }; return ( -
+

Chess

Play realtime multiplayer

+
- +
- {/* Create Room Section */}
-

New Game

+

+ New Game +

{!roomCode ? ( -
- +
@@ -149,23 +190,24 @@ export function Lobby() { or
- - {/* Join Room Section */} +
-

Join Game

+

+ Join Game +

setCodeInput(e.target.value.toUpperCase())} + onChange={(e) => setCodeInput(e.target.value.toUpperCase())} placeholder="Enter 6-letter code" maxLength={6} className="w-full font-mono text-center text-lg py-2.5 px-4 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent placeholder:text-slate-400" /> -
- {/* Error Display */} {error && ( -
+

{error}

)}