import { Client, Session, Socket, MatchmakerTicket, MatchData, MatchmakerMatched, } from "@heroiclabs/nakama-js"; import { ApiMatch, ApiLeaderboardRecordList, // @ts-ignore } from "@heroiclabs/nakama-js/dist/api.gen" import React, { createContext, useContext, useState } from "react"; function getOrCreateDeviceId(): string { const key = "nakama.deviceId"; let id = localStorage.getItem(key); if (!id) { id = crypto.randomUUID(); localStorage.setItem(key, id); } return id; } export interface NakamaContextType { client: Client; socket: Socket | null; session: Session | null; matchId: string | null; loginOrRegister(username: string): Promise; logout(): Promise; joinMatchmaker(mode: string): Promise; joinMatch(matchId: string): Promise; sendMatchData(matchId: string, op: number, data: object): void; onMatchData(cb: (msg: any) => void): void; getLeaderboardTop(): Promise; listOpenMatches(): Promise; } export const NakamaContext = createContext(null!); export function NakamaProvider({ children }: { children: React.ReactNode }) { console.log( "[Nakama] Initializing...", import.meta.env.VITE_WS_SKEY, import.meta.env.VITE_WS_HOST, import.meta.env.VITE_WS_PORT, import.meta.env.VITE_WS_SSL === "true" ); const [client] = useState( () => new Client( import.meta.env.VITE_WS_SKEY, import.meta.env.VITE_WS_HOST, import.meta.env.VITE_WS_PORT, import.meta.env.VITE_WS_SSL === "true" ) ); const [session, setSession] = useState(null); const [socket, setSocket] = useState(null); const [matchId, setMatchId] = useState(null); const lastModeRef = React.useRef(null); const socketRef = React.useRef(null); async function autoLogin() { const deviceId = getOrCreateDeviceId(); try { return await client.authenticateDevice( deviceId, false ); } catch (e) { // fallback: treat as new user localStorage.removeItem("registered"); throw e; } } async function registerWithUsername(username: string) { const deviceId = getOrCreateDeviceId(); // create + set username const session = await client.authenticateDevice( deviceId, true, username ); // mark an account as registered localStorage.setItem("registered", "yes"); localStorage.setItem("username", username); return session; } async function getSession(username?: string) { const isRegistered = localStorage.getItem("registered") === "yes"; if (!username && !isRegistered) { throw new Error("No username provided and not registered"); } let newSession; if (!isRegistered) { newSession = await registerWithUsername(username ?? ""); } else { newSession = await autoLogin(); } return newSession; } // ---------------------------------------------------- // LOGIN // ---------------------------------------------------- async function loginOrRegister(username?: string) { // authenticate user const newSession = await getSession(username); setSession(newSession); // create a socket (new Nakama 3.x signature) const s = client.createSocket(undefined, undefined); // no SSL on localhost await s.connect(newSession, true); setSocket(s); socketRef.current = s; console.log("[Nakama] WebSocket connected"); // MATCHMAKER MATCHED CALLBACK s.onmatchmakermatched = async (matched: MatchmakerMatched) => { // 1) If match_id is empty → server rejected the group. if (!matched.match_id) { console.warn("[Nakama] Match rejected by server. Auto-requeueing..."); if (lastModeRef.current) { try { await joinMatchmaker(lastModeRef.current); } catch (e) { console.error("[Nakama] Requeue failed:", e); } } return; } // 2) Valid match: continue as usual. console.log("[Nakama] MATCHED:", matched); try { await s.joinMatch(matched.match_id); setMatchId(matched.match_id); console.log("[Nakama] Auto-joined match:", matched.match_id); } catch (err) { console.error("[Nakama] Failed to join match:", err); } }; } async function logout() { try { // 1) Disconnect socket if present if (socketRef.current) { socketRef.current.disconnect(true); console.log("[Nakama] WebSocket disconnected"); } } catch (err) { console.warn("[Nakama] Error while disconnecting socket:", err); } // 2) Clear state setSocket(null); socketRef.current = null; setSession(null); console.log("[Nakama] Clean logout completed"); } // ---------------------------------------------------- // MATCHMAKING // ---------------------------------------------------- async function joinMatchmaker(mode: string) { const socket = socketRef.current; if (!socket) throw new Error("socket missing"); console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`); const ticket: MatchmakerTicket = await socket.addMatchmaker( `*`, // query 2, // min count 2, // max count { mode } // stringProperties ); lastModeRef.current = mode; return ticket.ticket; } // ---------------------------------------------------- // EXPLICIT MATCH JOIN // ---------------------------------------------------- async function joinMatch(id: string) { if (!socket) throw new Error("socket missing"); await socket.joinMatch(id); setMatchId(id); console.log("[Nakama] Joined match", id); } // ---------------------------------------------------- // MATCH STATE SEND // ---------------------------------------------------- function sendMatchData(matchId: string, op: number, data: object) { if (!socket) return; socket.sendMatchState(matchId, op, JSON.stringify(data)); } // ---------------------------------------------------- // MATCH DATA LISTENER // ---------------------------------------------------- function onMatchData(cb: (msg: any) => void) { if (!socket) return; socket.onmatchdata = (m: MatchData) => { const decoded = JSON.parse(new TextDecoder().decode(m.data)); cb({ opCode: m.op_code, data: decoded, userId: m.presence?.user_id ?? null, }); }; } async function getLeaderboardTop(): Promise { if (!session) return []; return await client.listLeaderboardRecords( session, "tictactoe", [], 10 // top 10 ); } async function listOpenMatches(): Promise { if (!session) { console.warn("[Nakama] listOpenMatches called before login"); return []; } const result = await client.listMatches(session, 10); console.log("[Nakama] Open matches:", result.matches); return result.matches ?? []; } return ( {children} ); } // --------------------------------------------- // USE HOOK // --------------------------------------------- export function useNakama(): NakamaContextType { const ctx = useContext(NakamaContext); if (!ctx) throw new Error("useNakama must be inside a NakamaProvider"); return ctx; }