"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { DEFAULT_WS_URL, ParsedMessage, RECONNECT_INTERVAL_MS, RECONNECT_TIMEOUT_MS, cmd, parseMessage, } from "@/lib/protocol"; export type ConnectionRole = "observer" | "player"; export type ConnectionStatus = | "idle" | "connecting" | "connected" | "reconnecting" | "disconnected"; type MessageListener = (message: ParsedMessage, raw: string) => void; interface ConnectOptions { role: ConnectionRole; wsUrl: string; username?: string; } interface ConnectionContextValue { role: ConnectionRole | null; wsUrl: string; username: string; status: ConnectionStatus; isInMatch: boolean; reconnectAttempts: number; shouldRedirectToConnect: boolean; becomePlayer: (username: string) => void; connect: (options: ConnectOptions) => void; disconnect: () => void; send: (message: string) => boolean; subscribe: (listener: MessageListener) => () => void; clearRedirectFlag: () => void; } const ConnectionContext = createContext(null); interface SessionState { role: ConnectionRole; wsUrl: string; username: string; } export function ConnectionProvider({ children, }: { children: React.ReactNode; }) { const [role, setRole] = useState(null); const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL); const [username, setUsername] = useState(""); const [status, setStatus] = useState("idle"); const [isInMatch, setIsInMatch] = useState(false); const [reconnectAttempts, setReconnectAttempts] = useState(0); const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false); const wsRef = useRef(null); const listenersRef = useRef>(new Set()); const manualCloseRef = useRef(false); const reconnectTimerRef = useRef | null>(null); const reconnectDeadlineRef = useRef(null); const reconnectActiveRef = useRef(false); const isInMatchRef = useRef(false); const sessionRef = useRef(null); const clearReconnectTimer = useCallback(() => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } }, []); const clearReconnectState = useCallback(() => { reconnectActiveRef.current = false; reconnectDeadlineRef.current = null; clearReconnectTimer(); setReconnectAttempts(0); }, [clearReconnectTimer]); const emitMessage = useCallback((message: ParsedMessage, raw: string) => { listenersRef.current.forEach((listener) => listener(message, raw)); }, []); const safeCloseSocket = useCallback(() => { const current = wsRef.current; if (!current) return; current.onopen = null; current.onmessage = null; current.onclose = null; current.onerror = null; try { current.close(); } catch { // no-op } wsRef.current = null; }, []); const handleDisconnect = useCallback(() => { const currentRole = sessionRef.current?.role; if (currentRole === "observer") { clearReconnectState(); setStatus("disconnected"); setShouldRedirectToConnect(true); return; } if (currentRole === "player" && isInMatchRef.current) { if (reconnectActiveRef.current) { setStatus("reconnecting"); return; } reconnectActiveRef.current = true; reconnectDeadlineRef.current = Date.now() + RECONNECT_TIMEOUT_MS; setStatus("reconnecting"); setReconnectAttempts(0); return; } clearReconnectState(); setStatus("disconnected"); setShouldRedirectToConnect(true); }, [clearReconnectState]); const attachSocket = useCallback( (socket: WebSocket, reconnecting: boolean) => { socket.onopen = () => { const session = sessionRef.current; if (!session) return; if (session.role === "observer") { socket.send(cmd.observe()); return; } if (reconnecting) { socket.send(cmd.reconnect(session.username)); } else { socket.send(cmd.connect(session.username)); } }; socket.onmessage = (event) => { const raw = event.data as string; console.log(raw); const parsed = parseMessage(raw); if (parsed.type === "OBSERVE_ACK") { setRole("observer"); setShouldRedirectToConnect(false); setStatus("connected"); } if (parsed.type === "CONNECT_ACK") { setRole("player"); } if (parsed.type === "RECONNECT_ACK") { clearReconnectState(); setShouldRedirectToConnect(false); setStatus("connected"); } if (parsed.type === "DISCONNECT_ACK") { setRole("observer"); setUsername(""); isInMatchRef.current = false; setIsInMatch(false); } if (parsed.type === "GAME_START") { isInMatchRef.current = true; setIsInMatch(true); } if ( parsed.type === "GAME_WINS" || parsed.type === "GAME_LOSS" || parsed.type === "GAME_DRAW" || parsed.type === "GAME_TERMINATED" ) { isInMatchRef.current = false; setIsInMatch(false); } if ( parsed.type === "ERROR" && reconnecting && parsed.message.startsWith("ERROR:INVALID:RECONNECT") ) { safeCloseSocket(); } emitMessage(parsed, raw); }; socket.onclose = () => { wsRef.current = null; if (manualCloseRef.current) { manualCloseRef.current = false; return; } handleDisconnect(); }; socket.onerror = () => { // Allow close event to drive state transitions. }; }, [clearReconnectState, emitMessage, handleDisconnect, safeCloseSocket], ); const openSocket = useCallback( (reconnecting: boolean) => { const session = sessionRef.current; if (!session) return; safeCloseSocket(); manualCloseRef.current = false; const socket = new WebSocket(session.wsUrl); wsRef.current = socket; attachSocket(socket, reconnecting); }, [attachSocket, safeCloseSocket], ); useEffect(() => { if (!reconnectActiveRef.current) return; const runReconnectAttempt = () => { if (!reconnectActiveRef.current) return; const deadline = reconnectDeadlineRef.current; if (!deadline || Date.now() >= deadline) { reconnectActiveRef.current = false; reconnectDeadlineRef.current = null; setStatus("disconnected"); setShouldRedirectToConnect(true); return; } setReconnectAttempts((prev) => prev + 1); openSocket(true); clearReconnectTimer(); reconnectTimerRef.current = setTimeout( runReconnectAttempt, RECONNECT_INTERVAL_MS, ); }; runReconnectAttempt(); return () => clearReconnectTimer(); }, [clearReconnectTimer, openSocket, status]); const connect = useCallback( ({ role, wsUrl, username }: ConnectOptions) => { const resolvedUsername = (username ?? "").trim(); sessionRef.current = { role, wsUrl, username: resolvedUsername }; setRole(role); setWsUrl(wsUrl); setUsername(resolvedUsername); setShouldRedirectToConnect(false); clearReconnectState(); isInMatchRef.current = false; setIsInMatch(false); setStatus("connecting"); openSocket(false); }, [clearReconnectState, openSocket], ); const becomePlayer = useCallback( (username: string) => { const resolvedUsername = (username ?? "").trim(); setRole("player"); setUsername(resolvedUsername); isInMatchRef.current = false; setIsInMatch(false); send(cmd.connect(resolvedUsername)); }, [clearReconnectState, openSocket], ); const disconnect = useCallback(() => { clearReconnectState(); manualCloseRef.current = true; safeCloseSocket(); sessionRef.current = null; setRole(null); setStatus("idle"); setUsername(""); setIsInMatch(false); isInMatchRef.current = false; setShouldRedirectToConnect(false); }, [clearReconnectState, safeCloseSocket]); const send = useCallback((message: string) => { if (wsRef.current?.readyState !== WebSocket.OPEN) return false; wsRef.current.send(message); return true; }, []); const subscribe = useCallback((listener: MessageListener) => { listenersRef.current.add(listener); return () => { listenersRef.current.delete(listener); }; }, []); const clearRedirectFlag = useCallback(() => { setShouldRedirectToConnect(false); }, []); useEffect(() => { return () => { clearReconnectState(); manualCloseRef.current = true; safeCloseSocket(); }; }, [clearReconnectState, safeCloseSocket]); const value = useMemo( () => ({ role, wsUrl, username, status, isInMatch, reconnectAttempts, shouldRedirectToConnect, becomePlayer, connect, disconnect, send, subscribe, clearRedirectFlag, }), [ role, wsUrl, username, status, isInMatch, reconnectAttempts, shouldRedirectToConnect, connect, disconnect, send, subscribe, clearRedirectFlag, ], ); return ( {children} ); } export function useConnection() { const context = useContext(ConnectionContext); if (!context) { throw new Error("useConnection must be used within a ConnectionProvider"); } return context; }