/** Lobby + match WebSocket client (racing state sync). */ import { PeerManager } from './peers.js'; const STATE_HZ = 30; const STATE_INTERVAL = 1 / STATE_HZ; const LOBBY_POLL_MS = 1000; const HEARTBEAT_MS = 3000; function wsUrl(spaceUrl, mode, lobbyId, username, role = 'play') { const base = (spaceUrl || window.location.origin).replace(/\/$/, ''); const u = new URL(base); u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'; u.pathname = `/ws/match/${encodeURIComponent(mode)}/${encodeURIComponent(lobbyId)}`; u.search = `user=${encodeURIComponent(username)}&role=${encodeURIComponent(role)}`; return u.toString(); } export class NetClient { constructor({ scene, onStatus, onMatchStart, onMatchEnd, onLobbyUpdate, onMatchConnectionLost }) { this.scene = scene; this.onStatus = onStatus || (() => {}); this.onMatchStart = onMatchStart || (() => {}); this.onMatchEnd = onMatchEnd || (() => {}); this.onLobbyUpdate = onLobbyUpdate || (() => {}); this.onMatchConnectionLost = onMatchConnectionLost || (() => {}); this.username = null; this.avatarUrl = null; this.spaceUrl = null; this.playAllowed = true; this.host = 'local'; this.mode = null; this.lobbyId = null; this.seat = null; this.side = null; this.matchId = null; this.inMatch = false; this.ws = null; this.peers = new PeerManager(scene); this.board = null; this._pollTimer = null; this._hbTimer = null; this._stateAcc = 0; this._spawnIndex = 0; this.players = []; this.spectating = false; this.carId = null; } async initLocal() { const cfg = await fetch('/api/config').then((r) => r.json()); this.spaceUrl = cfg.spaceUrl || ''; this.username = cfg.username || null; this.host = cfg.host || 'local'; this.playAllowed = cfg.host === 'space' ? false : cfg.playAllowed !== false; this.avatarUrl = cfg.avatarUrl || (this.username && !String(this.username).startsWith('guest-') ? `https://huggingface.co/avatars/${encodeURIComponent(this.username)}` : null); if (!this.username) { return { ok: false, error: cfg.authError || 'No player identity' }; } if (this.host === 'local' && String(this.username).startsWith('guest-')) { return { ok: false, error: 'Local play requires HF_TOKEN (guest ids are spectate-only on Space)' }; } if (this.playAllowed && cfg.hasToken === false && this.host !== 'space') { return { ok: false, error: cfg.authError || 'Set HF_TOKEN in .env.local' }; } return { ok: true, username: this.username, avatarUrl: this.avatarUrl, spaceUrl: this.spaceUrl || window.location.origin, playAllowed: this.playAllowed, host: this.host, }; } _lobbyBase() { return ''; } async _fetchLobbies() { const res = await fetch(`${this._lobbyBase()}/api/lobbies`); if (!res.ok) throw new Error(`Lobby server ${res.status}`); const board = await res.json(); if (!board?.mvp && !board?.sandbox) throw new Error('Bad lobby payload'); return board; } startLobbyPoll(mode) { this.mode = mode; this.stopLobbyPoll(); const tick = async () => { try { const board = await this._fetchLobbies(); this.board = board; this.onLobbyUpdate(board, mode, null); } catch (err) { this.onStatus(`Lobby poll failed: ${err.message}`); this.onLobbyUpdate(null, mode, err.message || String(err)); } }; tick(); this._pollTimer = setInterval(tick, LOBBY_POLL_MS); this._hbTimer = setInterval(() => { if (!this.username) return; fetch(`${this._lobbyBase()}/api/lobbies/heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: this.username }), }).catch(() => {}); }, HEARTBEAT_MS); } stopLobbyPoll() { if (this._pollTimer) clearInterval(this._pollTimer); if (this._hbTimer) clearInterval(this._hbTimer); this._pollTimer = null; this._hbTimer = null; } async claim(mode, lobbyId, seat) { if (!this.playAllowed) throw new Error('Play disabled here — spectate only'); const res = await fetch(`/api/lobbies/${encodeURIComponent(mode)}/${encodeURIComponent(lobbyId)}/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: this.username, seat, avatarUrl: this.avatarUrl || null, }), }); let data; try { data = await res.json(); } catch { throw new Error(`claim failed (${res.status})`); } if (!res.ok || !data.ok) throw new Error(data.error || `claim failed (${res.status})`); this.mode = mode; this.lobbyId = lobbyId; this.seat = seat; this.side = seatSide(seat); this.matchId = data.matchId || null; return data; } async leaveLobby() { this.disconnectMatch(); if (!this.username) return; await fetch(`${this._lobbyBase()}/api/lobbies/leave`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: this.username }), }).catch(() => {}); this.lobbyId = null; this.seat = null; } connectMatch() { if (!this.playAllowed || this.host === 'space') { this.onStatus('Play disabled here — spectate only'); return; } if (!this.lobbyId || !this.mode) return; this._openMatchWs('play'); } connectSpectate(mode, lobbyId) { if (!mode || !lobbyId || !this.username) return; this.mode = mode; this.lobbyId = lobbyId; this.seat = null; this.side = 'ffa'; this.spectating = true; this._openMatchWs('spectate'); } _openMatchWs(role) { if (this.host === 'space' || !this.playAllowed) role = 'spectate'; this.disconnectMatch(false); this.spectating = role === 'spectate'; const url = wsUrl(this.spaceUrl, this.mode, this.lobbyId, this.username, role); this.onStatus(this.spectating ? 'Connecting as spectator…' : 'Connecting to race…'); const ws = new WebSocket(url); this.ws = ws; ws.onopen = () => { this.onStatus(this.spectating ? 'Waiting for race (spectate)…' : 'Waiting for race start…'); this.send({ type: 'ready', username: this.username }); }; ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } this._onMessage(msg); }; ws.onclose = () => { if (this.ws !== ws) return; const wasInMatch = this.inMatch; const wasSpectating = this.spectating; this.ws = null; this.inMatch = false; this.onStatus('Match connection closed'); if (wasInMatch || wasSpectating) { this.onMatchConnectionLost({ wasInMatch, wasSpectating }); } }; ws.onerror = () => { this.onStatus('Match WebSocket error'); }; } disconnectMatch(clearPeers = true) { const ws = this.ws; this.ws = null; this.inMatch = false; this.matchId = null; this.spectating = false; if (ws) { try { ws.close(); } catch { // ignore } } if (clearPeers) this.peers.clear(); } send(msg) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; if (this.spectating && msg?.type && msg.type !== 'ping' && msg.type !== 'ready') return; this.ws.send(JSON.stringify(msg)); } _onMessage(msg) { const type = msg.type; if (type === 'waiting') { this.onStatus('In lobby — waiting for race start…'); return; } if (type === 'error') { this.onStatus(msg.error || 'Server error'); if (/seat|seated|rejoin|lobby reset|match did not start/i.test(msg.error || '')) { try { this.ws?.close(); } catch { // ignore } } return; } if (type === 'match_start') { this.inMatch = true; this.matchId = msg.matchId; this.players = msg.players || []; this.spectating = !!msg.spectating || this.spectating || !this.playAllowed || this.host === 'space'; this.seat = this.spectating ? null : (msg.seat || this.seat); this.side = this.spectating ? 'ffa' : seatSide(this.seat); const me = (this.players || []).find((p) => p.username === this.username); this.carId = this.spectating ? null : (me?.carId || null); const hideSelf = this.spectating ? null : this.username; this.peers.syncPlayers(this.players, hideSelf); this._spawnIndex = this.spectating ? 0 : spawnIndexFor(this.seat, this.mode, this.players); this.onMatchStart({ mode: msg.mode || this.mode, seat: this.seat, side: this.side, players: this.players, spawnIndex: this._spawnIndex, spectating: this.spectating, carId: this.carId, }); this.onStatus(this.spectating ? `Spectating ${this.mode} · ${this.lobbyId}` : `Race live — ${this.mode}`); return; } if (type === 'player_joined' || type === 'player_left') { this.players = msg.players || this.players; this.peers.syncPlayers(this.players, this.spectating ? null : this.username); return; } if (type === 'state' && msg.from) { this.peers.applyState(msg.from, msg); return; } if (type === 'match_end') { this.onMatchEnd(msg); this.onStatus(`Race over — ${msg.winner || 'finish'}`); } } /** Call each frame while in a race */ update(delta, car) { if (!this.inMatch) return; this.peers.update(delta); if (this.spectating || !car) return; this._stateAcc += delta; if (this._stateAcc >= STATE_INTERVAL) { this._stateAcc = 0; const p = car.position; this.send({ type: 'state', pos: [p.x, p.y, p.z], rot: [car.yaw, 0, 0], speed: car.speed || 0, avatarUrl: this.avatarUrl || undefined, }); } } } function seatSide(seat) { if (!seat) return 'ffa'; const s = String(seat); if (s.startsWith('X-')) return 'X'; if (s.startsWith('Y-')) return 'Y'; return 'ffa'; } export function spawnIndexFor(seat, mode, players) { if (mode === 'mvp') { const m = String(seat).match(/^P-(\d+)$/i); if (m) return Math.max(0, Math.min(7, Number(m[1]) - 1)); } // sandbox / fallback: index among seated players const names = (players || []).map((p) => p.username).sort(); const me = (players || []).find((p) => p.seat === seat)?.username; const idx = Math.max(0, names.indexOf(me)); return idx % 8; } /** Grid offsets until real track start line exists */ export const SPAWN_OFFSETS = [ { x: -6, z: 0 }, { x: -2, z: 0 }, { x: 2, z: 0 }, { x: 6, z: 0 }, { x: -6, z: -6 }, { x: -2, z: -6 }, { x: 2, z: -6 }, { x: 6, z: -6 }, ];