From c0194b77eb8e40de954630cbe3705b93e5f7cb39 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 1 Feb 2026 20:50:43 -0600 Subject: [PATCH] Add WebSocket client and game store infrastructure - Extend socket client with heartbeat handling - Add game store computed properties and state management - Add ConnectionStatus and game-related types - Support turn phase, game over, and connection tracking Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/socket/client.ts | 36 ++++++++++-- frontend/src/stores/game.ts | 104 +++++++++++++++++++++++++++++----- frontend/src/types/index.ts | 52 +++++++++++++++++ 3 files changed, 171 insertions(+), 21 deletions(-) diff --git a/frontend/src/socket/client.ts b/frontend/src/socket/client.ts index 7956216..f4547d3 100644 --- a/frontend/src/socket/client.ts +++ b/frontend/src/socket/client.ts @@ -231,7 +231,11 @@ class SocketClient { * Subscribe to game state updates. */ onGameState(handler: ServerToClientEvents['game:state']): () => void { - this._socket?.on('game:state', handler) + if (!this._socket) { + console.warn('[SocketClient] Cannot subscribe to game:state - socket not connected') + return () => {} // Return no-op unsubscribe + } + this._socket.on('game:state', handler) return () => this._socket?.off('game:state', handler) } @@ -239,7 +243,11 @@ class SocketClient { * Subscribe to action results. */ onActionResult(handler: ServerToClientEvents['game:action_result']): () => void { - this._socket?.on('game:action_result', handler) + if (!this._socket) { + console.warn('[SocketClient] Cannot subscribe to game:action_result - socket not connected') + return () => {} + } + this._socket.on('game:action_result', handler) return () => this._socket?.off('game:action_result', handler) } @@ -247,7 +255,11 @@ class SocketClient { * Subscribe to game errors. */ onGameError(handler: ServerToClientEvents['game:error']): () => void { - this._socket?.on('game:error', handler) + if (!this._socket) { + console.warn('[SocketClient] Cannot subscribe to game:error - socket not connected') + return () => {} + } + this._socket.on('game:error', handler) return () => this._socket?.off('game:error', handler) } @@ -255,7 +267,11 @@ class SocketClient { * Subscribe to game over events. */ onGameOver(handler: ServerToClientEvents['game:game_over']): () => void { - this._socket?.on('game:game_over', handler) + if (!this._socket) { + console.warn('[SocketClient] Cannot subscribe to game:game_over - socket not connected') + return () => {} + } + this._socket.on('game:game_over', handler) return () => this._socket?.off('game:game_over', handler) } @@ -263,7 +279,11 @@ class SocketClient { * Subscribe to opponent connection status. */ onOpponentConnected(handler: ServerToClientEvents['game:opponent_connected']): () => void { - this._socket?.on('game:opponent_connected', handler) + if (!this._socket) { + console.warn('[SocketClient] Cannot subscribe to game:opponent_connected - socket not connected') + return () => {} + } + this._socket.on('game:opponent_connected', handler) return () => this._socket?.off('game:opponent_connected', handler) } @@ -271,7 +291,11 @@ class SocketClient { * Subscribe to turn timeout warnings. */ onTurnTimeoutWarning(handler: ServerToClientEvents['game:turn_timeout_warning']): () => void { - this._socket?.on('game:turn_timeout_warning', handler) + if (!this._socket) { + console.warn('[SocketClient] Cannot subscribe to game:turn_timeout_warning - socket not connected') + return () => {} + } + this._socket.on('game:turn_timeout_warning', handler) return () => this._socket?.off('game:turn_timeout_warning', handler) } } diff --git a/frontend/src/stores/game.ts b/frontend/src/stores/game.ts index 7f4f416..b109de8 100644 --- a/frontend/src/stores/game.ts +++ b/frontend/src/stores/game.ts @@ -13,8 +13,9 @@ import type { CardInstance, CardDefinition, TurnPhase, + Action, } from '@/types' -import { getMyPlayerState, getOpponentState, getCardDefinition } from '@/types' +import { getMyPlayerState, getOpponentState, getCardDefinition, ConnectionStatus } from '@/types' export const useGameStore = defineStore('game', () => { // --------------------------------------------------------------------------- @@ -24,11 +25,20 @@ export const useGameStore = defineStore('game', () => { /** Current game state from server */ const gameState = ref(null) + /** Current game ID */ + const currentGameId = ref(null) + /** Whether we're currently in a game */ const isInGame = ref(false) - /** Connection status */ - const isConnected = ref(false) + /** WebSocket connection status */ + const connectionStatus = ref(ConnectionStatus.DISCONNECTED) + + /** Last event ID received from server (for reconnection replay) */ + const lastEventId = ref(null) + + /** Actions queued while offline (for retry on reconnect) */ + const pendingActions = ref([]) /** Loading state */ const isLoading = ref(false) @@ -134,11 +144,33 @@ export const useGameStore = defineStore('game', () => { // Computed - Game State // --------------------------------------------------------------------------- - /** Current game ID */ + /** Current game ID from state (prefer currentGameId ref for setting) */ const gameId = computed(() => - gameState.value?.game_id ?? null + gameState.value?.game_id ?? currentGameId.value ) + /** Current turn phase */ + const currentPhase = computed(() => + gameState.value?.phase ?? null + ) + + /** Computed connection state helpers */ + const isConnected = computed(() => + connectionStatus.value === ConnectionStatus.CONNECTED + ) + + /** My player state */ + const myPlayerState = computed(() => { + if (!gameState.value) return null + return getMyPlayerState(gameState.value) + }) + + /** Opponent's player state */ + const opponentPlayerState = computed(() => { + if (!gameState.value) return null + return getOpponentState(gameState.value) + }) + /** Whether it's my turn */ const isMyTurn = computed(() => gameState.value?.is_my_turn ?? false @@ -199,21 +231,48 @@ export const useGameStore = defineStore('game', () => { // --------------------------------------------------------------------------- /** Set the game state (called from WebSocket handler) */ - function setGameState(state: VisibleGameState): void { + function setGameState(state: VisibleGameState, eventId?: string): void { gameState.value = state + currentGameId.value = state.game_id isInGame.value = true error.value = null + + // Track last event ID for reconnection support + if (eventId) { + lastEventId.value = eventId + } + } + + /** Set WebSocket connection status */ + function setConnectionStatus(status: ConnectionStatus): void { + connectionStatus.value = status + } + + /** Queue an action for retry when connection is restored */ + function queueAction(action: Action): void { + pendingActions.value.push(action) + } + + /** Clear pending actions (after successful retry or on game exit) */ + function clearPendingActions(): void { + pendingActions.value = [] + } + + /** Get and clear pending actions (for retry logic) */ + function takePendingActions(): Action[] { + const actions = [...pendingActions.value] + pendingActions.value = [] + return actions } /** Clear the game state (when leaving game) */ - function clearGameState(): void { + function clearGame(): void { gameState.value = null + currentGameId.value = null isInGame.value = false - } - - /** Set connection status */ - function setConnected(connected: boolean): void { - isConnected.value = connected + pendingActions.value = [] + error.value = null + // Keep lastEventId for potential reconnection } /** Set loading state */ @@ -233,14 +292,19 @@ export const useGameStore = defineStore('game', () => { return { // State gameState, + currentGameId, isInGame, - isConnected, + connectionStatus, + lastEventId, + pendingActions, isLoading, error, // Player states myPlayer, opponent, + myPlayerState, + opponentPlayerState, // My zones myHand, @@ -264,21 +328,31 @@ export const useGameStore = defineStore('game', () => { gameId, isMyTurn, phase, + currentPhase, turnNumber, isGameOver, winnerId, didIWin, forcedAction, hasForcedAction, + isConnected, // Card lookup lookupCard, // Actions setGameState, - clearGameState, - setConnected, + setConnectionStatus, + queueAction, + clearPendingActions, + takePendingActions, + clearGame, setLoading, setError, } +}, { + persist: { + // Persist only lastEventId for reconnection support + paths: ['lastEventId'], + }, }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fc4ad9a..bd97307 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -91,6 +91,58 @@ export type { export { CARD_SIZES } from './phaser' +// ============================================================================= +// WebSocket Types (from ws.ts) +// ============================================================================= + +export type { + // Client messages + JoinGameMessage, + ActionMessage, + ResignMessage, + HeartbeatMessage, + ClientMessage, + // Server messages + GameStateMessage, + ActionResultMessage, + ErrorMessage, + TurnStartMessage, + TurnTimeoutMessage, + GameOverMessage, + OpponentStatusMessage, + HeartbeatAckMessage, + ServerMessage, + // Action types + Action, + PlayCardAction, + AttachEnergyAction, + EvolveAction, + AttackAction, + RetreatAction, + UseAbilityAction, + EndTurnAction, + SelectPrizeAction, + SelectNewActiveAction, + DiscardFromHandAction, +} from './ws' + +export { + WSErrorCode, + ConnectionStatus, + // Type guards + isGameStateMessage, + isActionResultMessage, + isErrorMessage, + isTurnStartMessage, + isTurnTimeoutMessage, + isGameOverMessage, + isOpponentStatusMessage, + isHeartbeatAckMessage, + // Utility functions + generateMessageId, + createClientMessage, +} from './ws' + // ============================================================================= // API Types (from api.ts) // =============================================================================