Add Socket.IO client with typed game events (F0-006)

- Create typed event interfaces for game namespace
- Add full game state types (GameState, Card, CardInPlay, etc.)
- Implement connection manager singleton with auth
- Add auto-reconnection with exponential backoff
- Provide helper methods for game actions (joinGame, sendAction, etc.)
- Add typed event subscription helpers with unsubscribe

Phase F0 progress: 7/8 tasks complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 11:15:17 -06:00
parent 0720084cb1
commit 3a566ffd5a
5 changed files with 725 additions and 3 deletions

View File

@ -6,7 +6,7 @@
"created": "2026-01-30",
"lastUpdated": "2026-01-30",
"totalTasks": 8,
"completedTasks": 6,
"completedTasks": 7,
"status": "in_progress"
},
"tasks": [
@ -123,8 +123,8 @@
"description": "WebSocket connection manager",
"category": "api",
"priority": 6,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["F0-001", "F0-004"],
"files": [
{"path": "src/socket/client.ts", "status": "create"},

View File

@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { socketClient } from './client'
describe('socketClient', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset socket client state
socketClient.disconnect()
})
describe('initial state', () => {
it('starts disconnected', () => {
/**
* Test that socket client initializes in disconnected state.
*
* The socket should not connect until explicitly requested,
* to avoid unnecessary connections on pages that don't need
* real-time updates.
*/
expect(socketClient.connectionState).toBe('disconnected')
expect(socketClient.isConnected).toBe(false)
expect(socketClient.socket).toBeNull()
})
})
describe('connect', () => {
it('throws error when not authenticated', async () => {
/**
* Test that connection requires authentication.
*
* The socket server requires a valid token for authentication.
* Attempting to connect without one should fail immediately.
*/
// Auth store starts with no tokens
await expect(socketClient.connect()).rejects.toThrow('not authenticated')
})
it('requires valid token before connecting', async () => {
/**
* Test that expired tokens prevent connection.
*
* If the token is expired and refresh fails, connection
* should not be attempted.
*/
const auth = useAuthStore()
// Set expired token with no refresh token
auth.setTokens({
accessToken: 'expired-token',
refreshToken: '',
expiresAt: Date.now() - 1000, // Already expired
})
await expect(socketClient.connect()).rejects.toThrow('not authenticated')
})
})
describe('connection state listeners', () => {
it('notifies listeners of state changes', () => {
/**
* Test that connection state changes are broadcast.
*
* Components need to react to connection state changes
* (e.g., showing reconnecting indicator).
*/
const listener = vi.fn()
const unsubscribe = socketClient.onConnectionStateChange(listener)
// Trigger a state change by disconnecting
socketClient.disconnect()
expect(listener).toHaveBeenCalledWith('disconnected')
// Cleanup
unsubscribe()
})
it('unsubscribe stops notifications', () => {
/**
* Test that unsubscribe removes the listener.
*
* Components should unsubscribe when unmounting to prevent
* memory leaks and stale callbacks.
*/
const listener = vi.fn()
const unsubscribe = socketClient.onConnectionStateChange(listener)
// Unsubscribe
unsubscribe()
// Clear previous calls
listener.mockClear()
// Trigger a state change
socketClient.disconnect()
// Listener should not be called after unsubscribe
expect(listener).not.toHaveBeenCalled()
})
it('supports multiple listeners', () => {
/**
* Test that multiple listeners can subscribe.
*
* Different components may need to track connection state
* independently.
*/
const listener1 = vi.fn()
const listener2 = vi.fn()
const unsub1 = socketClient.onConnectionStateChange(listener1)
const unsub2 = socketClient.onConnectionStateChange(listener2)
socketClient.disconnect()
expect(listener1).toHaveBeenCalledWith('disconnected')
expect(listener2).toHaveBeenCalledWith('disconnected')
// Cleanup
unsub1()
unsub2()
})
})
describe('disconnect', () => {
it('sets state to disconnected', () => {
/**
* Test that disconnect updates the connection state.
*
* After disconnecting, the state should reflect that
* we're no longer connected.
*/
socketClient.disconnect()
expect(socketClient.connectionState).toBe('disconnected')
expect(socketClient.isConnected).toBe(false)
})
it('clears socket reference', () => {
/**
* Test that disconnect clears the socket.
*
* After disconnecting, the socket reference should be null
* to prevent accidental use of a dead connection.
*/
socketClient.disconnect()
expect(socketClient.socket).toBeNull()
})
})
describe('helper methods exist', () => {
it('has all game action helpers', () => {
/**
* Test that all game action helper methods exist.
*
* These helpers provide a clean API for common game operations
* without needing to know the exact event names.
*/
expect(typeof socketClient.joinGame).toBe('function')
expect(typeof socketClient.sendAction).toBe('function')
expect(typeof socketClient.resign).toBe('function')
expect(typeof socketClient.spectate).toBe('function')
expect(typeof socketClient.leaveSpectate).toBe('function')
})
it('has all event subscription helpers', () => {
/**
* Test that all event subscription helper methods exist.
*
* These helpers provide typed subscriptions to server events
* with automatic cleanup via returned unsubscribe functions.
*/
expect(typeof socketClient.onGameState).toBe('function')
expect(typeof socketClient.onActionResult).toBe('function')
expect(typeof socketClient.onGameError).toBe('function')
expect(typeof socketClient.onGameOver).toBe('function')
expect(typeof socketClient.onOpponentConnected).toBe('function')
expect(typeof socketClient.onTurnTimeoutWarning).toBe('function')
})
})
})

View File

@ -0,0 +1,284 @@
/**
* Socket.IO client manager for real-time game communication.
*
* Provides a singleton connection manager with:
* - Automatic authentication via token
* - Reconnection with exponential backoff
* - Typed event emitters for the game namespace
*/
import { io, Socket } from 'socket.io-client'
import { config } from '@/config'
import { useAuthStore } from '@/stores/auth'
import type { ClientToServerEvents, ServerToClientEvents } from './types'
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
export interface SocketClientOptions {
/** Auto-connect on creation (default: false) */
autoConnect?: boolean
/** Namespace to connect to (default: '/game') */
namespace?: string
}
/**
* Socket.IO client manager.
*
* Use `socketClient.connect()` to establish connection.
* Use `socketClient.socket` to access the raw socket for event handling.
*/
class SocketClient {
private _socket: TypedSocket | null = null
private _connectionState: ConnectionState = 'disconnected'
private _namespace: string = '/game'
// Event listeners for connection state changes
private connectionListeners: Set<(state: ConnectionState) => void> = new Set()
/**
* Get the current connection state.
*/
get connectionState(): ConnectionState {
return this._connectionState
}
/**
* Get the raw socket instance.
*
* Returns null if not connected.
*/
get socket(): TypedSocket | null {
return this._socket
}
/**
* Check if currently connected.
*/
get isConnected(): boolean {
return this._connectionState === 'connected'
}
/**
* Connect to the Socket.IO server.
*
* Authenticates using the current access token from auth store.
*/
async connect(options: SocketClientOptions = {}): Promise<void> {
const { autoConnect = true, namespace = '/game' } = options
this._namespace = namespace
// Don't reconnect if already connected
if (this._socket?.connected) {
return
}
// Disconnect existing socket if any
if (this._socket) {
this._socket.disconnect()
this._socket = null
}
const auth = useAuthStore()
const token = await auth.getValidToken()
if (!token) {
throw new Error('Cannot connect: not authenticated')
}
this.setConnectionState('connecting')
const socketUrl = `${config.wsUrl}${namespace}`
this._socket = io(socketUrl, {
auth: { token },
autoConnect,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
timeout: 20000,
transports: ['websocket', 'polling'],
}) as TypedSocket
this.setupEventHandlers()
// Wait for connection or error
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'))
}, 20000)
this._socket!.once('connect', () => {
clearTimeout(timeout)
resolve()
})
this._socket!.once('connect_error', (error) => {
clearTimeout(timeout)
reject(error)
})
})
}
/**
* Disconnect from the server.
*/
disconnect(): void {
if (this._socket) {
this._socket.disconnect()
this._socket = null
}
this.setConnectionState('disconnected')
}
/**
* Set up internal event handlers for connection management.
*/
private setupEventHandlers(): void {
if (!this._socket) return
this._socket.on('connect', () => {
this.setConnectionState('connected')
})
this._socket.on('disconnect', (reason) => {
if (reason === 'io server disconnect') {
// Server initiated disconnect - don't auto-reconnect
this.setConnectionState('disconnected')
} else {
// Client-side disconnect or network issue - will auto-reconnect
this.setConnectionState('reconnecting')
}
})
this._socket.io.on('reconnect_attempt', () => {
this.setConnectionState('reconnecting')
})
this._socket.io.on('reconnect', () => {
this.setConnectionState('connected')
})
this._socket.io.on('reconnect_failed', () => {
this.setConnectionState('disconnected')
})
}
/**
* Update connection state and notify listeners.
*/
private setConnectionState(state: ConnectionState): void {
this._connectionState = state
this.connectionListeners.forEach(listener => listener(state))
}
/**
* Subscribe to connection state changes.
*
* @returns Unsubscribe function
*/
onConnectionStateChange(listener: (state: ConnectionState) => void): () => void {
this.connectionListeners.add(listener)
return () => this.connectionListeners.delete(listener)
}
// ============================================
// Game Event Helpers
// ============================================
/**
* Join a game room.
*/
joinGame(gameId: string): void {
this._socket?.emit('game:join', { game_id: gameId })
}
/**
* Send a game action.
*/
sendAction(action: ClientToServerEvents['game:action'] extends (data: infer T) => void ? T : never): void {
this._socket?.emit('game:action', action)
}
/**
* Resign from the current game.
*/
resign(gameId: string): void {
this._socket?.emit('game:resign', { game_id: gameId })
}
/**
* Join as spectator.
*/
spectate(gameId: string): void {
this._socket?.emit('game:spectate', { game_id: gameId })
}
/**
* Leave spectator mode.
*/
leaveSpectate(gameId: string): void {
this._socket?.emit('game:leave_spectate', { game_id: gameId })
}
// ============================================
// Event Subscription Helpers
// ============================================
/**
* Subscribe to game state updates.
*/
onGameState(handler: ServerToClientEvents['game:state']): () => void {
this._socket?.on('game:state', handler)
return () => this._socket?.off('game:state', handler)
}
/**
* Subscribe to action results.
*/
onActionResult(handler: ServerToClientEvents['game:action_result']): () => void {
this._socket?.on('game:action_result', handler)
return () => this._socket?.off('game:action_result', handler)
}
/**
* Subscribe to game errors.
*/
onGameError(handler: ServerToClientEvents['game:error']): () => void {
this._socket?.on('game:error', handler)
return () => this._socket?.off('game:error', handler)
}
/**
* Subscribe to game over events.
*/
onGameOver(handler: ServerToClientEvents['game:game_over']): () => void {
this._socket?.on('game:game_over', handler)
return () => this._socket?.off('game:game_over', handler)
}
/**
* Subscribe to opponent connection status.
*/
onOpponentConnected(handler: ServerToClientEvents['game:opponent_connected']): () => void {
this._socket?.on('game:opponent_connected', handler)
return () => this._socket?.off('game:opponent_connected', handler)
}
/**
* Subscribe to turn timeout warnings.
*/
onTurnTimeoutWarning(handler: ServerToClientEvents['game:turn_timeout_warning']): () => void {
this._socket?.on('game:turn_timeout_warning', handler)
return () => this._socket?.off('game:turn_timeout_warning', handler)
}
}
/**
* Singleton socket client instance.
*/
export const socketClient = new SocketClient()
export default socketClient

View File

@ -0,0 +1,29 @@
/**
* Socket.IO module exports.
*/
export { socketClient, default } from './client'
export type { ConnectionState, SocketClientOptions } from './client'
export type {
// Event types
ClientToServerEvents,
ServerToClientEvents,
// Game types
GameState,
PlayerState,
OpponentState,
Card,
CardInPlay,
Attack,
StatusCondition,
// Action types
GameAction,
GameActionType,
ActionResult,
ForcedAction,
// Other types
TurnPhase,
GameError,
GameOverData,
GameOverReason,
GameStats,
} from './types'

View File

@ -0,0 +1,225 @@
/**
* Socket.IO types for the game namespace.
*
* Based on backend WebSocket documentation in backend/app/socketio/
*/
// ============================================
// Client -> Server Events
// ============================================
export interface ClientToServerEvents {
/** Join or rejoin a game room */
'game:join': (data: { game_id: string }) => void
/** Execute a game action */
'game:action': (data: GameAction) => void
/** Resign from the current game */
'game:resign': (data: { game_id: string }) => void
/** Join as spectator */
'game:spectate': (data: { game_id: string }) => void
/** Leave spectator mode */
'game:leave_spectate': (data: { game_id: string }) => void
}
// ============================================
// Server -> Client Events
// ============================================
export interface ServerToClientEvents {
/** Full game state update (visibility filtered per player) */
'game:state': (state: GameState) => void
/** Result of a player action */
'game:action_result': (result: ActionResult) => void
/** Error notification */
'game:error': (error: GameError) => void
/** Turn timer warning */
'game:turn_timeout_warning': (data: { remaining_seconds: number }) => void
/** Game has ended */
'game:game_over': (data: GameOverData) => void
/** Opponent connection status changed */
'game:opponent_connected': (data: { connected: boolean }) => void
/** Auto-reconnected to game */
'game:reconnected': (data: { game_id: string; state: GameState }) => void
/** Spectator count update */
'game:spectator_count': (data: { count: number }) => void
}
// ============================================
// Game Action Types
// ============================================
export type GameActionType =
| 'play_pokemon'
| 'attach_energy'
| 'play_trainer'
| 'attack'
| 'retreat'
| 'use_ability'
| 'select_prize'
| 'select_new_active'
| 'end_turn'
export interface GameAction {
type: GameActionType
/** Card ID being played (for play actions) */
card_id?: string
/** Target card/zone ID */
target_id?: string
/** Attack index (0 or 1) */
attack_index?: number
/** Additional action-specific data */
data?: Record<string, unknown>
}
// ============================================
// Game State Types
// ============================================
export interface GameState {
game_id: string
turn_number: number
current_phase: TurnPhase
current_player: 'me' | 'opponent'
/** My visible state */
my_state: PlayerState
/** Opponent's visible state (hidden info redacted) */
opponent_state: OpponentState
/** Forced action required (e.g., select prize) */
forced_action?: ForcedAction
/** Turn timer remaining (seconds) */
turn_timer?: number
}
export type TurnPhase = 'draw' | 'main' | 'attack' | 'end'
export interface PlayerState {
/** Active Pokemon (full info) */
active: CardInPlay | null
/** Bench Pokemon (up to 5) */
bench: (CardInPlay | null)[]
/** Hand cards (full info - only visible to owner) */
hand: Card[]
/** Deck count (not contents) */
deck_count: number
/** Discard pile (visible to both players) */
discard: Card[]
/** Prize cards (face down until taken) */
prizes: (Card | null)[]
/** Number of prizes remaining */
prizes_remaining: number
}
export interface OpponentState {
/** Active Pokemon (public info only) */
active: CardInPlay | null
/** Bench Pokemon */
bench: (CardInPlay | null)[]
/** Hand count only (not contents) */
hand_count: number
/** Deck count */
deck_count: number
/** Discard pile */
discard: Card[]
/** Prize count (not contents) */
prizes_remaining: number
}
export interface Card {
id: string
definition_id: string
name: string
card_type: 'pokemon' | 'trainer' | 'energy'
/** Pokemon-specific fields */
hp?: number
pokemon_type?: string
stage?: 'basic' | 'stage1' | 'stage2'
evolves_from?: string
attacks?: Attack[]
weakness?: string
resistance?: string
retreat_cost?: number
/** Energy-specific fields */
energy_type?: string
/** Trainer-specific fields */
trainer_type?: 'item' | 'supporter' | 'stadium'
}
export interface CardInPlay extends Card {
/** Current damage on the card */
damage: number
/** Attached energy cards */
attached_energy: Card[]
/** Status conditions */
status: StatusCondition[]
/** Can evolve this turn */
can_evolve: boolean
}
export interface Attack {
name: string
cost: string[]
damage: number
effect?: string
effect_text?: string
}
export type StatusCondition = 'poisoned' | 'burned' | 'paralyzed' | 'asleep' | 'confused'
export interface ForcedAction {
type: 'select_prize' | 'select_new_active' | 'discard_cards'
/** Number of selections required */
count?: number
/** Valid target IDs */
valid_targets?: string[]
/** Reason for forced action */
reason?: string
}
// ============================================
// Action Result Types
// ============================================
export interface ActionResult {
success: boolean
action_type: GameActionType
/** Error message if not successful */
message?: string
/** State changes that resulted */
changes?: StateChange[]
}
export interface StateChange {
type: string
data: Record<string, unknown>
}
// ============================================
// Error and Game Over Types
// ============================================
export interface GameError {
message: string
code?: string
}
export interface GameOverData {
winner: 'me' | 'opponent' | 'draw'
reason: GameOverReason
/** Game statistics */
stats?: GameStats
}
export type GameOverReason =
| 'prizes_taken'
| 'no_pokemon'
| 'deck_out'
| 'resignation'
| 'timeout'
| 'disconnect'
export interface GameStats {
turns: number
cards_played: number
damage_dealt: number
knockouts: number
}