/** * WebSocket client with auto-reconnect, seq ordering, ping/clock sync (docs/03 §4–§8). * Room/game state lives in the zustand store below; components only read it. */ import { create } from 'zustand'; import { PROTOCOL, type GameResultView, type PublicUser, type RoomView, type ServerMessage } from '@bg/shared/lite'; import { toast } from '../store/toast'; export type ConnStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'replaced' | 'offline'; export interface ChatLine { id: number; from: PublicUser | null; text: string; at: number; } export interface GameState { seq: number; view: unknown; active: string[]; deadline: number | null; events: unknown[]; } interface RoomStore { status: ConnStatus; me: PublicUser | null; code: string | null; room: RoomView | null; roomSeq: number; game: GameState | null; result: GameResultView | null; chat: ChatLine[]; emotes: { id: number; from: string; emote: string }[]; pending: number | null; slow: boolean; rttMs: number | null; clockOffset: number; joinError: string | null; kicked: boolean; needsRefresh: boolean; } export const useRoom = create(() => ({ status: 'idle', me: null, code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], emotes: [], pending: null, slow: false, rttMs: null, clockOffset: 0, joinError: null, kicked: false, needsRefresh: false, })); const BACKOFF = [500, 1000, 2000, 4000, 8000]; const MAX_BACKOFF = 10_000; const PING_EVERY = 10_000; const PONG_TIMEOUT = 5_000; class GameSocket { private ws: WebSocket | null = null; private attempt = 0; private retryTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; private pongTimer: ReturnType | null = null; private pendingTimer: ReturnType | null = null; private offsets: number[] = []; private wanted = false; private cs = Date.now(); private chatId = 1; private onMessageHooks = new Set<(m: ServerMessage) => void>(); constructor() { if (typeof window !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') this.kick(); }); window.addEventListener('online', () => this.kick()); window.addEventListener('offline', () => useRoom.setState({ status: 'offline' })); } } /** Start (or keep) a connection. Safe to call repeatedly. */ start(): void { this.wanted = true; if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return; this.open(); } stop(): void { this.wanted = false; this.clearTimers(); this.ws?.close(); this.ws = null; useRoom.setState({ status: 'idle' }); } /** Reconnect immediately if we should be connected but are not. */ private kick(): void { if (!this.wanted) return; if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) { if (this.retryTimer) clearTimeout(this.retryTimer); this.retryTimer = null; this.open(); } else if (this.ws.readyState === WebSocket.OPEN) { this.ping(); } } private open(): void { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${proto}//${location.host}/ws`); this.ws = ws; useRoom.setState({ status: this.attempt === 0 ? 'connecting' : 'reconnecting' }); ws.onopen = () => { this.attempt = 0; this.pingTimer = setInterval(() => this.ping(), PING_EVERY); }; ws.onmessage = (e) => { let m: ServerMessage; try { m = JSON.parse(String(e.data)); } catch { return; } this.handle(m); for (const h of this.onMessageHooks) h(m); }; ws.onclose = (e) => { if (this.ws !== ws) return; this.clearTimers(); this.ws = null; const st = useRoom.getState().status; if (!this.wanted || st === 'replaced' || useRoom.getState().kicked) return; if (e.code === 4401 || e.code === 1008) { // Not logged in (session expired): stop and let the page ask for a nickname. useRoom.setState({ status: 'idle' }); this.wanted = false; window.dispatchEvent(new Event('bg:unauthorized')); return; } if (this.attempt >= 1) { // Repeated failures may mean the session is gone (the upgrade got a 401). fetch('/api/me', { credentials: 'same-origin' }) .then((r) => r.json()) .then((d: { me: unknown }) => { if (d.me === null) { this.wanted = false; useRoom.setState({ status: 'idle' }); window.dispatchEvent(new Event('bg:unauthorized')); } }) .catch(() => {}); } this.scheduleReconnect(); }; } private scheduleReconnect(extraDelay = 0): void { const base = BACKOFF[this.attempt] ?? MAX_BACKOFF; const jitter = base * (0.7 + Math.random() * 0.6); this.attempt++; useRoom.setState({ status: navigator.onLine === false ? 'offline' : 'reconnecting' }); this.retryTimer = setTimeout(() => { this.retryTimer = null; if (this.wanted) this.open(); }, jitter + extraDelay); } private clearTimers(): void { if (this.pingTimer) clearInterval(this.pingTimer); if (this.pongTimer) clearTimeout(this.pongTimer); this.pingTimer = null; this.pongTimer = null; } private ping(): void { if (this.ws?.readyState !== WebSocket.OPEN || this.pongTimer) return; this.send({ t: 'ping', ts: Date.now() }); this.pongTimer = setTimeout(() => { this.pongTimer = null; // Dead connection: force a reconnect. this.ws?.close(); }, PONG_TIMEOUT); } send(m: unknown): boolean { if (this.ws?.readyState !== WebSocket.OPEN) return false; this.ws.send(JSON.stringify(m)); return true; } subscribe(fn: (m: ServerMessage) => void): () => void { this.onMessageHooks.add(fn); return () => this.onMessageHooks.delete(fn); } // ------------------------------------------------------------- room commands join(code: string, as: 'player' | 'spectator' = 'player'): void { const cur = useRoom.getState(); if (cur.code !== code) { useRoom.setState({ code, room: null, roomSeq: -1, game: null, result: null, chat: [], joinError: null, kicked: false, pending: null }); } this.start(); this.send({ t: 'join', code, as }); } leave(): void { this.send({ t: 'leave' }); useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null }); } /** Sends a game action; returns false when not connected. */ act(a: unknown): boolean { const cs = ++this.cs; if (!this.send({ t: 'act', cs, a })) { toast('연결이 끊겨 있어요. 다시 연결되면 해 주세요.', 'error'); return false; } useRoom.setState({ pending: cs, slow: false }); if (this.pendingTimer) clearTimeout(this.pendingTimer); this.pendingTimer = setTimeout(() => { if (useRoom.getState().pending === cs) { useRoom.setState({ slow: true }); this.ping(); } }, 2000); return true; } /** Server time estimate. */ now(): number { return Date.now() + useRoom.getState().clockOffset; } // ------------------------------------------------------------- inbound private handle(m: ServerMessage): void { const st = useRoom.getState(); switch (m.t) { case 'welcome': { if (m.protocol !== PROTOCOL) useRoom.setState({ needsRefresh: true }); useRoom.setState({ status: 'open', me: m.me }); this.offsets = [m.serverTime - Date.now()]; useRoom.setState({ clockOffset: this.offsets[0]! }); // Re-join the room we were in (reconnect path). if (st.code && !st.kicked) this.send({ t: 'join', code: st.code, as: 'player' }); break; } case 'room': if (m.seq >= st.roomSeq || m.room.code !== st.room?.code) { useRoom.setState({ room: m.room, roomSeq: m.seq, joinError: null }); if (m.room.status === 'playing' && st.result) useRoom.setState({ result: null }); } break; case 'state': { if (st.game && m.seq < st.game.seq) break; // stale const patch: Partial = { game: { seq: m.seq, view: m.view, active: m.active, deadline: m.deadline, events: m.events }, }; if (m.cs !== undefined && m.cs === st.pending) { patch.pending = null; patch.slow = false; } useRoom.setState(patch); break; } case 'reject': if (m.cs === st.pending) useRoom.setState({ pending: null, slow: false }); toast(m.reason, 'error'); break; case 'result': useRoom.setState({ result: m.result }); break; case 'chat': useRoom.setState({ chat: [...st.chat, { id: this.chatId++, from: m.from, text: m.text, at: m.at }].slice(-100) }); break; case 'emote': { const id = this.chatId++; useRoom.setState({ emotes: [...st.emotes, { id, from: m.from, emote: m.id }].slice(-10) }); setTimeout(() => useRoom.setState((s) => ({ emotes: s.emotes.filter((e) => e.id !== id) })), 2500); break; } case 'notice': toast(m.message); break; case 'error': if (m.code === 'room-not-found' || m.code === 'join-failed') useRoom.setState({ joinError: m.message }); else toast(m.message, 'error'); break; case 'pong': { if (this.pongTimer) clearTimeout(this.pongTimer); this.pongTimer = null; const now = Date.now(); const rtt = now - m.ts; const offset = m.serverTime + rtt / 2 - now; this.offsets = [...this.offsets, offset].slice(-5); const sorted = [...this.offsets].sort((a, b) => a - b); useRoom.setState({ rttMs: rtt, clockOffset: sorted[Math.floor(sorted.length / 2)]! }); if (st.slow && st.pending) { // Connection is alive but our action got no answer: resync instead of resending. this.send({ t: 'sync' }); useRoom.setState({ pending: null, slow: false }); } break; } case 'bye': if (m.reason === 'restart') { toast('서버를 업데이트하고 있어요. 잠시 후 자동으로 다시 연결돼요.'); this.attempt = 0; this.ws?.close(); // onclose schedules a reconnect; add 1–3s so clients don't all return at once. this.ws = null; this.clearTimers(); this.scheduleReconnect(1000 + Math.random() * 2000); } else if (m.reason === 'replaced') { useRoom.setState({ status: 'replaced' }); } else if (m.reason === 'kicked') { useRoom.setState({ kicked: true, joinError: '방장이 방에서 내보냈어요.' }); } break; } } /** "Use here" after another tab took over. */ reclaim(): void { useRoom.setState({ status: 'connecting' }); this.wanted = true; this.attempt = 0; this.open(); } } export const socket = new GameSocket();