- Added rollback_play WebSocket handler (handlers.py:1632) - Accepts game_id and num_plays (default: 1) - Validates game state and play count - Broadcasts play_rolled_back and game_state_update events - Full error handling with rate limiting - Added undoLastPlay action to useGameActions composable - Emits rollback_play event to backend - Added Undo button to game page ([id].vue) - Amber floating action button with undo arrow icon - Positioned above substitutions button - Only visible when game is active and has plays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
347 lines
8.8 KiB
TypeScript
347 lines
8.8 KiB
TypeScript
/**
|
|
* Game Actions Composable
|
|
*
|
|
* Provides type-safe methods for emitting game actions to the server.
|
|
* Wraps Socket.io emit calls with proper error handling and validation.
|
|
*
|
|
* This composable is league-agnostic and will be shared between SBA and PD.
|
|
*/
|
|
|
|
import { computed } from 'vue'
|
|
import type {
|
|
DefensiveDecision,
|
|
OffensiveDecision,
|
|
ManualOutcomeSubmission,
|
|
PlayOutcome,
|
|
} from '~/types'
|
|
import { useWebSocket } from './useWebSocket'
|
|
import { useGameStore } from '~/store/game'
|
|
import { useUiStore } from '~/store/ui'
|
|
|
|
export function useGameActions(gameId?: string) {
|
|
const { socket, isConnected } = useWebSocket()
|
|
const gameStore = useGameStore()
|
|
const uiStore = useUiStore()
|
|
|
|
// Use provided gameId or get from store
|
|
const currentGameId = computed(() => gameId || gameStore.gameId)
|
|
|
|
/**
|
|
* Validate socket connection before emitting
|
|
*/
|
|
function validateConnection(): boolean {
|
|
console.log('[GameActions] validateConnection check:', {
|
|
isConnected: isConnected.value,
|
|
hasSocket: !!socket.value,
|
|
currentGameId: currentGameId.value,
|
|
passedGameId: gameId,
|
|
storeGameId: gameStore.gameId
|
|
})
|
|
|
|
if (!isConnected.value) {
|
|
console.error('[GameActions] Validation failed: Not connected')
|
|
uiStore.showError('Not connected to game server')
|
|
return false
|
|
}
|
|
|
|
if (!socket.value) {
|
|
console.error('[GameActions] Validation failed: No socket')
|
|
uiStore.showError('WebSocket not initialized')
|
|
return false
|
|
}
|
|
|
|
if (!currentGameId.value) {
|
|
console.error('[GameActions] Validation failed: No game ID')
|
|
uiStore.showError('No active game')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// Connection Actions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Join a game room
|
|
*/
|
|
function joinGame(role: 'player' | 'spectator' = 'player') {
|
|
if (!validateConnection()) return
|
|
|
|
console.log(`[GameActions] Joining game ${currentGameId.value} as ${role}`)
|
|
|
|
socket.value!.emit('join_game', {
|
|
game_id: currentGameId.value!,
|
|
role,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Leave current game room
|
|
*/
|
|
function leaveGame() {
|
|
if (!socket.value || !currentGameId.value) return
|
|
|
|
console.log(`[GameActions] Leaving game ${currentGameId.value}`)
|
|
|
|
socket.value.emit('leave_game', {
|
|
game_id: currentGameId.value,
|
|
})
|
|
|
|
// Clear game state
|
|
gameStore.resetGame()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Strategic Decision Actions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Submit defensive decision
|
|
*/
|
|
function submitDefensiveDecision(decision: DefensiveDecision) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Submitting defensive decision:', decision)
|
|
|
|
socket.value!.emit('submit_defensive_decision', {
|
|
game_id: currentGameId.value!,
|
|
infield_depth: decision.infield_depth,
|
|
outfield_depth: decision.outfield_depth,
|
|
hold_runners: decision.hold_runners,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Submit offensive decision
|
|
*/
|
|
function submitOffensiveDecision(decision: OffensiveDecision) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Submitting offensive decision:', decision)
|
|
|
|
socket.value!.emit('submit_offensive_decision', {
|
|
game_id: currentGameId.value!,
|
|
action: decision.action,
|
|
steal_attempts: decision.steal_attempts,
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Manual Outcome Workflow
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Roll dice for manual outcome selection
|
|
*/
|
|
function rollDice() {
|
|
if (!validateConnection()) return
|
|
|
|
if (!gameStore.canRollDice) {
|
|
uiStore.showWarning('Cannot roll dice at this time')
|
|
return
|
|
}
|
|
|
|
console.log('[GameActions] Rolling dice')
|
|
|
|
socket.value!.emit('roll_dice', {
|
|
game_id: currentGameId.value!,
|
|
})
|
|
|
|
uiStore.showInfo('Rolling dice...', 2000)
|
|
}
|
|
|
|
/**
|
|
* Submit manual outcome after reading card
|
|
*/
|
|
function submitManualOutcome(outcome: PlayOutcome, hitLocation?: string) {
|
|
if (!validateConnection()) return
|
|
|
|
if (!gameStore.canSubmitOutcome) {
|
|
uiStore.showWarning('Must roll dice first')
|
|
return
|
|
}
|
|
|
|
console.log('[GameActions] Submitting outcome:', outcome, hitLocation)
|
|
|
|
socket.value!.emit('submit_manual_outcome', {
|
|
game_id: currentGameId.value!,
|
|
outcome: outcome,
|
|
hit_location: hitLocation,
|
|
})
|
|
|
|
uiStore.showInfo('Submitting outcome...', 2000)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Substitution Actions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Request pinch hitter substitution
|
|
*/
|
|
function requestPinchHitter(
|
|
playerOutLineupId: number,
|
|
playerInCardId: number,
|
|
teamId: number
|
|
) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Requesting pinch hitter')
|
|
|
|
socket.value!.emit('request_pinch_hitter', {
|
|
game_id: currentGameId.value!,
|
|
player_out_lineup_id: playerOutLineupId,
|
|
player_in_card_id: playerInCardId,
|
|
team_id: teamId,
|
|
})
|
|
|
|
uiStore.showInfo('Requesting pinch hitter...', 3000)
|
|
}
|
|
|
|
/**
|
|
* Request defensive replacement
|
|
*/
|
|
function requestDefensiveReplacement(
|
|
playerOutLineupId: number,
|
|
playerInCardId: number,
|
|
newPosition: string,
|
|
teamId: number
|
|
) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Requesting defensive replacement')
|
|
|
|
socket.value!.emit('request_defensive_replacement', {
|
|
game_id: currentGameId.value!,
|
|
player_out_lineup_id: playerOutLineupId,
|
|
player_in_card_id: playerInCardId,
|
|
new_position: newPosition,
|
|
team_id: teamId,
|
|
})
|
|
|
|
uiStore.showInfo('Requesting defensive replacement...', 3000)
|
|
}
|
|
|
|
/**
|
|
* Request pitching change
|
|
*/
|
|
function requestPitchingChange(
|
|
playerOutLineupId: number,
|
|
playerInCardId: number,
|
|
teamId: number
|
|
) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Requesting pitching change')
|
|
|
|
socket.value!.emit('request_pitching_change', {
|
|
game_id: currentGameId.value!,
|
|
player_out_lineup_id: playerOutLineupId,
|
|
player_in_card_id: playerInCardId,
|
|
team_id: teamId,
|
|
})
|
|
|
|
uiStore.showInfo('Requesting pitching change...', 3000)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Undo/Rollback Actions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Undo the last N plays
|
|
* Rolls back plays from the database and reconstructs game state
|
|
*/
|
|
function undoLastPlay(numPlays: number = 1) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Undoing last', numPlays, 'play(s)')
|
|
|
|
socket.value!.emit('rollback_play', {
|
|
game_id: currentGameId.value!,
|
|
num_plays: numPlays,
|
|
})
|
|
|
|
uiStore.showInfo(`Undoing ${numPlays} play(s)...`, 3000)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Data Request Actions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get lineup for a team
|
|
*/
|
|
function getLineup(teamId: number) {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Requesting lineup for team:', teamId)
|
|
|
|
socket.value!.emit('get_lineup', {
|
|
game_id: currentGameId.value!,
|
|
team_id: teamId,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get box score
|
|
*/
|
|
function getBoxScore() {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Requesting box score')
|
|
|
|
socket.value!.emit('get_box_score', {
|
|
game_id: currentGameId.value!,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Request full game state (for reconnection)
|
|
*/
|
|
function requestGameState() {
|
|
if (!validateConnection()) return
|
|
|
|
console.log('[GameActions] Requesting full game state')
|
|
|
|
socket.value!.emit('request_game_state', {
|
|
game_id: currentGameId.value!,
|
|
})
|
|
|
|
uiStore.showInfo('Syncing game state...', 3000)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Return API
|
|
// ============================================================================
|
|
|
|
return {
|
|
// Connection
|
|
joinGame,
|
|
leaveGame,
|
|
|
|
// Strategic decisions
|
|
submitDefensiveDecision,
|
|
submitOffensiveDecision,
|
|
|
|
// Manual workflow
|
|
rollDice,
|
|
submitManualOutcome,
|
|
|
|
// Substitutions
|
|
requestPinchHitter,
|
|
requestDefensiveReplacement,
|
|
requestPitchingChange,
|
|
|
|
// Undo/Rollback
|
|
undoLastPlay,
|
|
|
|
// Data requests
|
|
getLineup,
|
|
getBoxScore,
|
|
requestGameState,
|
|
}
|
|
}
|