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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-01 20:50:43 -06:00
parent a20e97a0a0
commit c0194b77eb
3 changed files with 171 additions and 21 deletions

View File

@ -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)
}
}

View File

@ -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<VisibleGameState | null>(null)
/** Current game ID */
const currentGameId = ref<string | null>(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>(ConnectionStatus.DISCONNECTED)
/** Last event ID received from server (for reconnection replay) */
const lastEventId = ref<string | null>(null)
/** Actions queued while offline (for retry on reconnect) */
const pendingActions = ref<Action[]>([])
/** 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<string | null>(() =>
gameState.value?.game_id ?? null
gameState.value?.game_id ?? currentGameId.value
)
/** Current turn phase */
const currentPhase = computed<TurnPhase | null>(() =>
gameState.value?.phase ?? null
)
/** Computed connection state helpers */
const isConnected = computed<boolean>(() =>
connectionStatus.value === ConnectionStatus.CONNECTED
)
/** My player state */
const myPlayerState = computed<VisiblePlayerState | null>(() => {
if (!gameState.value) return null
return getMyPlayerState(gameState.value)
})
/** Opponent's player state */
const opponentPlayerState = computed<VisiblePlayerState | null>(() => {
if (!gameState.value) return null
return getOpponentState(gameState.value)
})
/** Whether it's my turn */
const isMyTurn = computed<boolean>(() =>
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'],
},
})

View File

@ -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)
// =============================================================================