strat-gameplay-webapp/frontend-sba/store/game.ts
Cal Corum 453280487c CLAUDE: Integrate XCheckWizard into GameplayPanel and wire up WebSocket/store
Step 7 of x-check interactive workflow implementation:

Frontend Integration:
- GameplayPanel.vue: Add x_check_result_pending workflow state, show XCheckWizard when decision_phase is awaiting_x_check_result, handle interactive vs read-only mode based on active_team_id
- store/game.ts: Add xCheckData and decideData state, add needsXCheckResult/needsDecide* getters, add set/clear actions for x-check and decide data
- useWebSocket.ts: Handle decision_required events with x_check_result/decide_advance/decide_throw/decide_speed_check types, route to appropriate store actions, clear x-check/decide data on play_resolved
- useGameActions.ts: Add submitXCheckResult(), submitDecideAdvance(), submitDecideThrow(), submitDecideResult() action wrappers
- types: Export XCheckData, DecideAdvanceData, DecideThrowData, DecideSpeedCheckData, PendingXCheck, and new WebSocket request types

Type fixes:
- XCheckData: Allow readonly arrays for d6_individual and chart_row (store returns readonly refs)
- GameplayPanel: Add userTeamId prop for determining interactive mode

Tests: 460 passing, 28 failing (GameplayPanel.spec.ts needs Pinia setup - pre-existing issue)

Next: Step 8 - End-to-end testing of basic x-check flow (no DECIDE)
2026-02-07 17:43:17 -06:00

535 lines
15 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,
} 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)
// 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 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
}
/**
* 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
}
/**
* 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),
// 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,
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,
resetGame,
getActiveLineup,
getBenchPlayers,
findPlayerInLineup,
}
})