diff --git a/src/apps/golf/components/GolfGame.module.css b/src/apps/golf/components/GolfGame.module.css index befe396..71dd7d3 100644 --- a/src/apps/golf/components/GolfGame.module.css +++ b/src/apps/golf/components/GolfGame.module.css @@ -50,6 +50,13 @@ gap: 1rem; } +.permalinkError { + color: #ff6b6b; + text-align: center; + font-size: 1.1rem; + margin: 0; +} + .primaryButton, .secondaryButton { padding: 0.75rem 1.5rem; diff --git a/src/apps/golf/components/GolfGame.tsx b/src/apps/golf/components/GolfGame.tsx index 3e805c2..f352195 100644 --- a/src/apps/golf/components/GolfGame.tsx +++ b/src/apps/golf/components/GolfGame.tsx @@ -64,7 +64,8 @@ const GolfGame = ({ onGameIdChange, onPlayerIdChange, onPlayerNameChange, onConn setRoomCode, leaveGame, dismissNewGameNotification, - joinNewGame + joinNewGame, + permalinkJoinAttempt } = useGolfGame({ onGameIdChange, onPlayerIdChange, onPlayerNameChange, onConnectionChange, permalinkParams }) const confirmLeave = useCallback(() => { @@ -355,6 +356,21 @@ const GolfGame = ({ onGameIdChange, onPlayerIdChange, onPlayerNameChange, onConn } if (!gameState) { + if (permalinkJoinAttempt.error) { + return ( +
+

Golf Card Game

+
+
+

{permalinkJoinAttempt.error}

+ +
+
+
+ ) + } return
Loading...
} diff --git a/src/hooks/useGolfGame.ts b/src/hooks/useGolfGame.ts index d0248bf..bdddddf 100644 --- a/src/hooks/useGolfGame.ts +++ b/src/hooks/useGolfGame.ts @@ -105,6 +105,7 @@ export const useGolfGame = ({ error: null as string | null, gameJoinAttempted: false }) + const [isReconnecting, setIsReconnecting] = useState(false) const [isManualNavigation, setIsManualNavigation] = useState(false) const [, setIsCreatingNewGame] = useState(false) const [newGameNotifications, setNewGameNotifications] = useState(null) const permalinkTimeoutRef = useRef(null) const notificationTimeoutRef = useRef(null) + const reconnectTimeoutRef = useRef(null) const countdownIntervalRef = useRef(null) const navigate = useNavigate() @@ -407,7 +409,19 @@ export const useGolfGame = ({ useEffect(() => { // Create network adapter with callbacks const adapter = new GolfNetworkAdapter({ + onReconnecting: () => { + setIsReconnecting(true) + // Safety: clear after 2s in case server has no state to restore + reconnectTimeoutRef.current = window.setTimeout(() => { + setIsReconnecting(false) + }, 2000) + }, onRoomJoined: (newPlayerId, newRoomState) => { + setIsReconnecting(false) + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } setPlayerId(newPlayerId) setRoomState(newRoomState) setIsInRoom(true) @@ -480,27 +494,7 @@ export const useGolfGame = ({ }, onNotification: (message) => { showNotification(message) - - // Handle permalink join errors - if (permalinkJoinAttempt.isAttempting) { - // Check for common error messages that indicate join failure - if (message.includes('not found') || message.includes('does not exist') || - message.includes('failed to join') || message.includes('error')) { - // Clear timeout and set error - if (permalinkTimeoutRef.current) { - clearTimeout(permalinkTimeoutRef.current) - permalinkTimeoutRef.current = null - } - setPermalinkJoinAttempt({ - isAttempting: false, - roomId: null, - gameId: null, - error: message, - gameJoinAttempted: false - }) - } - } - + // Parse game end notifications if (message.includes('Winner:')) { const winnerMatch = message.match(/Winner: (.+)/) @@ -509,6 +503,22 @@ export const useGolfGame = ({ } } }, + onGameError: (errorMessage) => { + if (permalinkJoinAttempt.isAttempting && + (errorMessage.includes('not found') || errorMessage.includes('does not exist'))) { + if (permalinkTimeoutRef.current) { + clearTimeout(permalinkTimeoutRef.current) + permalinkTimeoutRef.current = null + } + setPermalinkJoinAttempt({ + isAttempting: false, + roomId: null, + gameId: null, + error: 'This room no longer exists.', + gameJoinAttempted: false + }) + } + }, onConnectionChange: (connected) => { setIsConnected(connected) onConnectionChange?.(connected) @@ -562,6 +572,9 @@ export const useGolfGame = ({ if (permalinkTimeoutRef.current) { clearTimeout(permalinkTimeoutRef.current) } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [onGameIdChange, onPlayerIdChange, onPlayerNameChange, onConnectionChange, showNotification]) @@ -570,7 +583,8 @@ export const useGolfGame = ({ // Handle permalink-based automatic joining useEffect(() => { // Only attempt permalink joining if we have valid params and are connected - if (!permalinkParams || !permalinkParams.isValid || !isConnected || !networkAdapterRef.current) { + // Skip while reconnection state restore is in progress to avoid racing + if (!permalinkParams || !permalinkParams.isValid || !isConnected || isReconnecting || !networkAdapterRef.current) { return } @@ -627,7 +641,7 @@ export const useGolfGame = ({ // Join the room first networkAdapterRef.current.joinRoom(permalinkParams.roomId) } - }, [permalinkParams, isConnected, roomState?.id, gameState?.id, permalinkJoinAttempt.isAttempting, permalinkJoinAttempt.gameJoinAttempted, showNotification]) + }, [permalinkParams, isConnected, isReconnecting, roomState?.id, gameState?.id, permalinkJoinAttempt.isAttempting, permalinkJoinAttempt.gameJoinAttempted, showNotification]) // Handle successful room join for permalink flow useEffect(() => { diff --git a/src/plugins/golfNetworkPlugin.ts b/src/plugins/golfNetworkPlugin.ts index 6a6dd1f..f126e4f 100644 --- a/src/plugins/golfNetworkPlugin.ts +++ b/src/plugins/golfNetworkPlugin.ts @@ -49,6 +49,8 @@ export class GolfNetworkPlugin implements GameNetworkPlugin { private onNotification?: (message: string) => void private onGameEnded?: (winner: string, finalScores: FinalScore[]) => void private onNewGameStarted?: (gameId: string, previousGameId?: string) => void + private onReconnecting?: () => void + private onGameError?: (message: string) => void constructor(callbacks?: { onRoomJoined?: (playerId: string, roomState: Room) => void @@ -58,6 +60,8 @@ export class GolfNetworkPlugin implements GameNetworkPlugin { onNotification?: (message: string) => void onGameEnded?: (winner: string, finalScores: FinalScore[]) => void onNewGameStarted?: (gameId: string, previousGameId?: string) => void + onReconnecting?: () => void + onGameError?: (message: string) => void }) { if (callbacks) { this.onRoomJoined = callbacks.onRoomJoined @@ -67,6 +71,8 @@ export class GolfNetworkPlugin implements GameNetworkPlugin { this.onNotification = callbacks.onNotification this.onGameEnded = callbacks.onGameEnded this.onNewGameStarted = callbacks.onNewGameStarted + this.onReconnecting = callbacks.onReconnecting + this.onGameError = callbacks.onGameError } } @@ -213,6 +219,7 @@ export class GolfNetworkPlugin implements GameNetworkPlugin { this.clearSessionToken() } + this.onGameError?.(errorMessage) this.notify(errorMessage, context) } @@ -275,6 +282,7 @@ export class GolfNetworkPlugin implements GameNetworkPlugin { if (isReconnect) { console.log('♻️ Session restored - you should be back in your previous room/game') + this.onReconnecting?.() } } diff --git a/src/utils/networkAdapter.ts b/src/utils/networkAdapter.ts index cb12f19..7b7c009 100644 --- a/src/utils/networkAdapter.ts +++ b/src/utils/networkAdapter.ts @@ -113,6 +113,8 @@ export class GolfNetworkAdapter { onConnectionChange?: (connected: boolean) => void onGameEnded?: (winner: string, finalScores: FinalScore[]) => void onNewGameStarted?: (gameId: string, previousGameId?: string) => void + onReconnecting?: () => void + onGameError?: (message: string) => void }) { // Create manager with connection state callback this.manager = new NetworkManager({ @@ -145,7 +147,9 @@ export class GolfNetworkAdapter { }, onNotification: callbacks?.onNotification, onGameEnded: callbacks?.onGameEnded, - onNewGameStarted: callbacks?.onNewGameStarted + onNewGameStarted: callbacks?.onNewGameStarted, + onReconnecting: callbacks?.onReconnecting, + onGameError: callbacks?.onGameError }) // Register the plugin