From 813ffe21714668cabbfa04d6f5f17801b75ec2bb Mon Sep 17 00:00:00 2001 From: Feror Date: Tue, 24 Mar 2026 10:06:59 +0100 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liorations=20de=20stabilit=C3=A9=20de?= =?UTF-8?q?=20l'application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/src/App.tsx | 64 ++- mobile/src/components/ConnectionBanner.tsx | 102 ++++ mobile/src/i18n.ts | 17 +- mobile/src/notifications.ts | 2 + mobile/src/screens/BankerDashboardScreen.tsx | 10 +- mobile/src/screens/BankerToolsScreen.tsx | 25 +- mobile/src/screens/LobbyScreen.tsx | 36 +- mobile/src/screens/PlayerHomeScreen.tsx | 10 +- mobile/src/screens/chat/ChatThreadScreen.tsx | 5 +- mobile/src/state/connection.test.ts | 29 ++ mobile/src/state/connection.ts | 40 ++ mobile/src/state/session.ts | 499 +++++++++++++++---- server/protocol.ts | 3 + server/websocket.test.ts | 102 ++++ server/websocket.ts | 174 +++++-- 15 files changed, 913 insertions(+), 205 deletions(-) create mode 100644 mobile/src/components/ConnectionBanner.tsx create mode 100644 mobile/src/state/connection.test.ts create mode 100644 mobile/src/state/connection.ts create mode 100644 server/websocket.test.ts diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index a297d72..49948aa 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Linking } from "react-native"; +import { Linking, StyleSheet, View } from "react-native"; import { + CommonActions, NavigationContainer, type LinkingOptions, useNavigationContainerRef, @@ -13,6 +14,7 @@ import type { RootStackParamList } from "./navigation/types"; import { SessionProvider, useSession } from "./state/session-context"; import { getNavigationTheme, useTheme } from "./theme"; import { parseNotificationTarget, type NotificationTarget } from "./notifications"; +import ConnectionBanner from "./components/ConnectionBanner"; function extractGameId(url: string): string | null { try { @@ -93,24 +95,28 @@ function RootNavigationGate() { if (pending.type === "chat") { const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs"; const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat"; - navigationRef.navigate( - targetStack as never, - { - screen: targetTab, + navigationRef.dispatch( + CommonActions.navigate({ + name: targetStack, params: { - screen: "ChatThread", - params: { chatId: pending.chatId }, + screen: targetTab, + params: { + screen: "ChatThread", + params: { chatId: pending.chatId }, + }, }, - } as never, + }), ); return; } const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs"; const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome"; - navigationRef.navigate( - targetStack as never, - { screen: targetTab } as never, + navigationRef.dispatch( + CommonActions.navigate({ + name: targetStack, + params: { screen: targetTab }, + }), ); }, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]); @@ -145,7 +151,7 @@ function RootNavigationGate() { if (!manager.sessionId) { target = "Entry"; } else if (!manager.session) { - target = manager.connectionState === "error" ? "Entry" : "Lobby"; + target = "Lobby"; } else if (manager.session.status === "lobby") { target = "Lobby"; } else if (manager.isBanker) { @@ -195,14 +201,26 @@ function RootNavigationGate() { }, [handleNotificationResponse]); return ( - setNavReady(true)} - linking={linking} - theme={navigationTheme} - > - - + + setNavReady(true)} + linking={linking} + theme={navigationTheme} + > + + + + ); } @@ -220,3 +238,9 @@ export default function App() { ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/mobile/src/components/ConnectionBanner.tsx b/mobile/src/components/ConnectionBanner.tsx new file mode 100644 index 0000000..123e241 --- /dev/null +++ b/mobile/src/components/ConnectionBanner.tsx @@ -0,0 +1,102 @@ +import React, { useMemo } from "react"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useI18n } from "../i18n"; +import { useTheme, type AppTheme } from "../theme"; +import type { SessionConnectionState } from "../state/connection"; + +type ConnectionBannerProps = { + connectionState: SessionConnectionState; + reconnectAttempt: number; + visible: boolean; + onRetry: () => void; +}; + +export default function ConnectionBanner({ + connectionState, + reconnectAttempt, + visible, + onRetry, +}: ConnectionBannerProps) { + const { t } = useI18n(); + const theme = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const insets = useSafeAreaInsets(); + + if (!visible) { + return null; + } + + const title = + connectionState === "connecting" + ? t("connection.connecting") + : t("connection.reconnecting"); + const detail = t("connection.reconnectingDetail", { + count: reconnectAttempt || 1, + }); + + return ( + + + + {title} + {detail} + + + {t("common.retryNow")} + + + + ); +} + +const createStyles = (theme: AppTheme) => + StyleSheet.create({ + wrapper: { + position: "absolute", + left: 12, + right: 12, + zIndex: 20, + }, + banner: { + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: theme.colors.headerBackground, + borderWidth: 1, + borderColor: theme.colors.border, + flexDirection: "row", + alignItems: "center", + gap: 12, + shadowColor: "#000", + shadowOpacity: 0.12, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, + }, + copy: { + flex: 1, + gap: 2, + }, + title: { + color: theme.colors.text, + fontWeight: "700", + }, + detail: { + color: theme.colors.textMuted, + fontSize: 12, + }, + button: { + borderRadius: 999, + backgroundColor: theme.colors.primary, + paddingHorizontal: 12, + paddingVertical: 10, + }, + buttonText: { + color: theme.colors.primaryText, + fontWeight: "600", + }, + }); diff --git a/mobile/src/i18n.ts b/mobile/src/i18n.ts index f62ce22..d4a73c3 100644 --- a/mobile/src/i18n.ts +++ b/mobile/src/i18n.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import type { TransactionKind } from "./shared/types"; type Locale = "en" | "fr"; @@ -22,6 +23,8 @@ const translations = { "common.you": "You", "common.join": "Join", "common.continue": "Continue", + "common.retryNow": "Retry now", + "common.transactions": "Transactions", "common.send": "Send", "common.reset": "Reset", "common.cancel": "Cancel", @@ -153,11 +156,15 @@ const translations = { "transaction.transfer": "Transfer", "transaction.banker_adjust": "Banker adjustment", "transaction.banker_force_transfer": "Forced transfer", + "connection.connecting": "Connecting to your game", + "connection.reconnecting": "Reconnecting to your game", + "connection.reconnectingDetail": "Attempt {count}. Live updates will resume automatically.", "error.parseResponse": "Unable to parse server response", "error.createSession": "Unable to create session", "error.joinSession": "Unable to join session", "error.loadSessionInfo": "Unable to load session info", "error.connectionNotReady": "Connection not ready", + "error.reconnecting": "Reconnecting to the game. Please try again in a moment.", }, fr: { "app.name": "Negopoly Companion", @@ -178,6 +185,8 @@ const translations = { "common.you": "Vous", "common.join": "Rejoindre", "common.continue": "Continuer", + "common.retryNow": "Réessayer", + "common.transactions": "Transactions", "common.send": "Envoyer", "common.reset": "Réinitialiser", "common.cancel": "Annuler", @@ -309,11 +318,15 @@ const translations = { "transaction.transfer": "Transfert", "transaction.banker_adjust": "Ajustement banquier", "transaction.banker_force_transfer": "Transfert forcé", + "connection.connecting": "Connexion à la partie", + "connection.reconnecting": "Reconnexion à la partie", + "connection.reconnectingDetail": "Tentative {count}. Les mises à jour vont reprendre automatiquement.", "error.parseResponse": "Impossible de lire la réponse du serveur", "error.createSession": "Impossible de créer la session", "error.joinSession": "Impossible de rejoindre la session", "error.loadSessionInfo": "Impossible de charger les infos de session", "error.connectionNotReady": "Connexion non prête", + "error.reconnecting": "Reconnexion à la partie en cours. Réessayez dans un instant.", }, } as const; @@ -326,7 +339,7 @@ export function getLocale(): Locale { function translate(locale: Locale, key: I18nKey, vars?: Record) { const table = translations[locale] ?? translations.en; - let template = table[key] ?? translations.en[key] ?? key; + let template: string = table[key] ?? translations.en[key] ?? key; if (vars) { Object.entries(vars).forEach(([name, value]) => { template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value)); @@ -349,7 +362,7 @@ export function tStatic(key: I18nKey, vars?: Record) { } export function formatTransactionKind( - kind: "transfer" | "banker_adjust" | "banker_force_transfer", + kind: TransactionKind, t: (key: I18nKey) => string, ) { return t(`transaction.${kind}` as I18nKey); diff --git a/mobile/src/notifications.ts b/mobile/src/notifications.ts index 9bcb3aa..152926b 100644 --- a/mobile/src/notifications.ts +++ b/mobile/src/notifications.ts @@ -8,6 +8,8 @@ export type NotificationTarget = Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, + shouldShowBanner: true, + shouldShowList: true, shouldPlaySound: true, shouldSetBadge: false, }), diff --git a/mobile/src/screens/BankerDashboardScreen.tsx b/mobile/src/screens/BankerDashboardScreen.tsx index 29c320f..5f5f51f 100644 --- a/mobile/src/screens/BankerDashboardScreen.tsx +++ b/mobile/src/screens/BankerDashboardScreen.tsx @@ -27,10 +27,10 @@ function formatTransactionTimestamp(value: number) { } function getTransactionLabel( - kind: string, + kind: Transaction["kind"], note: string | null | undefined, t: ReturnType["t"], -) { +): string { if (kind === "banker_adjust" || kind === "banker_force_transfer") { const trimmed = note?.trim(); return trimmed || t("common.noReason"); @@ -43,15 +43,15 @@ function getTransactionDisplay( viewerId: string | null | undefined, players: Player[], t: ReturnType["t"], -) { +): { label: string; subtitle: string; amount: string; outgoing: boolean } { const absAmount = Math.abs(transaction.amount); const label = getTransactionLabel(transaction.kind, transaction.note, t); const findPlayer = (id: string | null) => players.find((player) => player.id === id); const from = findPlayer(transaction.fromId); const to = findPlayer(transaction.toId); let outgoing = false; - let counterparty = t("common.bank"); - const timeLabel = formatTransactionTimestamp(transaction.createdAt); + let counterparty: string = t("common.bank"); + const timeLabel: string = formatTransactionTimestamp(transaction.createdAt); if (transaction.kind === "banker_adjust") { outgoing = transaction.amount < 0; diff --git a/mobile/src/screens/BankerToolsScreen.tsx b/mobile/src/screens/BankerToolsScreen.tsx index b4695ee..692ca68 100644 --- a/mobile/src/screens/BankerToolsScreen.tsx +++ b/mobile/src/screens/BankerToolsScreen.tsx @@ -47,10 +47,10 @@ function formatTransactionTimestamp(value: number) { } function getTransactionLabel( - kind: string, + kind: Transaction["kind"], note: string | null | undefined, t: ReturnType["t"], -) { +): string { if (kind === "banker_adjust" || kind === "banker_force_transfer") { const trimmed = note?.trim(); return trimmed || t("common.noReason"); @@ -63,15 +63,15 @@ function getTransactionDisplay( viewerId: string | null | undefined, players: Player[], t: ReturnType["t"], -) { +): { label: string; subtitle: string; amount: string; outgoing: boolean } { const absAmount = Math.abs(transaction.amount); const label = getTransactionLabel(transaction.kind, transaction.note, t); const findPlayer = (id: string | null) => players.find((player) => player.id === id); const from = findPlayer(transaction.fromId); const to = findPlayer(transaction.toId); let outgoing = false; - let counterparty = t("common.bank"); - const timeLabel = formatTransactionTimestamp(transaction.createdAt); + let counterparty: string = t("common.bank"); + const timeLabel: string = formatTransactionTimestamp(transaction.createdAt); if (transaction.kind === "banker_adjust") { outgoing = transaction.amount < 0; @@ -327,6 +327,9 @@ export default function BankerToolsScreen() { ); } + const session = manager.session; + const me = manager.me; + const normalizedAdjustAmount = adjustAmount.replace(",", "."); const adjustValue = Number(normalizedAdjustAmount); const canAdjust = @@ -436,7 +439,7 @@ export default function BankerToolsScreen() { const display = getTransactionDisplay( transaction, selectedPlayerId, - manager.session?.players ?? [], + session.players, t, ); return ( @@ -695,14 +698,14 @@ export default function BankerToolsScreen() { manager.sendMessage({ type: "banker_blackout", sessionId: manager.sessionId, - bankerId: manager.me?.id, - active: !manager.session.blackoutActive, - reason: !manager.session.blackoutActive ? blackoutReason : null, + bankerId: me.id, + active: !session.blackoutActive, + reason: !session.blackoutActive ? blackoutReason : null, }) } > - {manager.session.blackoutActive + {session.blackoutActive ? t("banker.tools.blackoutDisable") : t("banker.tools.blackoutEnable")} @@ -713,7 +716,7 @@ export default function BankerToolsScreen() { manager.sendMessage({ type: "banker_end", sessionId: manager.sessionId, - bankerId: manager.me?.id, + bankerId: me.id, }) } > diff --git a/mobile/src/screens/LobbyScreen.tsx b/mobile/src/screens/LobbyScreen.tsx index f56c54e..d389bf7 100644 --- a/mobile/src/screens/LobbyScreen.tsx +++ b/mobile/src/screens/LobbyScreen.tsx @@ -65,25 +65,28 @@ export default function LobbyScreen() { ); } - const canStart = manager.isBanker && manager.session.status === "lobby"; - const pendingTakeover = manager.session.takeoverRequests.find( + const session = manager.session; + const me = manager.me; + + const canStart = manager.isBanker && session.status === "lobby"; + const pendingTakeover = session.takeoverRequests.find( (request) => request.requesterId === manager.playerId && request.status === "pending", ); const pendingRequests = manager.isBanker - ? manager.session.takeoverRequests.filter((request) => request.status === "pending") + ? session.takeoverRequests.filter((request) => request.status === "pending") : []; return ( {t("lobby.title")} - {t("lobby.code", { code: manager.session.code })} + {t("lobby.code", { code: session.code })} {pendingTakeover ? ( {t("entry.takeoverPending")} ) : null} item.id} contentContainerStyle={styles.list} renderItem={({ item }) => ( @@ -108,10 +111,9 @@ export default function LobbyScreen() { {pendingRequests.map((request) => { const requester = - manager.session.players.find((player) => player.id === request.requesterId) ?? - null; + session.players.find((player) => player.id === request.requesterId) ?? null; const dummy = - manager.session.players.find((player) => player.id === request.dummyId) ?? null; + session.players.find((player) => player.id === request.dummyId) ?? null; const requesterName = requester?.name ?? request.requesterName ?? t("common.player"); return ( @@ -126,12 +128,12 @@ export default function LobbyScreen() { style={styles.buttonSmall} onPress={() => manager.sendMessage({ - type: "banker_takeover_approve", - sessionId: manager.sessionId, - bankerId: manager.me?.id, - dummyId: request.dummyId, - requesterId: request.requesterId, - }) + type: "banker_takeover_approve", + sessionId: manager.sessionId, + bankerId: me.id, + dummyId: request.dummyId, + requesterId: request.requesterId, + }) } > {t("banker.approve")} @@ -143,7 +145,7 @@ export default function LobbyScreen() { ) : null} - {manager.isBanker && manager.session.status === "lobby" && ( + {manager.isBanker && session.status === "lobby" && ( {t("lobby.addDummyTitle")} {t("lobby.addDummySubtitle")} @@ -168,7 +170,7 @@ export default function LobbyScreen() { manager.sendMessage({ type: "banker_create_dummy", sessionId: manager.sessionId, - bankerId: manager.me?.id, + bankerId: me.id, name: dummyName, balance: Number(dummyBalance) || undefined, }); @@ -188,7 +190,7 @@ export default function LobbyScreen() { manager.sendMessage({ type: "banker_start", sessionId: manager.sessionId, - bankerId: manager.me?.id, + bankerId: me.id, }) } > diff --git a/mobile/src/screens/PlayerHomeScreen.tsx b/mobile/src/screens/PlayerHomeScreen.tsx index dd96f8f..1d7fb38 100644 --- a/mobile/src/screens/PlayerHomeScreen.tsx +++ b/mobile/src/screens/PlayerHomeScreen.tsx @@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) { } function getTransactionLabel( - kind: string, + kind: Transaction["kind"], note: string | null | undefined, t: ReturnType["t"], -) { +): string { if (kind === "banker_adjust" || kind === "banker_force_transfer") { const trimmed = note?.trim(); return trimmed || t("common.noReason"); @@ -44,15 +44,15 @@ function getTransactionDisplay( viewerId: string | null | undefined, players: Player[], t: ReturnType["t"], -) { +): { label: string; subtitle: string; amount: string; outgoing: boolean } { const absAmount = Math.abs(transaction.amount); const label = getTransactionLabel(transaction.kind, transaction.note, t); const findPlayer = (id: string | null) => players.find((player) => player.id === id); const from = findPlayer(transaction.fromId); const to = findPlayer(transaction.toId); let outgoing = false; - let counterparty = t("common.bank"); - const timeLabel = formatTransactionTimestamp(transaction.createdAt); + let counterparty: string = t("common.bank"); + const timeLabel: string = formatTransactionTimestamp(transaction.createdAt); if (transaction.kind === "banker_adjust") { outgoing = transaction.amount < 0; diff --git a/mobile/src/screens/chat/ChatThreadScreen.tsx b/mobile/src/screens/chat/ChatThreadScreen.tsx index f778d6e..1b9dd8c 100644 --- a/mobile/src/screens/chat/ChatThreadScreen.tsx +++ b/mobile/src/screens/chat/ChatThreadScreen.tsx @@ -64,6 +64,7 @@ export default function ChatThreadScreen() { ); } + const activeThread = thread; const showEmp = manager.session.blackoutActive && !manager.isBanker; function handleSend() { @@ -74,7 +75,7 @@ export default function ChatThreadScreen() { sessionId: manager.sessionId, playerId: manager.me?.id, body: message.trim(), - groupId: thread.id === "global" ? null : thread.id, + groupId: activeThread.id === "global" ? null : activeThread.id, }); setMessage(""); } @@ -87,7 +88,7 @@ export default function ChatThreadScreen() { keyboardVerticalOffset={keyboardOffset} > - {thread.name} + {activeThread.name} {threadKindLabel} diff --git a/mobile/src/state/connection.test.ts b/mobile/src/state/connection.test.ts new file mode 100644 index 0000000..49b5980 --- /dev/null +++ b/mobile/src/state/connection.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { + CONNECTION_IDLE_TIMEOUT_MS, + RECONNECT_MAX_DELAY_MS, + getReconnectDelayMs, + isConnectionStale, + isTerminalSocketClose, +} from "./connection"; + +test("getReconnectDelayMs grows with backoff and caps at the max delay", () => { + expect(getReconnectDelayMs(0, () => 0)).toBe(800); + expect(getReconnectDelayMs(1, () => 0)).toBe(1600); + expect(getReconnectDelayMs(4, () => 0)).toBe(8000); + expect(getReconnectDelayMs(8, () => 1)).toBe(Math.round(RECONNECT_MAX_DELAY_MS * 1.2)); +}); + +test("isConnectionStale respects the idle timeout", () => { + const now = 100_000; + expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS + 1, now)).toBe(false); + expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS - 1, now)).toBe(true); + expect(isConnectionStale(null, now)).toBe(false); +}); + +test("isTerminalSocketClose only resets on invalid session/player closes", () => { + expect(isTerminalSocketClose(1008, "Session not found")).toBe(true); + expect(isTerminalSocketClose(1008, "Player not found")).toBe(true); + expect(isTerminalSocketClose(4000, "Connection stale")).toBe(false); + expect(isTerminalSocketClose(1008, "Policy violation")).toBe(false); +}); diff --git a/mobile/src/state/connection.ts b/mobile/src/state/connection.ts new file mode 100644 index 0000000..6e98d62 --- /dev/null +++ b/mobile/src/state/connection.ts @@ -0,0 +1,40 @@ +export type SessionConnectionState = + | "idle" + | "connecting" + | "open" + | "reconnecting" + | "error"; + +export const CONNECTION_PING_INTERVAL_MS = 15_000; +export const CONNECTION_IDLE_TIMEOUT_MS = 45_000; +export const CONNECTION_WATCHDOG_INTERVAL_MS = 5_000; +export const RECONNECT_BASE_DELAY_MS = 1_000; +export const RECONNECT_MAX_DELAY_MS = 10_000; + +export function getReconnectDelayMs( + attempt: number, + random: () => number = Math.random, +): number { + const normalizedAttempt = Math.max(0, attempt); + const baseDelay = Math.min( + RECONNECT_MAX_DELAY_MS, + RECONNECT_BASE_DELAY_MS * 2 ** normalizedAttempt, + ); + const jitterMultiplier = 0.8 + random() * 0.4; + return Math.round(baseDelay * jitterMultiplier); +} + +export function isTerminalSocketClose(code?: number, reason?: string): boolean { + return code === 1008 && /session not found|player not found/i.test(reason ?? ""); +} + +export function isConnectionStale( + lastActivityAt: number | null | undefined, + nowMs = Date.now(), + timeoutMs = CONNECTION_IDLE_TIMEOUT_MS, +): boolean { + if (!lastActivityAt) { + return false; + } + return nowMs - lastActivityAt > timeoutMs; +} diff --git a/mobile/src/state/session.ts b/mobile/src/state/session.ts index 10b199f..83572fa 100644 --- a/mobile/src/state/session.ts +++ b/mobile/src/state/session.ts @@ -1,9 +1,18 @@ import { useEffect, useRef, useState } from "react"; +import { AppState, type AppStateStatus } from "react-native"; 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"; import { registerForPushNotificationsAsync } from "../notifications"; +import { + CONNECTION_PING_INTERVAL_MS, + CONNECTION_WATCHDOG_INTERVAL_MS, + type SessionConnectionState, + getReconnectDelayMs, + isConnectionStale, + isTerminalSocketClose, +} from "./connection"; const STORAGE_KEY = "negopoly:session"; @@ -13,6 +22,12 @@ type StoredSession = { playerId: string; }; +type IncomingMessage = + | { type: "state"; session: SessionSnapshot } + | { type: "error"; message: string } + | { type: "takeover_approved"; assignedPlayerId: string } + | { type: "pong" }; + async function readStoredSession(): Promise { try { const raw = await AsyncStorage.getItem(STORAGE_KEY); @@ -37,18 +52,265 @@ export function useSessionManager() { const [playerId, setPlayerId] = useState(""); const [session, setSession] = useState(null); const [error, setError] = useState(null); - const [connectionState, setConnectionState] = useState< - "idle" | "connecting" | "open" | "error" - >("idle"); - const [tick, setTick] = useState(0); + const [connectionState, setConnectionState] = + useState("idle"); const [pushToken, setPushToken] = useState<{ token: string; platform: "ios" | "android"; } | null>(null); + const [reconnectAttempt, setReconnectAttempt] = useState(0); + const [lastActivityAt, setLastActivityAt] = useState(null); const wsRef = useRef(null); + const sessionIdRef = useRef(sessionId); + const sessionCodeRef = useRef(sessionCode); + const playerIdRef = useRef(playerId); + const sessionRef = useRef(session); + const connectionGenerationRef = useRef(0); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | null>(null); + const watchdogTimerRef = useRef | null>(null); + const suppressReconnectRef = useRef(false); + const lastActivityAtRef = useRef(null); + const appStateRef = useRef(AppState.currentState); const lastPushRegistrationRef = useRef(null); + function markActivity(at = Date.now()) { + lastActivityAtRef.current = at; + setLastActivityAt(at); + } + + function clearReconnectTimer() { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + } + + function clearSocketTimers() { + if (pingTimerRef.current) { + clearInterval(pingTimerRef.current); + pingTimerRef.current = null; + } + if (watchdogTimerRef.current) { + clearInterval(watchdogTimerRef.current); + watchdogTimerRef.current = null; + } + } + + function closeSocket(ws: WebSocket | null, code?: number, reason?: string) { + if (!ws || ws.readyState === WebSocket.CLOSED) { + return; + } + try { + if (typeof code === "number") { + ws.close(code, reason); + return; + } + ws.close(); + } catch { + // Ignore close failures. + } + } + + function teardownConnection() { + clearReconnectTimer(); + clearSocketTimers(); + connectionGenerationRef.current += 1; + const ws = wsRef.current; + wsRef.current = null; + closeSocket(ws); + } + + function scheduleReconnect(generation: number) { + if ( + suppressReconnectRef.current || + generation !== connectionGenerationRef.current || + !sessionIdRef.current || + !playerIdRef.current + ) { + return; + } + + clearReconnectTimer(); + const nextAttempt = reconnectAttemptRef.current + 1; + reconnectAttemptRef.current = nextAttempt; + setReconnectAttempt(nextAttempt); + setConnectionState("reconnecting"); + + const delay = getReconnectDelayMs(nextAttempt - 1); + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null; + openSocket("retry"); + }, delay); + } + + function startSocketTimers(ws: WebSocket, generation: number) { + clearSocketTimers(); + + pingTimerRef.current = setInterval(() => { + if ( + generation !== connectionGenerationRef.current || + ws.readyState !== WebSocket.OPEN + ) { + return; + } + try { + ws.send( + JSON.stringify({ + type: "ping", + sessionId: sessionIdRef.current, + playerId: playerIdRef.current, + }), + ); + } catch { + closeSocket(ws, 4001, "Ping failed"); + } + }, CONNECTION_PING_INTERVAL_MS); + + watchdogTimerRef.current = setInterval(() => { + if ( + generation !== connectionGenerationRef.current || + appStateRef.current !== "active" + ) { + return; + } + if (!isConnectionStale(lastActivityAtRef.current)) { + return; + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + closeSocket(ws, 4000, "Connection stale"); + return; + } + scheduleReconnect(generation); + }, CONNECTION_WATCHDOG_INTERVAL_MS); + } + + function openSocket(reason: "initial" | "retry" | "resume" | "manual") { + const targetSessionId = sessionIdRef.current; + const targetPlayerId = playerIdRef.current; + if (!targetSessionId || !targetPlayerId) { + return; + } + + clearReconnectTimer(); + clearSocketTimers(); + + const previousSocket = wsRef.current; + const generation = connectionGenerationRef.current + 1; + connectionGenerationRef.current = generation; + wsRef.current = null; + closeSocket(previousSocket); + + const recovering = Boolean(sessionRef.current) || reason !== "initial"; + setConnectionState(recovering ? "reconnecting" : "connecting"); + + const ws = new WebSocket(getWsUrl(targetSessionId, targetPlayerId)); + wsRef.current = ws; + + ws.onopen = () => { + if (connectionGenerationRef.current !== generation) { + return; + } + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + setConnectionState("open"); + setError(null); + markActivity(); + startSocketTimers(ws, generation); + }; + + ws.onmessage = (event) => { + if (connectionGenerationRef.current !== generation) { + return; + } + + markActivity(); + + try { + const message = JSON.parse(event.data) as IncomingMessage; + if (message.type === "state") { + setSession(message.session); + return; + } + if (message.type === "error") { + setError(message.message); + return; + } + if (message.type === "takeover_approved") { + const assignedId = message.assignedPlayerId; + setPlayerId(assignedId); + if (sessionIdRef.current && sessionCodeRef.current) { + void writeStoredSession({ + sessionId: sessionIdRef.current, + sessionCode: sessionCodeRef.current, + playerId: assignedId, + }); + } + } + } catch { + setError(tStatic("error.parseResponse")); + } + }; + + ws.onerror = () => { + if (connectionGenerationRef.current !== generation) { + return; + } + setConnectionState("reconnecting"); + }; + + ws.onclose = (event) => { + if (connectionGenerationRef.current !== generation) { + return; + } + + if (wsRef.current === ws) { + wsRef.current = null; + } + clearSocketTimers(); + + const reasonText = typeof event?.reason === "string" ? event.reason : ""; + if (isTerminalSocketClose(event?.code, reasonText)) { + setConnectionState("error"); + void resetSession(); + return; + } + + if ( + suppressReconnectRef.current || + !sessionIdRef.current || + !playerIdRef.current + ) { + setConnectionState("idle"); + return; + } + + scheduleReconnect(generation); + }; + } + + function retryConnection() { + if (!sessionIdRef.current || !playerIdRef.current) { + return; + } + suppressReconnectRef.current = false; + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + openSocket("manual"); + } + + useEffect(() => { + sessionIdRef.current = sessionId; + sessionCodeRef.current = sessionCode; + playerIdRef.current = playerId; + }, [playerId, sessionCode, sessionId]); + + useEffect(() => { + sessionRef.current = session; + }, [session]); + useEffect(() => { let mounted = true; readStoredSession().then((stored) => { @@ -74,8 +336,39 @@ export function useSessionManager() { }, []); useEffect(() => { - const timer = setInterval(() => setTick((value) => value + 1), 1000); - return () => clearInterval(timer); + const subscription = AppState.addEventListener("change", (nextState) => { + const previousState = appStateRef.current; + appStateRef.current = nextState; + + if ( + previousState !== "active" && + nextState === "active" && + sessionIdRef.current && + playerIdRef.current + ) { + const socket = wsRef.current; + const socketOpen = socket?.readyState === WebSocket.OPEN; + if (!socketOpen || isConnectionStale(lastActivityAtRef.current)) { + retryConnection(); + return; + } + try { + socket.send( + JSON.stringify({ + type: "ping", + sessionId: sessionIdRef.current, + playerId: playerIdRef.current, + }), + ); + } catch { + retryConnection(); + } + } + }); + + return () => { + subscription.remove(); + }; }, []); useEffect(() => { @@ -83,6 +376,29 @@ export function useSessionManager() { void registerPushTokenFor(sessionId, playerId); }, [pushToken, sessionId, playerId]); + useEffect(() => { + if (!sessionId || !playerId) { + suppressReconnectRef.current = true; + teardownConnection(); + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + lastActivityAtRef.current = null; + setLastActivityAt(null); + setConnectionState("idle"); + setSession(null); + return; + } + + suppressReconnectRef.current = false; + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + openSocket("initial"); + + return () => { + teardownConnection(); + }; + }, [playerId, sessionId]); + async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) { if (!pushToken) return; const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`; @@ -104,64 +420,6 @@ export function useSessionManager() { } } - 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]); - async function requestTakeover( dummyId: string, overrideSessionId?: string, @@ -215,25 +473,30 @@ export function useSessionManager() { 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) { + try { + 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; + } catch { 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) { @@ -249,25 +512,30 @@ export function useSessionManager() { ? 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) { + try { + 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; + } catch { 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 requestTakeoverToken( @@ -339,23 +607,39 @@ export function useSessionManager() { async function fetchSessionPreview(code: string): Promise { if (!code) return null; - const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); - if (!response.ok) { + try { + const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); + if (!response.ok) { + setError(tStatic("error.loadSessionInfo")); + return null; + } + return (await response.json()) as SessionPreview; + } catch { setError(tStatic("error.loadSessionInfo")); return null; } - return (await response.json()) as SessionPreview; } function sendMessage(payload: Record) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - setError(tStatic("error.connectionNotReady")); + retryConnection(); + setError( + connectionState === "reconnecting" || connectionState === "connecting" + ? tStatic("error.reconnecting") + : tStatic("error.connectionNotReady"), + ); return; } wsRef.current.send(JSON.stringify(payload)); } async function resetSession() { + suppressReconnectRef.current = true; + teardownConnection(); + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + lastActivityAtRef.current = null; + setLastActivityAt(null); try { await clearStoredSession(); } catch { @@ -370,15 +654,8 @@ export function useSessionManager() { } 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. - } - } + suppressReconnectRef.current = true; + teardownConnection(); await resetSession(); } @@ -392,9 +669,10 @@ export function useSessionManager() { session, me, isBanker, - tick, error, connectionState, + reconnectAttempt, + lastActivityAt, setError, createSession, joinSession, @@ -404,6 +682,7 @@ export function useSessionManager() { sendMessage, resetSession, leaveSession, + retryConnection, setSessionId, setPlayerId, setSession, diff --git a/server/protocol.ts b/server/protocol.ts index c2a1aae..f7eaa76 100644 --- a/server/protocol.ts +++ b/server/protocol.ts @@ -88,6 +88,9 @@ export type ServerMessage = type: "state"; session: SessionSnapshot; } + | { + type: "pong"; + } | { type: "error"; message: string; diff --git a/server/websocket.test.ts b/server/websocket.test.ts new file mode 100644 index 0000000..1f28f00 --- /dev/null +++ b/server/websocket.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, test } from "bun:test"; +import { createSession, removeSession } from "./store"; +import { joinSession } from "./domain"; +import { + STALE_SOCKET_TIMEOUT_MS, + handleSocketMessage, + reapStaleSockets, + registerSocket, + resetWebsocketStateForTests, + unregisterSocket, +} from "./websocket"; + +const OPEN = 1; +const CLOSED = 3; + +type FakeSocket = { + readyState: number; + sent: string[]; + closes: Array<{ code?: number; reason?: string }>; + send: (payload: string) => void; + close: (code?: number, reason?: string) => void; +}; + +function createFakeSocket(): FakeSocket { + return { + readyState: OPEN, + sent: [], + closes: [], + send(payload: string) { + this.sent.push(payload); + }, + close(code?: number, reason?: string) { + this.closes.push({ code, reason }); + this.readyState = CLOSED; + }, + }; +} + +afterEach(() => { + resetWebsocketStateForTests(); +}); + +test("overlapping sockets do not disconnect a player until the last socket closes", () => { + const { session } = createSession("Banker"); + const player = joinSession(session, "Jules"); + const firstSocket = createFakeSocket(); + const secondSocket = createFakeSocket(); + + registerSocket(firstSocket as unknown as WebSocket, session.id, player.id); + registerSocket(secondSocket as unknown as WebSocket, session.id, player.id); + + unregisterSocket(firstSocket as unknown as WebSocket); + expect(session.players.get(player.id)?.connected).toBe(true); + expect(session.players.get(player.id)?.isDummy).toBe(false); + + unregisterSocket(secondSocket as unknown as WebSocket); + expect(session.players.get(player.id)?.connected).toBe(false); + expect(session.players.get(player.id)?.isDummy).toBe(true); + + removeSession(session.id); +}); + +test("stale sockets are reaped and disconnect the player", () => { + const { session } = createSession("Banker"); + const player = joinSession(session, "Rosa"); + const socket = createFakeSocket(); + + registerSocket(socket as unknown as WebSocket, session.id, player.id); + reapStaleSockets(Date.now() + STALE_SOCKET_TIMEOUT_MS + 1); + + expect(socket.closes[0]).toEqual({ code: 4000, reason: "Connection stale" }); + expect(session.players.get(player.id)?.connected).toBe(false); + expect(session.players.get(player.id)?.isDummy).toBe(true); + + removeSession(session.id); +}); + +test("invalid session registration closes the socket with a terminal code", () => { + const socket = createFakeSocket(); + + registerSocket(socket as unknown as WebSocket, "missing-session", "missing-player"); + + expect(socket.closes[0]).toEqual({ code: 1008, reason: "Session not found" }); +}); + +test("ping messages update liveness and emit a pong", () => { + const { session } = createSession("Banker"); + const player = joinSession(session, "Nina"); + const socket = createFakeSocket(); + + registerSocket(socket as unknown as WebSocket, session.id, player.id); + socket.sent = []; + + handleSocketMessage( + socket as unknown as WebSocket, + JSON.stringify({ type: "ping", sessionId: session.id, playerId: player.id }), + ); + + expect(socket.sent.some((entry) => entry.includes('"type":"pong"'))).toBe(true); + + removeSession(session.id); +}); diff --git a/server/websocket.ts b/server/websocket.ts index 5270f1c..42b8297 100644 --- a/server/websocket.ts +++ b/server/websocket.ts @@ -20,10 +20,24 @@ import { getSession, removeSession } from "./store"; import { now } from "./util"; import { notifyChat, notifyTransaction } from "./notifications"; +export const STALE_SOCKET_TIMEOUT_MS = 45_000; +const STALE_SOCKET_REAP_INTERVAL_MS = 10_000; + +type SocketMeta = { + sessionId: string; + playerId: string; + lastSeenAt: number; +}; + const socketsBySession = new Map>(); -const metaBySocket = new WeakMap(); +const socketsByPlayer = new Map>(); +let metaBySocket = new WeakMap(); const testTimers = new Map>(); +function playerSocketKey(sessionId: string, playerId: string): string { + return `${sessionId}:${playerId}`; +} + function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } @@ -55,7 +69,7 @@ function runTestTransfer(sessionId: string): void { let attempts = 0; while (attempts < 5) { const from = players[randomInt(0, players.length - 1)]; - let to = players[randomInt(0, players.length - 1)]; + const to = players[randomInt(0, players.length - 1)]; if (to.id === from.id) { attempts += 1; continue; @@ -99,6 +113,54 @@ function getSessionSockets(sessionId: string): Set { return set; } +function getPlayerSockets(sessionId: string, playerId: string): Set { + const key = playerSocketKey(sessionId, playerId); + let set = socketsByPlayer.get(key); + if (!set) { + set = new Set(); + socketsByPlayer.set(key, set); + } + return set; +} + +function deleteSocketFromTracking(ws: WebSocket, meta: SocketMeta): { + sessionSocketCount: number; + playerSocketCount: number; +} { + const sessionSockets = socketsBySession.get(meta.sessionId); + if (sessionSockets) { + sessionSockets.delete(ws); + if (sessionSockets.size === 0) { + socketsBySession.delete(meta.sessionId); + } + } + + const playerKey = playerSocketKey(meta.sessionId, meta.playerId); + const playerSockets = socketsByPlayer.get(playerKey); + if (playerSockets) { + playerSockets.delete(ws); + if (playerSockets.size === 0) { + socketsByPlayer.delete(playerKey); + } + } + + metaBySocket.delete(ws); + + return { + sessionSocketCount: sessionSockets?.size ?? 0, + playerSocketCount: playerSockets?.size ?? 0, + }; +} + +function touchSocket(ws: WebSocket): SocketMeta | null { + const meta = metaBySocket.get(ws); + if (!meta) { + return null; + } + meta.lastSeenAt = now(); + return meta; +} + export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void { const session = getSession(sessionId); if (!session) { @@ -110,12 +172,15 @@ export function registerSocket(ws: WebSocket, sessionId: string, playerId: strin ws.close(1008, "Player not found"); return; } + + const meta: SocketMeta = { sessionId, playerId, lastSeenAt: now() }; + metaBySocket.set(ws, meta); + getSessionSockets(sessionId).add(ws); + getPlayerSockets(sessionId, playerId).add(ws); + player.connected = true; player.isDummy = false; - player.lastActiveAt = now(); - - metaBySocket.set(ws, { sessionId, playerId }); - getSessionSockets(sessionId).add(ws); + player.lastActiveAt = meta.lastSeenAt; sendStateToSession(session); } @@ -125,32 +190,64 @@ export function unregisterSocket(ws: WebSocket): void { if (!meta) { return; } + const { sessionId, playerId } = meta; + const { sessionSocketCount, playerSocketCount } = deleteSocketFromTracking(ws, meta); const session = getSession(sessionId); - if (session) { + + if (session && playerSocketCount === 0) { disconnectPlayer(session, playerId); sendStateToSession(session); } - const set = socketsBySession.get(sessionId); - if (set) { - set.delete(ws); - if (set.size === 0) { - socketsBySession.delete(sessionId); - if (session?.isTest) { - stopTestSimulation(sessionId); - removeSession(sessionId); - } - } + + if (sessionSocketCount === 0 && session?.isTest) { + stopTestSimulation(sessionId); + removeSession(sessionId); } - metaBySocket.delete(ws); +} + +export function reapStaleSockets(referenceNow = now()): void { + const staleSockets: WebSocket[] = []; + + socketsBySession.forEach((sessionSockets) => { + sessionSockets.forEach((socket) => { + const meta = metaBySocket.get(socket); + if (!meta) { + return; + } + if (referenceNow - meta.lastSeenAt <= STALE_SOCKET_TIMEOUT_MS) { + return; + } + staleSockets.push(socket); + }); + }); + + staleSockets.forEach((socket) => { + try { + socket.close(4000, "Connection stale"); + } catch { + // Ignore close failures. + } + unregisterSocket(socket); + }); +} + +export function resetWebsocketStateForTests(): void { + socketsBySession.clear(); + socketsByPlayer.clear(); + testTimers.forEach((timer) => clearTimeout(timer)); + testTimers.clear(); + metaBySocket = new WeakMap(); } export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void { + touchSocket(ws); + const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw); let parsed: ClientMessage; try { parsed = JSON.parse(messageText) as ClientMessage; - } catch (error) { + } catch { send(ws, { type: "error", message: "Invalid message" }); return; } @@ -162,7 +259,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v } try { - handleMessage(session, parsed); + handleMessage(ws, session, parsed); } catch (error) { const message = error instanceof DomainError @@ -175,25 +272,24 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v sendStateToSession(session); } -function handleMessage(session: Session, message: ClientMessage): void { +function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void { switch (message.type) { case "chat_send": { const chat = addChatMessage(session, message.playerId, message.body, message.groupId); notifyChat(session, chat); return; } - case "transfer": - { - const transaction = transfer( - session, - message.playerId, - message.toPlayerId, - message.amount, - message.note, - ); - notifyTransaction(session, transaction); - return; - } + case "transfer": { + const transaction = transfer( + session, + message.playerId, + message.toPlayerId, + message.amount, + message.note, + ); + notifyTransaction(session, transaction); + return; + } case "banker_adjust": { const transaction = bankerAdjust( session, @@ -247,6 +343,7 @@ function handleMessage(session: Session, message: ClientMessage): void { } case "ping": touchPlayer(session, message.playerId); + send(ws, { type: "pong" }); return; default: return; @@ -295,6 +392,13 @@ function notifyTakeoverApproval( if (meta.playerId === requesterId) { send(socket, { type: "takeover_approved", assignedPlayerId: assignedId }); meta.playerId = assignedId; + deleteSocketFromTracking(socket, { + ...meta, + playerId: requesterId, + }); + getSessionSockets(sessionId).add(socket); + getPlayerSockets(sessionId, assignedId).add(socket); + metaBySocket.set(socket, meta); } }); } @@ -305,3 +409,7 @@ function send(ws: WebSocket, message: ServerMessage): void { } ws.send(JSON.stringify(message)); } + +setInterval(() => { + reapStaleSockets(); +}, STALE_SOCKET_REAP_INTERVAL_MS);