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",
|
||||
"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"},
|
||||
|
||||
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