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);