strat-gameplay-webapp/frontend-sba/composables/useWebSocket.ts
Cal Corum 58b5deb88e CLAUDE: Connect gameplay loop - dice rolling and play resolution
Frontend changes to complete gameplay loop connection:
- Fixed useGameActions.ts submitManualOutcome() signature to match backend API
- Added play_resolved WebSocket event handler to useWebSocket.ts
- Fixed game page handleSubmitOutcome() to call submitManualOutcome() correctly
- Added missing imports (readonly, PlayResult) to useWebSocket.ts

Backend handlers already implemented (Phase 3E-Final):
- roll_dice: Rolls dice and broadcasts results to game room
- submit_manual_outcome: Validates outcome, resolves play, broadcasts result
- play_resolved: Emitted after successful play resolution

Workflow now complete:
1. User clicks "Roll Dice" → frontend emits roll_dice event
2. Backend rolls dice → broadcasts dice_rolled event
3. Frontend displays dice results → user reads card
4. User selects outcome → frontend emits submit_manual_outcome
5. Backend validates & resolves → broadcasts play_resolved event
6. Frontend displays play result → updates game state

Ready for end-to-end testing of complete at-bat workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:55:19 -06:00

503 lines
15 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 { io, Socket } 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('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('play_completed', (play) => {
console.log('[WebSocket] Play completed:', play.description)
gameStore.addPlayToHistory(play)
uiStore.showInfo(play.description, 3000)
})
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()
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()
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: 0, // Not provided by server
d6_two_b: 0, // Not provided by server
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,
}
}