268 lines
7.3 KiB
TypeScript
268 lines
7.3 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from "react";
|
||
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||
|
|
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
||
|
|
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
||
|
|
import { tStatic } from "../i18n";
|
||
|
|
|
||
|
|
const STORAGE_KEY = "negopoly:session";
|
||
|
|
|
||
|
|
type StoredSession = {
|
||
|
|
sessionId: string;
|
||
|
|
sessionCode: string;
|
||
|
|
playerId: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function readStoredSession(): Promise<StoredSession | null> {
|
||
|
|
try {
|
||
|
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||
|
|
if (!raw) return null;
|
||
|
|
return JSON.parse(raw) as StoredSession;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeStoredSession(session: StoredSession) {
|
||
|
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function clearStoredSession() {
|
||
|
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useSessionManager() {
|
||
|
|
const [sessionId, setSessionId] = useState("");
|
||
|
|
const [sessionCode, setSessionCode] = useState("");
|
||
|
|
const [playerId, setPlayerId] = useState("");
|
||
|
|
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
|
||
|
|
const [connectionState, setConnectionState] = useState<
|
||
|
|
"idle" | "connecting" | "open" | "error"
|
||
|
|
>("idle");
|
||
|
|
const [tick, setTick] = useState(0);
|
||
|
|
|
||
|
|
const wsRef = useRef<WebSocket | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let mounted = true;
|
||
|
|
readStoredSession().then((stored) => {
|
||
|
|
if (!mounted || !stored) return;
|
||
|
|
setSessionId(stored.sessionId);
|
||
|
|
setSessionCode(stored.sessionCode);
|
||
|
|
setPlayerId(stored.playerId);
|
||
|
|
});
|
||
|
|
return () => {
|
||
|
|
mounted = false;
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
||
|
|
return () => clearInterval(timer);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!sessionId || !playerId) {
|
||
|
|
setConnectionState("idle");
|
||
|
|
setSession(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setConnectionState("connecting");
|
||
|
|
const ws = new WebSocket(getWsUrl(sessionId, playerId));
|
||
|
|
wsRef.current = ws;
|
||
|
|
|
||
|
|
ws.onopen = () => setConnectionState("open");
|
||
|
|
ws.onmessage = (event) => {
|
||
|
|
try {
|
||
|
|
const message = JSON.parse(event.data);
|
||
|
|
if (message.type === "state") {
|
||
|
|
setSession(message.session as SessionSnapshot);
|
||
|
|
}
|
||
|
|
if (message.type === "error") {
|
||
|
|
setError(message.message);
|
||
|
|
}
|
||
|
|
if (message.type === "takeover_approved") {
|
||
|
|
const assignedId = message.assignedPlayerId as string;
|
||
|
|
setPlayerId(assignedId);
|
||
|
|
if (sessionId && sessionCode) {
|
||
|
|
writeStoredSession({
|
||
|
|
sessionId,
|
||
|
|
sessionCode,
|
||
|
|
playerId: assignedId,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
setError(tStatic("error.parseResponse"));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onerror = () => setConnectionState("error");
|
||
|
|
ws.onclose = (event) => {
|
||
|
|
setConnectionState("error");
|
||
|
|
const reason = typeof event?.reason === "string" ? event.reason : "";
|
||
|
|
if (event?.code === 1008 && /session not found|player not found/i.test(reason)) {
|
||
|
|
resetSession();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const pingTimer = setInterval(() => {
|
||
|
|
if (ws.readyState === WebSocket.OPEN) {
|
||
|
|
ws.send(JSON.stringify({ type: "ping", sessionId, playerId }));
|
||
|
|
}
|
||
|
|
}, 15000);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
clearInterval(pingTimer);
|
||
|
|
ws.close();
|
||
|
|
};
|
||
|
|
}, [sessionId, playerId]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!pendingTakeoverId) return;
|
||
|
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||
|
|
if (!sessionId || !playerId) return;
|
||
|
|
wsRef.current.send(
|
||
|
|
JSON.stringify({
|
||
|
|
type: "takeover_request",
|
||
|
|
sessionId,
|
||
|
|
playerId,
|
||
|
|
dummyId: pendingTakeoverId,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
setPendingTakeoverId(null);
|
||
|
|
}, [pendingTakeoverId, sessionId, playerId]);
|
||
|
|
|
||
|
|
async function createSession(bankerName: string) {
|
||
|
|
setError(null);
|
||
|
|
setSession(null);
|
||
|
|
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ bankerName }),
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
setError(tStatic("error.createSession"));
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const data = (await response.json()) as JoinResponse;
|
||
|
|
setSessionId(data.sessionId);
|
||
|
|
setSessionCode(data.sessionCode);
|
||
|
|
setPlayerId(data.playerId);
|
||
|
|
await writeStoredSession({
|
||
|
|
sessionId: data.sessionId,
|
||
|
|
sessionCode: data.sessionCode,
|
||
|
|
playerId: data.playerId,
|
||
|
|
});
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function joinSession(code: string, name: string) {
|
||
|
|
setError(null);
|
||
|
|
setSession(null);
|
||
|
|
if (!code) {
|
||
|
|
setError(tStatic("entry.alert.enterCode"));
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const storedNow = await readStoredSession();
|
||
|
|
const reusePlayerId =
|
||
|
|
storedNow && (storedNow.sessionCode === code || storedNow.sessionId === code)
|
||
|
|
? storedNow.playerId
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ name, playerId: reusePlayerId }),
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
setError(tStatic("error.joinSession"));
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const data = (await response.json()) as JoinResponse;
|
||
|
|
setSessionId(data.sessionId);
|
||
|
|
setSessionCode(data.sessionCode);
|
||
|
|
setPlayerId(data.playerId);
|
||
|
|
await writeStoredSession({
|
||
|
|
sessionId: data.sessionId,
|
||
|
|
sessionCode: data.sessionCode,
|
||
|
|
playerId: data.playerId,
|
||
|
|
});
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
||
|
|
if (!code) return null;
|
||
|
|
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
||
|
|
if (!response.ok) {
|
||
|
|
setError(tStatic("error.loadSessionInfo"));
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return (await response.json()) as SessionPreview;
|
||
|
|
}
|
||
|
|
|
||
|
|
function sendMessage(payload: Record<string, unknown>) {
|
||
|
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||
|
|
setError(tStatic("error.connectionNotReady"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
wsRef.current.send(JSON.stringify(payload));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function resetSession() {
|
||
|
|
try {
|
||
|
|
await clearStoredSession();
|
||
|
|
} catch {
|
||
|
|
// Ignore storage errors on reset.
|
||
|
|
}
|
||
|
|
setSessionId("");
|
||
|
|
setSessionCode("");
|
||
|
|
setPlayerId("");
|
||
|
|
setSession(null);
|
||
|
|
setError(null);
|
||
|
|
setPendingTakeoverId(null);
|
||
|
|
setConnectionState("idle");
|
||
|
|
}
|
||
|
|
|
||
|
|
async function leaveSession() {
|
||
|
|
const ws = wsRef.current;
|
||
|
|
wsRef.current = null;
|
||
|
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
||
|
|
try {
|
||
|
|
ws.close();
|
||
|
|
} catch {
|
||
|
|
// Ignore failures while closing the socket.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await resetSession();
|
||
|
|
}
|
||
|
|
|
||
|
|
const me = session?.players.find((player) => player.id === playerId) ?? null;
|
||
|
|
const isBanker = me?.role === "banker";
|
||
|
|
|
||
|
|
return {
|
||
|
|
sessionId,
|
||
|
|
sessionCode,
|
||
|
|
playerId,
|
||
|
|
session,
|
||
|
|
me,
|
||
|
|
isBanker,
|
||
|
|
tick,
|
||
|
|
error,
|
||
|
|
connectionState,
|
||
|
|
setError,
|
||
|
|
createSession,
|
||
|
|
joinSession,
|
||
|
|
fetchSessionPreview,
|
||
|
|
sendMessage,
|
||
|
|
resetSession,
|
||
|
|
leaveSession,
|
||
|
|
setSessionId,
|
||
|
|
setPlayerId,
|
||
|
|
setSession,
|
||
|
|
setPendingTakeoverId,
|
||
|
|
};
|
||
|
|
}
|