Améliorations de stabilité de l'application
This commit is contained in:
parent
62beda2bf7
commit
813ffe2171
15 changed files with 913 additions and 205 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Linking } from "react-native";
|
import { Linking, StyleSheet, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
|
CommonActions,
|
||||||
NavigationContainer,
|
NavigationContainer,
|
||||||
type LinkingOptions,
|
type LinkingOptions,
|
||||||
useNavigationContainerRef,
|
useNavigationContainerRef,
|
||||||
|
|
@ -13,6 +14,7 @@ import type { RootStackParamList } from "./navigation/types";
|
||||||
import { SessionProvider, useSession } from "./state/session-context";
|
import { SessionProvider, useSession } from "./state/session-context";
|
||||||
import { getNavigationTheme, useTheme } from "./theme";
|
import { getNavigationTheme, useTheme } from "./theme";
|
||||||
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
||||||
|
import ConnectionBanner from "./components/ConnectionBanner";
|
||||||
|
|
||||||
function extractGameId(url: string): string | null {
|
function extractGameId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -93,24 +95,28 @@ function RootNavigationGate() {
|
||||||
if (pending.type === "chat") {
|
if (pending.type === "chat") {
|
||||||
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||||
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
|
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
|
||||||
navigationRef.navigate(
|
navigationRef.dispatch(
|
||||||
targetStack as never,
|
CommonActions.navigate({
|
||||||
{
|
name: targetStack,
|
||||||
screen: targetTab,
|
|
||||||
params: {
|
params: {
|
||||||
screen: "ChatThread",
|
screen: targetTab,
|
||||||
params: { chatId: pending.chatId },
|
params: {
|
||||||
|
screen: "ChatThread",
|
||||||
|
params: { chatId: pending.chatId },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||||
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
|
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
|
||||||
navigationRef.navigate(
|
navigationRef.dispatch(
|
||||||
targetStack as never,
|
CommonActions.navigate({
|
||||||
{ screen: targetTab } as never,
|
name: targetStack,
|
||||||
|
params: { screen: targetTab },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
|
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
|
||||||
|
|
||||||
|
|
@ -145,7 +151,7 @@ function RootNavigationGate() {
|
||||||
if (!manager.sessionId) {
|
if (!manager.sessionId) {
|
||||||
target = "Entry";
|
target = "Entry";
|
||||||
} else if (!manager.session) {
|
} else if (!manager.session) {
|
||||||
target = manager.connectionState === "error" ? "Entry" : "Lobby";
|
target = "Lobby";
|
||||||
} else if (manager.session.status === "lobby") {
|
} else if (manager.session.status === "lobby") {
|
||||||
target = "Lobby";
|
target = "Lobby";
|
||||||
} else if (manager.isBanker) {
|
} else if (manager.isBanker) {
|
||||||
|
|
@ -195,14 +201,26 @@ function RootNavigationGate() {
|
||||||
}, [handleNotificationResponse]);
|
}, [handleNotificationResponse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer
|
<View style={styles.container}>
|
||||||
ref={navigationRef}
|
<NavigationContainer
|
||||||
onReady={() => setNavReady(true)}
|
ref={navigationRef}
|
||||||
linking={linking}
|
onReady={() => setNavReady(true)}
|
||||||
theme={navigationTheme}
|
linking={linking}
|
||||||
>
|
theme={navigationTheme}
|
||||||
<AppNavigator />
|
>
|
||||||
</NavigationContainer>
|
<AppNavigator />
|
||||||
|
<ConnectionBanner
|
||||||
|
visible={
|
||||||
|
Boolean(manager.sessionId) &&
|
||||||
|
manager.connectionState !== "idle" &&
|
||||||
|
manager.connectionState !== "open"
|
||||||
|
}
|
||||||
|
connectionState={manager.connectionState}
|
||||||
|
reconnectAttempt={manager.reconnectAttempt}
|
||||||
|
onRetry={manager.retryConnection}
|
||||||
|
/>
|
||||||
|
</NavigationContainer>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,3 +238,9 @@ export default function App() {
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
102
mobile/src/components/ConnectionBanner.tsx
Normal file
102
mobile/src/components/ConnectionBanner.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={[styles.wrapper, { top: insets.top + 8 }]}
|
||||||
|
>
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.detail}>{detail}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={onRetry}>
|
||||||
|
<Text style={styles.buttonText}>{t("common.retryNow")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { TransactionKind } from "./shared/types";
|
||||||
|
|
||||||
type Locale = "en" | "fr";
|
type Locale = "en" | "fr";
|
||||||
|
|
||||||
|
|
@ -22,6 +23,8 @@ const translations = {
|
||||||
"common.you": "You",
|
"common.you": "You",
|
||||||
"common.join": "Join",
|
"common.join": "Join",
|
||||||
"common.continue": "Continue",
|
"common.continue": "Continue",
|
||||||
|
"common.retryNow": "Retry now",
|
||||||
|
"common.transactions": "Transactions",
|
||||||
"common.send": "Send",
|
"common.send": "Send",
|
||||||
"common.reset": "Reset",
|
"common.reset": "Reset",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
|
|
@ -153,11 +156,15 @@ const translations = {
|
||||||
"transaction.transfer": "Transfer",
|
"transaction.transfer": "Transfer",
|
||||||
"transaction.banker_adjust": "Banker adjustment",
|
"transaction.banker_adjust": "Banker adjustment",
|
||||||
"transaction.banker_force_transfer": "Forced transfer",
|
"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.parseResponse": "Unable to parse server response",
|
||||||
"error.createSession": "Unable to create session",
|
"error.createSession": "Unable to create session",
|
||||||
"error.joinSession": "Unable to join session",
|
"error.joinSession": "Unable to join session",
|
||||||
"error.loadSessionInfo": "Unable to load session info",
|
"error.loadSessionInfo": "Unable to load session info",
|
||||||
"error.connectionNotReady": "Connection not ready",
|
"error.connectionNotReady": "Connection not ready",
|
||||||
|
"error.reconnecting": "Reconnecting to the game. Please try again in a moment.",
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
"app.name": "Negopoly Companion",
|
"app.name": "Negopoly Companion",
|
||||||
|
|
@ -178,6 +185,8 @@ const translations = {
|
||||||
"common.you": "Vous",
|
"common.you": "Vous",
|
||||||
"common.join": "Rejoindre",
|
"common.join": "Rejoindre",
|
||||||
"common.continue": "Continuer",
|
"common.continue": "Continuer",
|
||||||
|
"common.retryNow": "Réessayer",
|
||||||
|
"common.transactions": "Transactions",
|
||||||
"common.send": "Envoyer",
|
"common.send": "Envoyer",
|
||||||
"common.reset": "Réinitialiser",
|
"common.reset": "Réinitialiser",
|
||||||
"common.cancel": "Annuler",
|
"common.cancel": "Annuler",
|
||||||
|
|
@ -309,11 +318,15 @@ const translations = {
|
||||||
"transaction.transfer": "Transfert",
|
"transaction.transfer": "Transfert",
|
||||||
"transaction.banker_adjust": "Ajustement banquier",
|
"transaction.banker_adjust": "Ajustement banquier",
|
||||||
"transaction.banker_force_transfer": "Transfert forcé",
|
"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.parseResponse": "Impossible de lire la réponse du serveur",
|
||||||
"error.createSession": "Impossible de créer la session",
|
"error.createSession": "Impossible de créer la session",
|
||||||
"error.joinSession": "Impossible de rejoindre la session",
|
"error.joinSession": "Impossible de rejoindre la session",
|
||||||
"error.loadSessionInfo": "Impossible de charger les infos de session",
|
"error.loadSessionInfo": "Impossible de charger les infos de session",
|
||||||
"error.connectionNotReady": "Connexion non prête",
|
"error.connectionNotReady": "Connexion non prête",
|
||||||
|
"error.reconnecting": "Reconnexion à la partie en cours. Réessayez dans un instant.",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -326,7 +339,7 @@ export function getLocale(): Locale {
|
||||||
|
|
||||||
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
|
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
|
||||||
const table = translations[locale] ?? translations.en;
|
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) {
|
if (vars) {
|
||||||
Object.entries(vars).forEach(([name, value]) => {
|
Object.entries(vars).forEach(([name, value]) => {
|
||||||
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
|
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
|
||||||
|
|
@ -349,7 +362,7 @@ export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTransactionKind(
|
export function formatTransactionKind(
|
||||||
kind: "transfer" | "banker_adjust" | "banker_force_transfer",
|
kind: TransactionKind,
|
||||||
t: (key: I18nKey) => string,
|
t: (key: I18nKey) => string,
|
||||||
) {
|
) {
|
||||||
return t(`transaction.${kind}` as I18nKey);
|
return t(`transaction.${kind}` as I18nKey);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export type NotificationTarget =
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowAlert: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@ function formatTransactionTimestamp(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransactionLabel(
|
function getTransactionLabel(
|
||||||
kind: string,
|
kind: Transaction["kind"],
|
||||||
note: string | null | undefined,
|
note: string | null | undefined,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): string {
|
||||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||||
const trimmed = note?.trim();
|
const trimmed = note?.trim();
|
||||||
return trimmed || t("common.noReason");
|
return trimmed || t("common.noReason");
|
||||||
|
|
@ -43,15 +43,15 @@ function getTransactionDisplay(
|
||||||
viewerId: string | null | undefined,
|
viewerId: string | null | undefined,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||||
const absAmount = Math.abs(transaction.amount);
|
const absAmount = Math.abs(transaction.amount);
|
||||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||||
const from = findPlayer(transaction.fromId);
|
const from = findPlayer(transaction.fromId);
|
||||||
const to = findPlayer(transaction.toId);
|
const to = findPlayer(transaction.toId);
|
||||||
let outgoing = false;
|
let outgoing = false;
|
||||||
let counterparty = t("common.bank");
|
let counterparty: string = t("common.bank");
|
||||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||||
|
|
||||||
if (transaction.kind === "banker_adjust") {
|
if (transaction.kind === "banker_adjust") {
|
||||||
outgoing = transaction.amount < 0;
|
outgoing = transaction.amount < 0;
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ function formatTransactionTimestamp(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransactionLabel(
|
function getTransactionLabel(
|
||||||
kind: string,
|
kind: Transaction["kind"],
|
||||||
note: string | null | undefined,
|
note: string | null | undefined,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): string {
|
||||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||||
const trimmed = note?.trim();
|
const trimmed = note?.trim();
|
||||||
return trimmed || t("common.noReason");
|
return trimmed || t("common.noReason");
|
||||||
|
|
@ -63,15 +63,15 @@ function getTransactionDisplay(
|
||||||
viewerId: string | null | undefined,
|
viewerId: string | null | undefined,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||||
const absAmount = Math.abs(transaction.amount);
|
const absAmount = Math.abs(transaction.amount);
|
||||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||||
const from = findPlayer(transaction.fromId);
|
const from = findPlayer(transaction.fromId);
|
||||||
const to = findPlayer(transaction.toId);
|
const to = findPlayer(transaction.toId);
|
||||||
let outgoing = false;
|
let outgoing = false;
|
||||||
let counterparty = t("common.bank");
|
let counterparty: string = t("common.bank");
|
||||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||||
|
|
||||||
if (transaction.kind === "banker_adjust") {
|
if (transaction.kind === "banker_adjust") {
|
||||||
outgoing = transaction.amount < 0;
|
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 normalizedAdjustAmount = adjustAmount.replace(",", ".");
|
||||||
const adjustValue = Number(normalizedAdjustAmount);
|
const adjustValue = Number(normalizedAdjustAmount);
|
||||||
const canAdjust =
|
const canAdjust =
|
||||||
|
|
@ -436,7 +439,7 @@ export default function BankerToolsScreen() {
|
||||||
const display = getTransactionDisplay(
|
const display = getTransactionDisplay(
|
||||||
transaction,
|
transaction,
|
||||||
selectedPlayerId,
|
selectedPlayerId,
|
||||||
manager.session?.players ?? [],
|
session.players,
|
||||||
t,
|
t,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
|
@ -695,14 +698,14 @@ export default function BankerToolsScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_blackout",
|
type: "banker_blackout",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
active: !manager.session.blackoutActive,
|
active: !session.blackoutActive,
|
||||||
reason: !manager.session.blackoutActive ? blackoutReason : null,
|
reason: !session.blackoutActive ? blackoutReason : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonDangerText}>
|
<Text style={styles.buttonDangerText}>
|
||||||
{manager.session.blackoutActive
|
{session.blackoutActive
|
||||||
? t("banker.tools.blackoutDisable")
|
? t("banker.tools.blackoutDisable")
|
||||||
: t("banker.tools.blackoutEnable")}
|
: t("banker.tools.blackoutEnable")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -713,7 +716,7 @@ export default function BankerToolsScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_end",
|
type: "banker_end",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -65,25 +65,28 @@ export default function LobbyScreen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canStart = manager.isBanker && manager.session.status === "lobby";
|
const session = manager.session;
|
||||||
const pendingTakeover = manager.session.takeoverRequests.find(
|
const me = manager.me;
|
||||||
|
|
||||||
|
const canStart = manager.isBanker && session.status === "lobby";
|
||||||
|
const pendingTakeover = session.takeoverRequests.find(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.requesterId === manager.playerId && request.status === "pending",
|
request.requesterId === manager.playerId && request.status === "pending",
|
||||||
);
|
);
|
||||||
const pendingRequests = manager.isBanker
|
const pendingRequests = manager.isBanker
|
||||||
? manager.session.takeoverRequests.filter((request) => request.status === "pending")
|
? session.takeoverRequests.filter((request) => request.status === "pending")
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<Text style={styles.title}>{t("lobby.title")}</Text>
|
<Text style={styles.title}>{t("lobby.title")}</Text>
|
||||||
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
|
<Text style={styles.subtitle}>{t("lobby.code", { code: session.code })}</Text>
|
||||||
{pendingTakeover ? (
|
{pendingTakeover ? (
|
||||||
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={manager.session.players}
|
data={session.players}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
|
|
@ -108,10 +111,9 @@ export default function LobbyScreen() {
|
||||||
<View style={styles.takeoverList}>
|
<View style={styles.takeoverList}>
|
||||||
{pendingRequests.map((request) => {
|
{pendingRequests.map((request) => {
|
||||||
const requester =
|
const requester =
|
||||||
manager.session.players.find((player) => player.id === request.requesterId) ??
|
session.players.find((player) => player.id === request.requesterId) ?? null;
|
||||||
null;
|
|
||||||
const dummy =
|
const dummy =
|
||||||
manager.session.players.find((player) => player.id === request.dummyId) ?? null;
|
session.players.find((player) => player.id === request.dummyId) ?? null;
|
||||||
const requesterName =
|
const requesterName =
|
||||||
requester?.name ?? request.requesterName ?? t("common.player");
|
requester?.name ?? request.requesterName ?? t("common.player");
|
||||||
return (
|
return (
|
||||||
|
|
@ -126,12 +128,12 @@ export default function LobbyScreen() {
|
||||||
style={styles.buttonSmall}
|
style={styles.buttonSmall}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_takeover_approve",
|
type: "banker_takeover_approve",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
dummyId: request.dummyId,
|
dummyId: request.dummyId,
|
||||||
requesterId: request.requesterId,
|
requesterId: request.requesterId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
|
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
|
||||||
|
|
@ -143,7 +145,7 @@ export default function LobbyScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{manager.isBanker && manager.session.status === "lobby" && (
|
{manager.isBanker && session.status === "lobby" && (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
||||||
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
|
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
|
||||||
|
|
@ -168,7 +170,7 @@ export default function LobbyScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_create_dummy",
|
type: "banker_create_dummy",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
name: dummyName,
|
name: dummyName,
|
||||||
balance: Number(dummyBalance) || undefined,
|
balance: Number(dummyBalance) || undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -188,7 +190,7 @@ export default function LobbyScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_start",
|
type: "banker_start",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransactionLabel(
|
function getTransactionLabel(
|
||||||
kind: string,
|
kind: Transaction["kind"],
|
||||||
note: string | null | undefined,
|
note: string | null | undefined,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): string {
|
||||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||||
const trimmed = note?.trim();
|
const trimmed = note?.trim();
|
||||||
return trimmed || t("common.noReason");
|
return trimmed || t("common.noReason");
|
||||||
|
|
@ -44,15 +44,15 @@ function getTransactionDisplay(
|
||||||
viewerId: string | null | undefined,
|
viewerId: string | null | undefined,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||||
const absAmount = Math.abs(transaction.amount);
|
const absAmount = Math.abs(transaction.amount);
|
||||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||||
const from = findPlayer(transaction.fromId);
|
const from = findPlayer(transaction.fromId);
|
||||||
const to = findPlayer(transaction.toId);
|
const to = findPlayer(transaction.toId);
|
||||||
let outgoing = false;
|
let outgoing = false;
|
||||||
let counterparty = t("common.bank");
|
let counterparty: string = t("common.bank");
|
||||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||||
|
|
||||||
if (transaction.kind === "banker_adjust") {
|
if (transaction.kind === "banker_adjust") {
|
||||||
outgoing = transaction.amount < 0;
|
outgoing = transaction.amount < 0;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export default function ChatThreadScreen() {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const activeThread = thread;
|
||||||
const showEmp = manager.session.blackoutActive && !manager.isBanker;
|
const showEmp = manager.session.blackoutActive && !manager.isBanker;
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
|
|
@ -74,7 +75,7 @@ export default function ChatThreadScreen() {
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
playerId: manager.me?.id,
|
playerId: manager.me?.id,
|
||||||
body: message.trim(),
|
body: message.trim(),
|
||||||
groupId: thread.id === "global" ? null : thread.id,
|
groupId: activeThread.id === "global" ? null : activeThread.id,
|
||||||
});
|
});
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +88,7 @@ export default function ChatThreadScreen() {
|
||||||
keyboardVerticalOffset={keyboardOffset}
|
keyboardVerticalOffset={keyboardOffset}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>{thread.name}</Text>
|
<Text style={styles.headerTitle}>{activeThread.name}</Text>
|
||||||
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
|
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
29
mobile/src/state/connection.test.ts
Normal file
29
mobile/src/state/connection.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
40
mobile/src/state/connection.ts
Normal file
40
mobile/src/state/connection.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
||||||
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
||||||
import { tStatic } from "../i18n";
|
import { tStatic } from "../i18n";
|
||||||
import { registerForPushNotificationsAsync } from "../notifications";
|
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";
|
const STORAGE_KEY = "negopoly:session";
|
||||||
|
|
||||||
|
|
@ -13,6 +22,12 @@ type StoredSession = {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type IncomingMessage =
|
||||||
|
| { type: "state"; session: SessionSnapshot }
|
||||||
|
| { type: "error"; message: string }
|
||||||
|
| { type: "takeover_approved"; assignedPlayerId: string }
|
||||||
|
| { type: "pong" };
|
||||||
|
|
||||||
async function readStoredSession(): Promise<StoredSession | null> {
|
async function readStoredSession(): Promise<StoredSession | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
|
@ -37,18 +52,265 @@ export function useSessionManager() {
|
||||||
const [playerId, setPlayerId] = useState("");
|
const [playerId, setPlayerId] = useState("");
|
||||||
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connectionState, setConnectionState] = useState<
|
const [connectionState, setConnectionState] =
|
||||||
"idle" | "connecting" | "open" | "error"
|
useState<SessionConnectionState>("idle");
|
||||||
>("idle");
|
|
||||||
const [tick, setTick] = useState(0);
|
|
||||||
const [pushToken, setPushToken] = useState<{
|
const [pushToken, setPushToken] = useState<{
|
||||||
token: string;
|
token: string;
|
||||||
platform: "ios" | "android";
|
platform: "ios" | "android";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||||||
|
const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const sessionIdRef = useRef(sessionId);
|
||||||
|
const sessionCodeRef = useRef(sessionCode);
|
||||||
|
const playerIdRef = useRef(playerId);
|
||||||
|
const sessionRef = useRef<SessionSnapshot | null>(session);
|
||||||
|
const connectionGenerationRef = useRef(0);
|
||||||
|
const reconnectAttemptRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const watchdogTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const suppressReconnectRef = useRef(false);
|
||||||
|
const lastActivityAtRef = useRef<number | null>(null);
|
||||||
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||||
const lastPushRegistrationRef = useRef<string | null>(null);
|
const lastPushRegistrationRef = useRef<string | null>(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(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
readStoredSession().then((stored) => {
|
readStoredSession().then((stored) => {
|
||||||
|
|
@ -74,8 +336,39 @@ export function useSessionManager() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||||
return () => clearInterval(timer);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -83,6 +376,29 @@ export function useSessionManager() {
|
||||||
void registerPushTokenFor(sessionId, playerId);
|
void registerPushTokenFor(sessionId, playerId);
|
||||||
}, [pushToken, 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) {
|
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
|
||||||
if (!pushToken) return;
|
if (!pushToken) return;
|
||||||
const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`;
|
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(
|
async function requestTakeover(
|
||||||
dummyId: string,
|
dummyId: string,
|
||||||
overrideSessionId?: string,
|
overrideSessionId?: string,
|
||||||
|
|
@ -215,25 +473,30 @@ export function useSessionManager() {
|
||||||
async function createSession(bankerName: string) {
|
async function createSession(bankerName: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ bankerName }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ bankerName }),
|
||||||
if (!response.ok) {
|
});
|
||||||
|
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"));
|
setError(tStatic("error.createSession"));
|
||||||
return null;
|
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) {
|
async function joinSession(code: string, name: string) {
|
||||||
|
|
@ -249,25 +512,30 @@ export function useSessionManager() {
|
||||||
? storedNow.playerId
|
? storedNow.playerId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ name, playerId: reusePlayerId }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ name, playerId: reusePlayerId }),
|
||||||
if (!response.ok) {
|
});
|
||||||
|
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"));
|
setError(tStatic("error.joinSession"));
|
||||||
return null;
|
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(
|
async function requestTakeoverToken(
|
||||||
|
|
@ -339,23 +607,39 @@ export function useSessionManager() {
|
||||||
|
|
||||||
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
try {
|
||||||
if (!response.ok) {
|
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"));
|
setError(tStatic("error.loadSessionInfo"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (await response.json()) as SessionPreview;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage(payload: Record<string, unknown>) {
|
function sendMessage(payload: Record<string, unknown>) {
|
||||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
wsRef.current.send(JSON.stringify(payload));
|
wsRef.current.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetSession() {
|
async function resetSession() {
|
||||||
|
suppressReconnectRef.current = true;
|
||||||
|
teardownConnection();
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
lastActivityAtRef.current = null;
|
||||||
|
setLastActivityAt(null);
|
||||||
try {
|
try {
|
||||||
await clearStoredSession();
|
await clearStoredSession();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -370,15 +654,8 @@ export function useSessionManager() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveSession() {
|
async function leaveSession() {
|
||||||
const ws = wsRef.current;
|
suppressReconnectRef.current = true;
|
||||||
wsRef.current = null;
|
teardownConnection();
|
||||||
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
||||||
try {
|
|
||||||
ws.close();
|
|
||||||
} catch {
|
|
||||||
// Ignore failures while closing the socket.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await resetSession();
|
await resetSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,9 +669,10 @@ export function useSessionManager() {
|
||||||
session,
|
session,
|
||||||
me,
|
me,
|
||||||
isBanker,
|
isBanker,
|
||||||
tick,
|
|
||||||
error,
|
error,
|
||||||
connectionState,
|
connectionState,
|
||||||
|
reconnectAttempt,
|
||||||
|
lastActivityAt,
|
||||||
setError,
|
setError,
|
||||||
createSession,
|
createSession,
|
||||||
joinSession,
|
joinSession,
|
||||||
|
|
@ -404,6 +682,7 @@ export function useSessionManager() {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
resetSession,
|
resetSession,
|
||||||
leaveSession,
|
leaveSession,
|
||||||
|
retryConnection,
|
||||||
setSessionId,
|
setSessionId,
|
||||||
setPlayerId,
|
setPlayerId,
|
||||||
setSession,
|
setSession,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ export type ServerMessage =
|
||||||
type: "state";
|
type: "state";
|
||||||
session: SessionSnapshot;
|
session: SessionSnapshot;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "pong";
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "error";
|
type: "error";
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
102
server/websocket.test.ts
Normal file
102
server/websocket.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -20,10 +20,24 @@ import { getSession, removeSession } from "./store";
|
||||||
import { now } from "./util";
|
import { now } from "./util";
|
||||||
import { notifyChat, notifyTransaction } from "./notifications";
|
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<string, Set<WebSocket>>();
|
const socketsBySession = new Map<string, Set<WebSocket>>();
|
||||||
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
|
const socketsByPlayer = new Map<string, Set<WebSocket>>();
|
||||||
|
let metaBySocket = new WeakMap<WebSocket, SocketMeta>();
|
||||||
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function playerSocketKey(sessionId: string, playerId: string): string {
|
||||||
|
return `${sessionId}:${playerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function randomInt(min: number, max: number): number {
|
function randomInt(min: number, max: number): number {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +69,7 @@ function runTestTransfer(sessionId: string): void {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < 5) {
|
while (attempts < 5) {
|
||||||
const from = players[randomInt(0, players.length - 1)];
|
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) {
|
if (to.id === from.id) {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -99,6 +113,54 @@ function getSessionSockets(sessionId: string): Set<WebSocket> {
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlayerSockets(sessionId: string, playerId: string): Set<WebSocket> {
|
||||||
|
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 {
|
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
|
||||||
const session = getSession(sessionId);
|
const session = getSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
@ -110,12 +172,15 @@ export function registerSocket(ws: WebSocket, sessionId: string, playerId: strin
|
||||||
ws.close(1008, "Player not found");
|
ws.close(1008, "Player not found");
|
||||||
return;
|
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.connected = true;
|
||||||
player.isDummy = false;
|
player.isDummy = false;
|
||||||
player.lastActiveAt = now();
|
player.lastActiveAt = meta.lastSeenAt;
|
||||||
|
|
||||||
metaBySocket.set(ws, { sessionId, playerId });
|
|
||||||
getSessionSockets(sessionId).add(ws);
|
|
||||||
|
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
}
|
}
|
||||||
|
|
@ -125,32 +190,64 @@ export function unregisterSocket(ws: WebSocket): void {
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, playerId } = meta;
|
const { sessionId, playerId } = meta;
|
||||||
|
const { sessionSocketCount, playerSocketCount } = deleteSocketFromTracking(ws, meta);
|
||||||
const session = getSession(sessionId);
|
const session = getSession(sessionId);
|
||||||
if (session) {
|
|
||||||
|
if (session && playerSocketCount === 0) {
|
||||||
disconnectPlayer(session, playerId);
|
disconnectPlayer(session, playerId);
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
}
|
}
|
||||||
const set = socketsBySession.get(sessionId);
|
|
||||||
if (set) {
|
if (sessionSocketCount === 0 && session?.isTest) {
|
||||||
set.delete(ws);
|
stopTestSimulation(sessionId);
|
||||||
if (set.size === 0) {
|
removeSession(sessionId);
|
||||||
socketsBySession.delete(sessionId);
|
|
||||||
if (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<WebSocket, SocketMeta>();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
|
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
|
||||||
|
touchSocket(ws);
|
||||||
|
|
||||||
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
||||||
let parsed: ClientMessage;
|
let parsed: ClientMessage;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(messageText) as ClientMessage;
|
parsed = JSON.parse(messageText) as ClientMessage;
|
||||||
} catch (error) {
|
} catch {
|
||||||
send(ws, { type: "error", message: "Invalid message" });
|
send(ws, { type: "error", message: "Invalid message" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +259,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
handleMessage(session, parsed);
|
handleMessage(ws, session, parsed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof DomainError
|
error instanceof DomainError
|
||||||
|
|
@ -175,25 +272,24 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(session: Session, message: ClientMessage): void {
|
function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "chat_send": {
|
case "chat_send": {
|
||||||
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
||||||
notifyChat(session, chat);
|
notifyChat(session, chat);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "transfer":
|
case "transfer": {
|
||||||
{
|
const transaction = transfer(
|
||||||
const transaction = transfer(
|
session,
|
||||||
session,
|
message.playerId,
|
||||||
message.playerId,
|
message.toPlayerId,
|
||||||
message.toPlayerId,
|
message.amount,
|
||||||
message.amount,
|
message.note,
|
||||||
message.note,
|
);
|
||||||
);
|
notifyTransaction(session, transaction);
|
||||||
notifyTransaction(session, transaction);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
case "banker_adjust": {
|
case "banker_adjust": {
|
||||||
const transaction = bankerAdjust(
|
const transaction = bankerAdjust(
|
||||||
session,
|
session,
|
||||||
|
|
@ -247,6 +343,7 @@ function handleMessage(session: Session, message: ClientMessage): void {
|
||||||
}
|
}
|
||||||
case "ping":
|
case "ping":
|
||||||
touchPlayer(session, message.playerId);
|
touchPlayer(session, message.playerId);
|
||||||
|
send(ws, { type: "pong" });
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
|
|
@ -295,6 +392,13 @@ function notifyTakeoverApproval(
|
||||||
if (meta.playerId === requesterId) {
|
if (meta.playerId === requesterId) {
|
||||||
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
|
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
|
||||||
meta.playerId = 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));
|
ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
reapStaleSockets();
|
||||||
|
}, STALE_SOCKET_REAP_INTERVAL_MS);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue