diff --git a/frontend/project_plans/PHASE_F0_foundation.json b/frontend/project_plans/PHASE_F0_foundation.json index 0461230..7248aa9 100644 --- a/frontend/project_plans/PHASE_F0_foundation.json +++ b/frontend/project_plans/PHASE_F0_foundation.json @@ -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"}, diff --git a/frontend/src/socket/client.spec.ts b/frontend/src/socket/client.spec.ts new file mode 100644 index 0000000..3b6cae0 --- /dev/null +++ b/frontend/src/socket/client.spec.ts @@ -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') + }) + }) +}) diff --git a/frontend/src/socket/client.ts b/frontend/src/socket/client.ts new file mode 100644 index 0000000..7956216 --- /dev/null +++ b/frontend/src/socket/client.ts @@ -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 + +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 { + 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 diff --git a/frontend/src/socket/index.ts b/frontend/src/socket/index.ts new file mode 100644 index 0000000..6669c73 --- /dev/null +++ b/frontend/src/socket/index.ts @@ -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' diff --git a/frontend/src/socket/types.ts b/frontend/src/socket/types.ts new file mode 100644 index 0000000..3f7a636 --- /dev/null +++ b/frontend/src/socket/types.ts @@ -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 +} + +// ============================================ +// 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 +} + +// ============================================ +// 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 +}