strat-gameplay-webapp/frontend-sba/store/game.ts
Cal Corum fa3fadd14c CLAUDE: Implement uncapped hit decision UI + backend bugfixes (Issue #7)
Frontend: Full 5-phase interactive wizard for uncapped hit decisions
(lead advance, defensive throw, trail advance, throw target, safe/out)
with mobile-first design, offense/defense role switching, and auto-
clearing on workflow completion.

Backend fixes:
- Remove nested asyncio.Lock acquisition causing deadlocks in all
  submit_uncapped_* methods and initiate_uncapped_hit (non-re-entrant)
- Preserve pending_manual_roll during interactive workflows
- Add league_id to all dice_system.roll_d20() calls
- Extract D20Roll.roll int for state serialization
- Fix batter-runner not advancing when non-targeted in throw
- Fix rollback_plays not recalculating scores from remaining plays

Files: 10 modified, 1 new (UncappedHitWizard.vue)
Tests: 2481/2481 passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:54:57 -06:00

596 lines
17 KiB
TypeScript

/**
* Game Store
*
* Manages active game state, synchronized with backend via WebSocket.
* This is the central state container for real-time gameplay.
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
GameState,
PlayResult,
DecisionPrompt,
DefensiveDecision,
OffensiveDecision,
RollData,
Lineup,
BenchPlayer,
XCheckData,
DecideAdvanceData,
DecideThrowData,
DecideSpeedCheckData,
UncappedHitData,
} from '~/types'
export const useGameStore = defineStore('game', () => {
// ============================================================================
// State
// ============================================================================
const gameState = ref<GameState | null>(null)
const homeLineup = ref<Lineup[]>([])
const awayLineup = ref<Lineup[]>([])
const homeBench = ref<BenchPlayer[]>([])
const awayBench = ref<BenchPlayer[]>([])
const playHistory = ref<PlayResult[]>([])
const currentDecisionPrompt = ref<DecisionPrompt | null>(null)
const pendingRoll = ref<RollData | null>(null)
const lastPlayResult = ref<PlayResult | null>(null)
const isConnected = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
// X-Check workflow state
const xCheckData = ref<XCheckData | null>(null)
const decideData = ref<DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null>(null)
// Uncapped hit workflow state
const uncappedHitData = ref<UncappedHitData | null>(null)
// Decision state (local pending decisions before submission)
const pendingDefensiveSetup = ref<DefensiveDecision | null>(null)
const pendingOffensiveDecision = ref<Omit<OffensiveDecision, 'steal_attempts'> | null>(null)
const pendingStealAttempts = ref<number[]>([])
const decisionHistory = ref<Array<{
type: 'Defensive' | 'Offensive'
summary: string
timestamp: string
}>>([])
// ============================================================================
// Getters
// ============================================================================
const gameId = computed(() => gameState.value?.game_id ?? null)
const leagueId = computed(() => gameState.value?.league_id ?? null)
const currentInning = computed(() => gameState.value?.inning ?? 1)
const currentHalf = computed(() => gameState.value?.half ?? 'top')
const outs = computed(() => gameState.value?.outs ?? 0)
const balls = computed(() => gameState.value?.balls ?? 0)
const strikes = computed(() => gameState.value?.strikes ?? 0)
const homeScore = computed(() => gameState.value?.home_score ?? 0)
const awayScore = computed(() => gameState.value?.away_score ?? 0)
const gameStatus = computed(() => gameState.value?.status ?? 'pending')
const isGameActive = computed(() => gameStatus.value === 'active')
const isGameComplete = computed(() => gameStatus.value === 'completed')
const currentBatter = computed(() => gameState.value?.current_batter ?? null)
const currentPitcher = computed(() => gameState.value?.current_pitcher ?? null)
const currentCatcher = computed(() => gameState.value?.current_catcher ?? null)
const runnersOnBase = computed(() => {
const runners: number[] = []
if (gameState.value?.on_first) runners.push(1)
if (gameState.value?.on_second) runners.push(2)
if (gameState.value?.on_third) runners.push(3)
return runners
})
const basesLoaded = computed(() => runnersOnBase.value.length === 3)
const basesEmpty = computed(() => runnersOnBase.value.length === 0)
const runnerInScoringPosition = computed(() =>
runnersOnBase.value.includes(2) || runnersOnBase.value.includes(3)
)
const battingTeamId = computed(() => {
if (!gameState.value) return null
return gameState.value.half === 'top'
? gameState.value.away_team_id
: gameState.value.home_team_id
})
const fieldingTeamId = computed(() => {
if (!gameState.value) return null
return gameState.value.half === 'top'
? gameState.value.home_team_id
: gameState.value.away_team_id
})
const isBattingTeamAI = computed(() => {
if (!gameState.value) return false
return gameState.value.half === 'top'
? gameState.value.away_team_is_ai
: gameState.value.home_team_is_ai
})
const isFieldingTeamAI = computed(() => {
if (!gameState.value) return false
return gameState.value.half === 'top'
? gameState.value.home_team_is_ai
: gameState.value.away_team_is_ai
})
const needsDefensiveDecision = computed(() => {
// Standardized naming (2025-01-21): Both backend and frontend now use 'awaiting_defensive'
return currentDecisionPrompt.value?.phase === 'awaiting_defensive' ||
gameState.value?.decision_phase === 'awaiting_defensive'
})
const needsOffensiveDecision = computed(() => {
// Standardized naming (2025-01-21): Both backend and frontend now use 'awaiting_offensive'
return currentDecisionPrompt.value?.phase === 'awaiting_offensive' ||
gameState.value?.decision_phase === 'awaiting_offensive'
})
const needsStolenBaseDecision = computed(() => {
// Standardized naming (2025-01-21): Both backend and frontend now use 'awaiting_stolen_base'
return currentDecisionPrompt.value?.phase === 'awaiting_stolen_base' ||
gameState.value?.decision_phase === 'awaiting_stolen_base'
})
const needsXCheckResult = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_x_check_result' ||
gameState.value?.decision_phase === 'awaiting_x_check_result'
})
const needsDecideAdvance = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_decide_advance' ||
gameState.value?.decision_phase === 'awaiting_decide_advance'
})
const needsDecideThrow = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_decide_throw' ||
gameState.value?.decision_phase === 'awaiting_decide_throw'
})
const needsDecideResult = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_decide_result' ||
gameState.value?.decision_phase === 'awaiting_decide_result'
})
const needsUncappedLeadAdvance = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_lead_advance' ||
gameState.value?.decision_phase === 'awaiting_uncapped_lead_advance'
})
const needsUncappedDefensiveThrow = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_defensive_throw' ||
gameState.value?.decision_phase === 'awaiting_uncapped_defensive_throw'
})
const needsUncappedTrailAdvance = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_trail_advance' ||
gameState.value?.decision_phase === 'awaiting_uncapped_trail_advance'
})
const needsUncappedThrowTarget = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_throw_target' ||
gameState.value?.decision_phase === 'awaiting_uncapped_throw_target'
})
const needsUncappedSafeOut = computed(() => {
return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_safe_out' ||
gameState.value?.decision_phase === 'awaiting_uncapped_safe_out'
})
const needsUncappedDecision = computed(() => {
return needsUncappedLeadAdvance.value ||
needsUncappedDefensiveThrow.value ||
needsUncappedTrailAdvance.value ||
needsUncappedThrowTarget.value ||
needsUncappedSafeOut.value
})
const canRollDice = computed(() => {
return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value
})
const canSubmitOutcome = computed(() => {
return pendingRoll.value !== null
})
const recentPlays = computed(() => {
return playHistory.value.slice(-10).reverse()
})
// ============================================================================
// Actions
// ============================================================================
/**
* Set complete game state (from server)
*/
function setGameState(state: GameState) {
const oldBatter = gameState.value?.current_batter
const newBatter = state?.current_batter
const oldBatterInfo = oldBatter
? `lineup_id=${oldBatter.lineup_id}, batting_order=${oldBatter.batting_order}`
: 'None'
const newBatterInfo = newBatter
? `lineup_id=${newBatter.lineup_id}, batting_order=${newBatter.batting_order}`
: 'None'
console.log('[GameStore] setGameState - current_batter:', oldBatterInfo, '->', newBatterInfo)
gameState.value = state
error.value = null
}
/**
* Update partial game state
*/
function updateGameState(updates: Partial<GameState>) {
if (gameState.value) {
gameState.value = { ...gameState.value, ...updates }
}
}
/**
* Set lineups for both teams
*/
function setLineups(home: Lineup[], away: Lineup[]) {
homeLineup.value = home
awayLineup.value = away
}
/**
* Update lineup for a specific team
*/
function updateLineup(teamId: number, lineup: Lineup[]) {
if (teamId === gameState.value?.home_team_id) {
homeLineup.value = lineup
} else if (teamId === gameState.value?.away_team_id) {
awayLineup.value = lineup
}
}
/**
* Set bench players for a specific team
* Bench players come from RosterLink (players not in active lineup)
* with is_pitcher/is_batter computed properties for UI filtering
*/
function setBench(teamId: number, bench: BenchPlayer[]) {
if (teamId === gameState.value?.home_team_id) {
homeBench.value = bench
} else if (teamId === gameState.value?.away_team_id) {
awayBench.value = bench
}
}
/**
* Set play history (replaces entire array - used for initial sync)
* O(1) operation - no deduplication needed
*/
function setPlayHistory(plays: PlayResult[]) {
playHistory.value = plays
}
/**
* Add play to history (used for live play_resolved events)
* O(1) operation - assumes no duplicates from live events
*/
function addPlayToHistory(play: PlayResult) {
playHistory.value.push(play)
// Update game state from play result if provided
if (play.new_state) {
updateGameState(play.new_state)
}
}
/**
* Set current decision prompt
*/
function setDecisionPrompt(prompt: DecisionPrompt | null) {
currentDecisionPrompt.value = prompt
}
/**
* Clear decision prompt after submission
*/
function clearDecisionPrompt() {
currentDecisionPrompt.value = null
}
/**
* Set pending dice roll
*/
function setPendingRoll(roll: RollData | null) {
pendingRoll.value = roll
}
/**
* Clear pending roll after outcome submission
*/
function clearPendingRoll() {
pendingRoll.value = null
}
/**
* Set last play result
*/
function setLastPlayResult(result: PlayResult | null) {
lastPlayResult.value = result
}
/**
* Clear last play result
*/
function clearLastPlayResult() {
lastPlayResult.value = null
}
/**
* Set connection status
*/
function setConnected(connected: boolean) {
isConnected.value = connected
}
/**
* Set loading state
*/
function setLoading(loading: boolean) {
isLoading.value = loading
}
/**
* Set error
*/
function setError(message: string | null) {
error.value = message
}
/**
* Set pending defensive setup (before submission)
*/
function setPendingDefensiveSetup(setup: DefensiveDecision | null) {
pendingDefensiveSetup.value = setup
}
/**
* Set pending offensive decision (before submission)
*/
function setPendingOffensiveDecision(decision: Omit<OffensiveDecision, 'steal_attempts'> | null) {
pendingOffensiveDecision.value = decision
}
/**
* Set pending steal attempts (before submission)
*/
function setPendingStealAttempts(attempts: number[]) {
pendingStealAttempts.value = attempts
}
/**
* Add decision to history
*/
function addDecisionToHistory(type: 'Defensive' | 'Offensive', summary: string) {
decisionHistory.value.unshift({
type,
summary,
timestamp: new Date().toLocaleTimeString(),
})
// Keep only last 10 decisions
if (decisionHistory.value.length > 10) {
decisionHistory.value = decisionHistory.value.slice(0, 10)
}
}
/**
* Clear all pending decisions
*/
function clearPendingDecisions() {
pendingDefensiveSetup.value = null
pendingOffensiveDecision.value = null
pendingStealAttempts.value = []
}
/**
* Set x-check data (from decision_required event)
*/
function setXCheckData(data: XCheckData | null) {
xCheckData.value = data
}
/**
* Clear x-check data after resolution
*/
function clearXCheckData() {
xCheckData.value = null
}
/**
* Set DECIDE data (from decision_required event)
*/
function setDecideData(data: DecideAdvanceData | DecideThrowData | DecideSpeedCheckData | null) {
decideData.value = data
}
/**
* Clear DECIDE data after resolution
*/
function clearDecideData() {
decideData.value = null
}
/**
* Set uncapped hit data (from decision_required event)
*/
function setUncappedHitData(data: UncappedHitData | null) {
uncappedHitData.value = data
}
/**
* Clear uncapped hit data after resolution
*/
function clearUncappedHitData() {
uncappedHitData.value = null
}
/**
* Reset game store (when leaving game)
*/
function resetGame() {
gameState.value = null
homeLineup.value = []
awayLineup.value = []
homeBench.value = []
awayBench.value = []
playHistory.value = []
currentDecisionPrompt.value = null
pendingRoll.value = null
isConnected.value = false
isLoading.value = false
error.value = null
pendingDefensiveSetup.value = null
pendingOffensiveDecision.value = null
pendingStealAttempts.value = []
decisionHistory.value = []
xCheckData.value = null
decideData.value = null
uncappedHitData.value = null
}
/**
* Get active lineup for a team
*/
function getActiveLineup(teamId: number): Lineup[] {
const lineup = teamId === gameState.value?.home_team_id
? homeLineup.value
: awayLineup.value
return lineup.filter(p => p.is_active)
}
/**
* Get bench players for a team
*/
function getBenchPlayers(teamId: number): Lineup[] {
const lineup = teamId === gameState.value?.home_team_id
? homeLineup.value
: awayLineup.value
return lineup.filter(p => !p.is_active)
}
/**
* Find player in lineup by lineup_id
*/
function findPlayerInLineup(lineupId: number): Lineup | undefined {
return [...homeLineup.value, ...awayLineup.value].find(
p => p.lineup_id === lineupId
)
}
// ============================================================================
// Return Store API
// ============================================================================
return {
// State
gameState: readonly(gameState),
homeLineup: readonly(homeLineup),
awayLineup: readonly(awayLineup),
homeBench: readonly(homeBench),
awayBench: readonly(awayBench),
playHistory: readonly(playHistory),
currentDecisionPrompt: readonly(currentDecisionPrompt),
pendingRoll: readonly(pendingRoll),
lastPlayResult: readonly(lastPlayResult),
isConnected: readonly(isConnected),
isLoading: readonly(isLoading),
error: readonly(error),
pendingDefensiveSetup: readonly(pendingDefensiveSetup),
pendingOffensiveDecision: readonly(pendingOffensiveDecision),
pendingStealAttempts: readonly(pendingStealAttempts),
decisionHistory: readonly(decisionHistory),
xCheckData: readonly(xCheckData),
decideData: readonly(decideData),
uncappedHitData: readonly(uncappedHitData),
// Getters
gameId,
leagueId,
currentInning,
currentHalf,
outs,
balls,
strikes,
homeScore,
awayScore,
gameStatus,
isGameActive,
isGameComplete,
currentBatter,
currentPitcher,
currentCatcher,
runnersOnBase,
basesLoaded,
basesEmpty,
runnerInScoringPosition,
battingTeamId,
fieldingTeamId,
isBattingTeamAI,
isFieldingTeamAI,
needsDefensiveDecision,
needsOffensiveDecision,
needsStolenBaseDecision,
needsXCheckResult,
needsDecideAdvance,
needsDecideThrow,
needsDecideResult,
needsUncappedLeadAdvance,
needsUncappedDefensiveThrow,
needsUncappedTrailAdvance,
needsUncappedThrowTarget,
needsUncappedSafeOut,
needsUncappedDecision,
canRollDice,
canSubmitOutcome,
recentPlays,
// Actions
setGameState,
updateGameState,
setLineups,
updateLineup,
setBench,
setPlayHistory,
addPlayToHistory,
setDecisionPrompt,
clearDecisionPrompt,
setPendingRoll,
clearPendingRoll,
setLastPlayResult,
clearLastPlayResult,
setConnected,
setLoading,
setError,
setPendingDefensiveSetup,
setPendingOffensiveDecision,
setPendingStealAttempts,
addDecisionToHistory,
clearPendingDecisions,
setXCheckData,
clearXCheckData,
setDecideData,
clearDecideData,
setUncappedHitData,
clearUncappedHitData,
resetGame,
getActiveLineup,
getBenchPlayers,
findPlayerInLineup,
}
})