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:
parent
0720084cb1
commit
3a566ffd5a
@ -6,7 +6,7 @@
|
|||||||
"created": "2026-01-30",
|
"created": "2026-01-30",
|
||||||
"lastUpdated": "2026-01-30",
|
"lastUpdated": "2026-01-30",
|
||||||
"totalTasks": 8,
|
"totalTasks": 8,
|
||||||
"completedTasks": 6,
|
"completedTasks": 7,
|
||||||
"status": "in_progress"
|
"status": "in_progress"
|
||||||
},
|
},
|
||||||
"tasks": [
|
"tasks": [
|
||||||
@ -123,8 +123,8 @@
|
|||||||
"description": "WebSocket connection manager",
|
"description": "WebSocket connection manager",
|
||||||
"category": "api",
|
"category": "api",
|
||||||
"priority": 6,
|
"priority": 6,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["F0-001", "F0-004"],
|
"dependencies": ["F0-001", "F0-004"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "src/socket/client.ts", "status": "create"},
|
{"path": "src/socket/client.ts", "status": "create"},
|
||||||
|
|||||||
184
frontend/src/socket/client.spec.ts
Normal file
184
frontend/src/socket/client.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
284
frontend/src/socket/client.ts
Normal file
284
frontend/src/socket/client.ts
Normal 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
|
||||||
29
frontend/src/socket/index.ts
Normal file
29
frontend/src/socket/index.ts
Normal 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'
|
||||||
225
frontend/src/socket/types.ts
Normal file
225
frontend/src/socket/types.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user