519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
/**
|
|
* WebSocket Composable
|
|
*
|
|
* Manages Socket.io connection with type safety, authentication, and auto-reconnection.
|
|
* This composable is league-agnostic and will be shared between SBA and PD frontends.
|
|
*
|
|
* Features:
|
|
* - Type-safe Socket.io client (TypedSocket)
|
|
* - JWT authentication integration
|
|
* - Auto-reconnection with exponential backoff
|
|
* - Connection status tracking
|
|
* - Event listener lifecycle management
|
|
* - Integration with game store
|
|
*/
|
|
|
|
import { ref, computed, watch, onUnmounted, readonly } from 'vue'
|
|
import type { Socket } from 'socket.io-client';
|
|
import { io } from 'socket.io-client'
|
|
import type {
|
|
TypedSocket,
|
|
ClientToServerEvents,
|
|
ServerToClientEvents,
|
|
PlayResult,
|
|
} from '~/types'
|
|
import { useAuthStore } from '~/store/auth'
|
|
import { useGameStore } from '~/store/game'
|
|
import { useUiStore } from '~/store/ui'
|
|
|
|
// Reconnection configuration
|
|
const RECONNECTION_DELAY_BASE = 1000 // Start with 1 second
|
|
const RECONNECTION_DELAY_MAX = 30000 // Max 30 seconds
|
|
const MAX_RECONNECTION_ATTEMPTS = 10
|
|
|
|
// Singleton socket instance
|
|
let socketInstance: Socket<ServerToClientEvents, ClientToServerEvents> | null = null
|
|
let reconnectionAttempts = 0
|
|
let reconnectionTimeout: NodeJS.Timeout | null = null
|
|
|
|
// Singleton reactive state (shared across all useWebSocket calls)
|
|
const isConnected = ref(false)
|
|
const isConnecting = ref(false)
|
|
const connectionError = ref<string | null>(null)
|
|
const lastConnectionAttempt = ref<number | null>(null)
|
|
|
|
export function useWebSocket() {
|
|
// State is now module-level singleton (above)
|
|
|
|
// ============================================================================
|
|
// Stores
|
|
// ============================================================================
|
|
|
|
const authStore = useAuthStore()
|
|
const gameStore = useGameStore()
|
|
const uiStore = useUiStore()
|
|
|
|
// ============================================================================
|
|
// Computed
|
|
// ============================================================================
|
|
|
|
const socket = computed((): TypedSocket | null => {
|
|
return socketInstance as TypedSocket | null
|
|
})
|
|
|
|
const canConnect = computed(() => {
|
|
return authStore.isAuthenticated && authStore.isTokenValid
|
|
})
|
|
|
|
const shouldReconnect = computed(() => {
|
|
return (
|
|
!isConnected.value &&
|
|
canConnect.value &&
|
|
reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS
|
|
)
|
|
})
|
|
|
|
// ============================================================================
|
|
// Connection Management
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Connect to WebSocket server with JWT authentication
|
|
*/
|
|
function connect() {
|
|
if (!canConnect.value) {
|
|
console.warn('[WebSocket] Cannot connect: not authenticated or token invalid')
|
|
return
|
|
}
|
|
|
|
if (isConnected.value || isConnecting.value) {
|
|
console.warn('[WebSocket] Already connected or connecting')
|
|
return
|
|
}
|
|
|
|
isConnecting.value = true
|
|
connectionError.value = null
|
|
lastConnectionAttempt.value = Date.now()
|
|
|
|
const config = useRuntimeConfig()
|
|
const wsUrl = config.public.wsUrl
|
|
|
|
console.log('[WebSocket] Connecting to', wsUrl)
|
|
|
|
// Create or reuse socket instance
|
|
if (!socketInstance) {
|
|
socketInstance = io(wsUrl, {
|
|
auth: {
|
|
token: authStore.token,
|
|
},
|
|
autoConnect: false,
|
|
reconnection: false, // We handle reconnection manually for better control
|
|
transports: ['websocket', 'polling'],
|
|
})
|
|
|
|
setupEventListeners()
|
|
} else {
|
|
// Update auth token if reconnecting
|
|
socketInstance.auth = {
|
|
token: authStore.token,
|
|
}
|
|
}
|
|
|
|
// Connect
|
|
socketInstance.connect()
|
|
}
|
|
|
|
/**
|
|
* Disconnect from WebSocket server
|
|
*/
|
|
function disconnect() {
|
|
console.log('[WebSocket] Disconnecting')
|
|
|
|
// Clear reconnection timer
|
|
if (reconnectionTimeout) {
|
|
clearTimeout(reconnectionTimeout)
|
|
reconnectionTimeout = null
|
|
}
|
|
|
|
// Disconnect socket
|
|
if (socketInstance) {
|
|
socketInstance.disconnect()
|
|
}
|
|
|
|
isConnected.value = false
|
|
isConnecting.value = false
|
|
connectionError.value = null
|
|
reconnectionAttempts = 0
|
|
}
|
|
|
|
/**
|
|
* Reconnect with exponential backoff
|
|
*/
|
|
function scheduleReconnection() {
|
|
if (!shouldReconnect.value) {
|
|
console.log('[WebSocket] Reconnection not needed or max attempts reached')
|
|
return
|
|
}
|
|
|
|
// Calculate delay with exponential backoff
|
|
const delay = Math.min(
|
|
RECONNECTION_DELAY_BASE * Math.pow(2, reconnectionAttempts),
|
|
RECONNECTION_DELAY_MAX
|
|
)
|
|
|
|
console.log(
|
|
`[WebSocket] Scheduling reconnection attempt ${reconnectionAttempts + 1}/${MAX_RECONNECTION_ATTEMPTS} in ${delay}ms`
|
|
)
|
|
|
|
reconnectionTimeout = setTimeout(() => {
|
|
reconnectionAttempts++
|
|
connect()
|
|
}, delay)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Event Listeners Setup
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Set up all WebSocket event listeners
|
|
*/
|
|
function setupEventListeners() {
|
|
if (!socketInstance) return
|
|
|
|
// ========================================
|
|
// Connection Events
|
|
// ========================================
|
|
|
|
socketInstance.on('connect', () => {
|
|
console.log('[WebSocket] Connected successfully')
|
|
isConnected.value = true
|
|
isConnecting.value = false
|
|
connectionError.value = null
|
|
reconnectionAttempts = 0
|
|
|
|
// Update game store
|
|
gameStore.setConnected(true)
|
|
|
|
// Show success toast
|
|
uiStore.showSuccess('Connected to game server')
|
|
})
|
|
|
|
socketInstance.on('disconnect', (reason) => {
|
|
console.log('[WebSocket] Disconnected:', reason)
|
|
isConnected.value = false
|
|
isConnecting.value = false
|
|
|
|
// Update game store
|
|
gameStore.setConnected(false)
|
|
|
|
// Show warning toast
|
|
uiStore.showWarning('Disconnected from game server')
|
|
|
|
// Attempt reconnection if not intentional
|
|
if (reason !== 'io client disconnect') {
|
|
scheduleReconnection()
|
|
}
|
|
})
|
|
|
|
socketInstance.on('connect_error', (error) => {
|
|
console.error('[WebSocket] Connection error:', error)
|
|
isConnected.value = false
|
|
isConnecting.value = false
|
|
connectionError.value = error.message
|
|
|
|
// Update game store
|
|
gameStore.setError(error.message)
|
|
|
|
// Show error toast
|
|
uiStore.showError(`Connection failed: ${error.message}`)
|
|
|
|
// Attempt reconnection
|
|
scheduleReconnection()
|
|
})
|
|
|
|
socketInstance.on('connected', (data) => {
|
|
console.log('[WebSocket] Server confirmed connection for user:', data.user_id)
|
|
})
|
|
|
|
socketInstance.on('game_joined', (data: { game_id: string; role: string }) => {
|
|
console.log('[WebSocket] Successfully joined game:', data.game_id, 'as', data.role)
|
|
uiStore.showSuccess(`Joined game as ${data.role}`)
|
|
})
|
|
|
|
socketInstance.on('heartbeat_ack', () => {
|
|
// Heartbeat acknowledged - connection is healthy
|
|
// No action needed, just prevents timeout
|
|
})
|
|
|
|
// ========================================
|
|
// Game State Events
|
|
// ========================================
|
|
|
|
socketInstance.on('game_state', (state) => {
|
|
console.log('[WebSocket] Full game state received (from request_game_state)')
|
|
gameStore.setGameState(state)
|
|
})
|
|
|
|
socketInstance.on('game_state_update', (state) => {
|
|
console.log('[WebSocket] Game state update received')
|
|
gameStore.setGameState(state)
|
|
})
|
|
|
|
socketInstance.on('game_state_sync', (data) => {
|
|
console.log('[WebSocket] Full game state sync received')
|
|
gameStore.setGameState(data.state)
|
|
// Add recent plays to history
|
|
data.recent_plays.forEach((play) => {
|
|
gameStore.addPlayToHistory(play)
|
|
})
|
|
})
|
|
|
|
socketInstance.on('inning_change', (data) => {
|
|
console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`)
|
|
uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000)
|
|
})
|
|
|
|
socketInstance.on('game_ended', (data) => {
|
|
console.log('[WebSocket] Game ended:', data.winner_team_id)
|
|
uiStore.showSuccess(
|
|
`Game Over! Final: ${data.final_score.away} - ${data.final_score.home}`,
|
|
10000
|
|
)
|
|
})
|
|
|
|
// ========================================
|
|
// Decision Events
|
|
// ========================================
|
|
|
|
socketInstance.on('decision_required', (prompt) => {
|
|
console.log('[WebSocket] Decision required:', prompt.phase)
|
|
gameStore.setDecisionPrompt(prompt)
|
|
})
|
|
|
|
socketInstance.on('defensive_decision_submitted', (data) => {
|
|
console.log('[WebSocket] Defensive decision submitted')
|
|
gameStore.clearDecisionPrompt()
|
|
|
|
// Request updated game state to get new decision_phase
|
|
if (socketInstance && gameStore.gameId) {
|
|
socketInstance.emit('request_game_state', {
|
|
game_id: gameStore.gameId
|
|
})
|
|
}
|
|
|
|
if (data.pending_decision) {
|
|
uiStore.showInfo('Defense set. Waiting for offense...', 3000)
|
|
} else {
|
|
uiStore.showSuccess('Defense set. Ready to play!', 3000)
|
|
}
|
|
})
|
|
|
|
socketInstance.on('offensive_decision_submitted', (data) => {
|
|
console.log('[WebSocket] Offensive decision submitted')
|
|
gameStore.clearDecisionPrompt()
|
|
|
|
// Request updated game state to get new decision_phase
|
|
if (socketInstance && gameStore.gameId) {
|
|
socketInstance.emit('request_game_state', {
|
|
game_id: gameStore.gameId
|
|
})
|
|
}
|
|
|
|
uiStore.showSuccess('Offense set. Ready to play!', 3000)
|
|
})
|
|
|
|
// ========================================
|
|
// Manual Workflow Events
|
|
// ========================================
|
|
|
|
socketInstance.on('dice_rolled', (data) => {
|
|
console.log('[WebSocket] Dice rolled:', data.roll_id)
|
|
gameStore.setPendingRoll({
|
|
roll_id: data.roll_id,
|
|
d6_one: data.d6_one,
|
|
d6_two_a: data.d6_two_a,
|
|
d6_two_b: data.d6_two_b,
|
|
d6_two_total: data.d6_two_total,
|
|
chaos_d20: data.chaos_d20,
|
|
resolution_d20: data.resolution_d20,
|
|
check_wild_pitch: data.check_wild_pitch,
|
|
check_passed_ball: data.check_passed_ball,
|
|
timestamp: data.timestamp,
|
|
})
|
|
uiStore.showInfo(data.message, 5000)
|
|
})
|
|
|
|
socketInstance.on('outcome_accepted', (data) => {
|
|
console.log('[WebSocket] Outcome accepted:', data.outcome)
|
|
gameStore.clearPendingRoll()
|
|
uiStore.showSuccess('Outcome submitted. Resolving play...', 3000)
|
|
})
|
|
|
|
socketInstance.on('play_resolved', (data) => {
|
|
console.log('[WebSocket] Play resolved:', data.description)
|
|
|
|
// Convert to PlayResult and add to game store
|
|
const playResult: PlayResult = {
|
|
play_number: data.play_number,
|
|
outcome: data.outcome,
|
|
hit_location: data.hit_location,
|
|
description: data.description,
|
|
outs_recorded: data.outs_recorded,
|
|
runs_scored: data.runs_scored,
|
|
batter_result: data.batter_result,
|
|
runners_advanced: data.runners_advanced,
|
|
is_hit: data.is_hit,
|
|
is_out: data.is_out,
|
|
is_walk: data.is_walk,
|
|
roll_id: data.roll_id,
|
|
x_check_details: data.x_check_details,
|
|
}
|
|
|
|
gameStore.setLastPlayResult(playResult)
|
|
gameStore.addPlayToHistory(playResult)
|
|
uiStore.showSuccess(data.description, 5000)
|
|
})
|
|
|
|
// ========================================
|
|
// Substitution Events
|
|
// ========================================
|
|
|
|
socketInstance.on('player_substituted', (data) => {
|
|
console.log('[WebSocket] Player substituted:', data.type)
|
|
uiStore.showInfo(data.message, 5000)
|
|
// Request updated lineup
|
|
if (socketInstance) {
|
|
socketInstance.emit('get_lineup', {
|
|
game_id: gameStore.gameId!,
|
|
team_id: data.team_id,
|
|
})
|
|
}
|
|
})
|
|
|
|
socketInstance.on('substitution_confirmed', (data) => {
|
|
console.log('[WebSocket] Substitution confirmed')
|
|
uiStore.showSuccess(data.message, 5000)
|
|
})
|
|
|
|
// ========================================
|
|
// Data Response Events
|
|
// ========================================
|
|
|
|
socketInstance.on('lineup_data', (data) => {
|
|
console.log('[WebSocket] Lineup data received for team:', data.team_id)
|
|
gameStore.updateLineup(data.team_id, data.players)
|
|
})
|
|
|
|
socketInstance.on('box_score_data', (data) => {
|
|
console.log('[WebSocket] Box score data received')
|
|
// Box score will be handled by dedicated component
|
|
// Just log for now
|
|
})
|
|
|
|
// ========================================
|
|
// Error Events
|
|
// ========================================
|
|
|
|
socketInstance.on('error', (data) => {
|
|
console.error('[WebSocket] Server error:', data.message)
|
|
gameStore.setError(data.message)
|
|
uiStore.showError(data.message, 7000)
|
|
})
|
|
|
|
socketInstance.on('outcome_rejected', (data) => {
|
|
console.error('[WebSocket] Outcome rejected:', data.message)
|
|
uiStore.showError(`Invalid outcome: ${data.message}`, 7000)
|
|
})
|
|
|
|
socketInstance.on('substitution_error', (data) => {
|
|
console.error('[WebSocket] Substitution error:', data.message)
|
|
uiStore.showError(`Substitution failed: ${data.message}`, 7000)
|
|
})
|
|
|
|
socketInstance.on('invalid_action', (data) => {
|
|
console.error('[WebSocket] Invalid action:', data.reason)
|
|
uiStore.showError(`Invalid action: ${data.reason}`, 7000)
|
|
})
|
|
|
|
socketInstance.on('connection_error', (data) => {
|
|
console.error('[WebSocket] Connection error:', data.error)
|
|
connectionError.value = data.error
|
|
uiStore.showError(`Connection error: ${data.error}`, 7000)
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Heartbeat
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Send periodic heartbeat to keep connection alive
|
|
*/
|
|
let heartbeatInterval: NodeJS.Timeout | null = null
|
|
|
|
function startHeartbeat() {
|
|
if (heartbeatInterval) return
|
|
|
|
heartbeatInterval = setInterval(() => {
|
|
if (socketInstance?.connected) {
|
|
socketInstance.emit('heartbeat')
|
|
}
|
|
}, 30000) // Every 30 seconds
|
|
}
|
|
|
|
function stopHeartbeat() {
|
|
if (heartbeatInterval) {
|
|
clearInterval(heartbeatInterval)
|
|
heartbeatInterval = null
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Watchers
|
|
// ============================================================================
|
|
|
|
// Auto-connect when authenticated
|
|
watch(
|
|
() => authStore.isAuthenticated,
|
|
(authenticated) => {
|
|
if (authenticated && !isConnected.value) {
|
|
connect()
|
|
startHeartbeat()
|
|
} else if (!authenticated && isConnected.value) {
|
|
disconnect()
|
|
stopHeartbeat()
|
|
}
|
|
},
|
|
{ immediate: false }
|
|
)
|
|
|
|
// ============================================================================
|
|
// Lifecycle
|
|
// ============================================================================
|
|
|
|
onUnmounted(() => {
|
|
console.log('[WebSocket] Component unmounted, cleaning up')
|
|
stopHeartbeat()
|
|
// Don't disconnect on unmount - keep connection alive for app
|
|
// Only disconnect when explicitly requested or user logs out
|
|
})
|
|
|
|
// ============================================================================
|
|
// Public API
|
|
// ============================================================================
|
|
|
|
return {
|
|
// State
|
|
socket,
|
|
isConnected: readonly(isConnected),
|
|
isConnecting: readonly(isConnecting),
|
|
connectionError: readonly(connectionError),
|
|
canConnect,
|
|
|
|
// Actions
|
|
connect,
|
|
disconnect,
|
|
}
|
|
}
|