Files
tic-tac-toe-ui/src/tictactoe/providers/NakamaProvider.tsx
Vishesh 'ironeagle' Bangotra b25cd1a039
All checks were successful
continuous-integration/drone/push Build is passing
logging connection params
2025-11-29 23:06:34 +05:30

290 lines
7.6 KiB
TypeScript

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<void>;
logout(): Promise<void>;
joinMatchmaker(mode: string): Promise<string>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: any) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>;
}
export const NakamaContext = createContext<NakamaContextType>(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<Session | null>(null);
const [socket, setSocket] = useState<Socket | null>(null);
const [matchId, setMatchId] = useState<string | null>(null);
const lastModeRef = React.useRef<string | null>(null);
const socketRef = React.useRef<Socket | null>(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<ApiLeaderboardRecordList> {
if (!session) return [];
return await client.listLeaderboardRecords(
session,
"tictactoe",
[],
10 // top 10
);
}
async function listOpenMatches(): Promise<ApiMatch[]> {
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 (
<NakamaContext.Provider
value={{
client,
session,
socket,
matchId,
loginOrRegister,
logout,
joinMatchmaker,
joinMatch,
sendMatchData,
onMatchData,
getLeaderboardTop,
listOpenMatches,
}}
>
{children}
</NakamaContext.Provider>
);
}
// ---------------------------------------------
// USE HOOK
// ---------------------------------------------
export function useNakama(): NakamaContextType {
const ctx = useContext(NakamaContext);
if (!ctx) throw new Error("useNakama must be inside a NakamaProvider");
return ctx;
}