CLAUDE: Standardize decision phase naming and fix frontend type mismatches

Frontend alignment with backend WebSocket protocol:

**Type System Fixes**:
- types/game.ts: Changed DecisionPhase to use 'awaiting_*' convention matching backend
- types/game.ts: Fixed PlayOutcome enum values to match backend string values (e.g., 'strikeout' not 'STRIKEOUT')
- types/game.ts: Added comprehensive play outcome types (groundball_a/b/c, flyout variants, x_check)

**Decision Detection**:
- store/game.ts: Updated decision detection to check both decision prompt AND gameState.decision_phase
- components: Updated all decision phase checks to use 'awaiting_defensive', 'awaiting_offensive', 'awaiting_stolen_base'

**WebSocket Enhancements**:
- useWebSocket.ts: Added game_joined event handler with success toast
- useWebSocket.ts: Fixed dice roll data - now receives d6_two_a and d6_two_b from server
- useWebSocket.ts: Request fresh game state after decision submissions to sync decision_phase

**New Constants**:
- constants/outcomes.ts: Created centralized PlayOutcome enum with display labels and descriptions

**Testing**:
- Updated test expectations for new decision phase naming
- All component tests passing

**Why**:
Eliminates confusion from dual naming conventions. Frontend now uses same vocabulary as backend.
Fixes runtime type errors from enum value mismatches.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-21 15:40:52 -06:00
parent 9627a79dce
commit 1373286391
12 changed files with 235 additions and 96 deletions

View File

@ -90,8 +90,8 @@ Game state contains minimal `LineupPlayerState` (lineup_id, position). Use `game
## References ## References
- **WebSocket Protocol Spec**: `../.claude/WEBSOCKET_PROTOCOL_SPEC.md` - Complete event catalog and workflow
- **Implementation Guide**: `../.claude/implementation/01-infrastructure.md` - **Implementation Guide**: `../.claude/implementation/01-infrastructure.md`
- **WebSocket Protocol**: `../.claude/implementation/websocket-protocol.md`
- **Full PRD**: `../prd-web-scorecard-1.1.md` - **Full PRD**: `../prd-web-scorecard-1.1.md`
--- ---

View File

@ -98,7 +98,7 @@
type="submit" type="submit"
variant="success" variant="success"
size="lg" size="lg"
:disabled="!isActive || !hasChanges" :disabled="!isActive"
:loading="submitting" :loading="submitting"
full-width full-width
> >
@ -213,7 +213,7 @@ const holdingDisplay = computed(() => {
}).join(', ') }).join(', ')
}) })
// Check if setup has changed from initial // Check if setup has changed from initial (for display only)
const hasChanges = computed(() => { const hasChanges = computed(() => {
if (!props.currentSetup) return true if (!props.currentSetup) return true
return ( return (
@ -225,13 +225,13 @@ const hasChanges = computed(() => {
const submitButtonText = computed(() => { const submitButtonText = computed(() => {
if (!props.isActive) return 'Wait for Your Turn' if (!props.isActive) return 'Wait for Your Turn'
if (!hasChanges.value) return 'No Changes' if (!hasChanges.value) return 'Submit (Keep Setup)'
return 'Submit Defensive Setup' return 'Submit Defensive Setup'
}) })
// Handle form submission // Handle form submission
const handleSubmit = async () => { const handleSubmit = async () => {
if (!props.isActive || !hasChanges.value) return if (!props.isActive) return
submitting.value = true submitting.value = true
try { try {

View File

@ -68,7 +68,7 @@
type="submit" type="submit"
variant="success" variant="success"
size="lg" size="lg"
:disabled="!isActive || !hasChanges" :disabled="!isActive"
:loading="submitting" :loading="submitting"
full-width full-width
> >
@ -209,7 +209,7 @@ const hasChanges = computed(() => {
const submitButtonText = computed(() => { const submitButtonText = computed(() => {
if (!props.isActive) return 'Wait for Your Turn' if (!props.isActive) return 'Wait for Your Turn'
if (!hasChanges.value) return 'No Changes' if (!hasChanges.value) return 'Submit (Keep Action)'
return 'Submit Offensive Strategy' return 'Submit Offensive Strategy'
}) })
@ -240,7 +240,7 @@ const getActionButtonClasses = (action: OffensiveDecision['action'], disabled: b
} }
const handleSubmit = async () => { const handleSubmit = async () => {
if (!props.isActive || !hasChanges.value) return if (!props.isActive) return
submitting.value = true submitting.value = true
try { try {

View File

@ -61,6 +61,8 @@
<ManualOutcomeEntry <ManualOutcomeEntry
:roll-data="pendingRoll" :roll-data="pendingRoll"
:can-submit="canSubmitOutcome" :can-submit="canSubmitOutcome"
:outs="outs"
:has-runners="hasRunners"
@submit="handleSubmitOutcome" @submit="handleSubmitOutcome"
@cancel="handleCancelOutcome" @cancel="handleCancelOutcome"
/> />
@ -114,9 +116,14 @@ interface Props {
pendingRoll: RollData | null pendingRoll: RollData | null
lastPlayResult: PlayResult | null lastPlayResult: PlayResult | null
canSubmitOutcome: boolean canSubmitOutcome: boolean
outs?: number
hasRunners?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
})
const emit = defineEmits<{ const emit = defineEmits<{
rollDice: [] rollDice: []

View File

@ -107,9 +107,14 @@ import type { PlayOutcome, RollData } from '~/types'
interface Props { interface Props {
rollData: RollData | null rollData: RollData | null
canSubmit: boolean canSubmit: boolean
outs?: number
hasRunners?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
})
const emit = defineEmits<{ const emit = defineEmits<{
submit: [{ outcome: PlayOutcome; hitLocation?: string }] submit: [{ outcome: PlayOutcome; hitLocation?: string }]
@ -120,48 +125,25 @@ const emit = defineEmits<{
const selectedOutcome = ref<PlayOutcome | null>(null) const selectedOutcome = ref<PlayOutcome | null>(null)
const selectedHitLocation = ref<string | null>(null) const selectedHitLocation = ref<string | null>(null)
// Outcome categories // Import centralized outcome constants
const outcomeCategories = [ import { OUTCOME_CATEGORIES, OUTCOMES_REQUIRING_HIT_LOCATION, HIT_LOCATIONS } from '~/constants/outcomes'
{
name: 'Outs',
outcomes: ['STRIKEOUT', 'GROUNDOUT', 'FLYOUT', 'LINEOUT', 'POPOUT', 'DOUBLE_PLAY'] as PlayOutcome[],
},
{
name: 'Hits',
outcomes: ['SINGLE_1', 'SINGLE_2', 'SINGLE_UNCAPPED', 'DOUBLE_2', 'DOUBLE_3', 'DOUBLE_UNCAPPED', 'TRIPLE', 'HOMERUN'] as PlayOutcome[],
},
{
name: 'Walks / HBP',
outcomes: ['WALK', 'INTENTIONAL_WALK', 'HIT_BY_PITCH'] as PlayOutcome[],
},
{
name: 'Special',
outcomes: ['ERROR'] as PlayOutcome[],
},
{
name: 'Interrupts',
outcomes: ['STOLEN_BASE', 'CAUGHT_STEALING', 'WILD_PITCH', 'PASSED_BALL', 'BALK', 'PICK_OFF'] as PlayOutcome[],
},
]
// Hit location options // Use imported constants
const infieldPositions = ['P', 'C', '1B', '2B', '3B', 'SS'] const outcomeCategories = OUTCOME_CATEGORIES
const outfieldPositions = ['LF', 'CF', 'RF'] const infieldPositions = HIT_LOCATIONS.infield
const outfieldPositions = HIT_LOCATIONS.outfield
// Outcomes that require hit location const outcomesNeedingHitLocation = OUTCOMES_REQUIRING_HIT_LOCATION
// Only outcomes that affect defensive plays require location
const outcomesNeedingHitLocation = [
'GROUNDOUT', // All groundouts
'FLYOUT', // All flyouts
'LINEOUT', // All lineouts
'SINGLE_UNCAPPED', // Decision tree hits
'DOUBLE_UNCAPPED', // Decision tree hits
'ERROR', // Defensive plays
]
// Computed // Computed
const needsHitLocation = computed(() => { const needsHitLocation = computed(() => {
return selectedOutcome.value !== null && outcomesNeedingHitLocation.includes(selectedOutcome.value) if (!selectedOutcome.value) return false
if (!(outcomesNeedingHitLocation as readonly string[]).includes(selectedOutcome.value)) return false
// Hit location only matters when there are runners on base AND less than 2 outs
// (for fielding choices and runner advancement)
const hasRunnersAndCanAdvance = props.hasRunners && props.outs < 2
return hasRunnersAndCanAdvance
}) })
const canSubmitForm = computed(() => { const canSubmitForm = computed(() => {
@ -174,7 +156,7 @@ const canSubmitForm = computed(() => {
const selectOutcome = (outcome: PlayOutcome) => { const selectOutcome = (outcome: PlayOutcome) => {
selectedOutcome.value = outcome selectedOutcome.value = outcome
// Clear hit location if the new outcome doesn't need it // Clear hit location if the new outcome doesn't need it
if (!outcomesNeedingHitLocation.includes(outcome)) { if (!(outcomesNeedingHitLocation as readonly string[]).includes(outcome)) {
selectedHitLocation.value = null selectedHitLocation.value = null
} }
} }

View File

@ -235,6 +235,11 @@ export function useWebSocket() {
console.log('[WebSocket] Server confirmed connection for user:', data.user_id) 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', () => { socketInstance.on('heartbeat_ack', () => {
// Heartbeat acknowledged - connection is healthy // Heartbeat acknowledged - connection is healthy
// No action needed, just prevents timeout // No action needed, just prevents timeout
@ -263,12 +268,6 @@ export function useWebSocket() {
}) })
}) })
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) => { socketInstance.on('inning_change', (data) => {
console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`) console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`)
uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000) uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000)
@ -294,6 +293,14 @@ export function useWebSocket() {
socketInstance.on('defensive_decision_submitted', (data) => { socketInstance.on('defensive_decision_submitted', (data) => {
console.log('[WebSocket] Defensive decision submitted') console.log('[WebSocket] Defensive decision submitted')
gameStore.clearDecisionPrompt() 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) { if (data.pending_decision) {
uiStore.showInfo('Defense set. Waiting for offense...', 3000) uiStore.showInfo('Defense set. Waiting for offense...', 3000)
} else { } else {
@ -304,6 +311,14 @@ export function useWebSocket() {
socketInstance.on('offensive_decision_submitted', (data) => { socketInstance.on('offensive_decision_submitted', (data) => {
console.log('[WebSocket] Offensive decision submitted') console.log('[WebSocket] Offensive decision submitted')
gameStore.clearDecisionPrompt() 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) uiStore.showSuccess('Offense set. Ready to play!', 3000)
}) })
@ -316,8 +331,8 @@ export function useWebSocket() {
gameStore.setPendingRoll({ gameStore.setPendingRoll({
roll_id: data.roll_id, roll_id: data.roll_id,
d6_one: data.d6_one, d6_one: data.d6_one,
d6_two_a: 0, // Not provided by server d6_two_a: data.d6_two_a,
d6_two_b: 0, // Not provided by server d6_two_b: data.d6_two_b,
d6_two_total: data.d6_two_total, d6_two_total: data.d6_two_total,
chaos_d20: data.chaos_d20, chaos_d20: data.chaos_d20,
resolution_d20: data.resolution_d20, resolution_d20: data.resolution_d20,

View File

@ -0,0 +1,93 @@
/**
* Outcome Constants
*
* Centralized definitions for play outcomes used across the UI.
* These match the backend PlayOutcome enum values.
*/
import type { PlayOutcome } from '~/types/game'
/**
* Outcome categories for UI display and selection
* Grouped by common baseball categories for user-friendly selection
*/
export const OUTCOME_CATEGORIES = [
{
name: 'Outs',
outcomes: [
'strikeout',
'groundball_a',
'groundball_b',
'groundball_c',
'flyout_a',
'flyout_b',
'flyout_c',
'lineout',
'popout',
] as const satisfies readonly PlayOutcome[],
},
{
name: 'Hits',
outcomes: [
'single_1',
'single_2',
'single_uncapped',
'double_2',
'double_3',
'double_uncapped',
'triple',
'homerun',
] as const satisfies readonly PlayOutcome[],
},
{
name: 'Walks / HBP',
outcomes: [
'walk',
'intentional_walk',
'hbp',
] as const satisfies readonly PlayOutcome[],
},
{
name: 'Special',
outcomes: [
'error',
'x_check',
] as const satisfies readonly PlayOutcome[],
},
{
name: 'Interrupts',
outcomes: [
'stolen_base',
'caught_stealing',
'wild_pitch',
'passed_ball',
'balk',
'pick_off',
] as const satisfies readonly PlayOutcome[],
},
] as const
/**
* Outcomes that require a hit location to be specified
* These outcomes involve defensive plays where location matters
*/
export const OUTCOMES_REQUIRING_HIT_LOCATION = [
'groundball_a',
'groundball_b',
'groundball_c',
'flyout_a',
'flyout_b',
'flyout_c',
'lineout',
'single_uncapped',
'double_uncapped',
'error',
] as const satisfies readonly PlayOutcome[]
/**
* Hit location options
*/
export const HIT_LOCATIONS = {
infield: ['P', 'C', '1B', '2B', '3B', 'SS'] as const,
outfield: ['LF', 'CF', 'RF'] as const,
} as const

View File

@ -86,6 +86,8 @@
:pending-roll="pendingRoll" :pending-roll="pendingRoll"
:last-play-result="lastPlayResult" :last-play-result="lastPlayResult"
:can-submit-outcome="canSubmitOutcome" :can-submit-outcome="canSubmitOutcome"
:outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.runners?.first || gameState?.runners?.second || gameState?.runners?.third)"
@roll-dice="handleRollDice" @roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome" @submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult" @dismiss-result="handleDismissResult"
@ -137,6 +139,8 @@
:pending-roll="pendingRoll" :pending-roll="pendingRoll"
:last-play-result="lastPlayResult" :last-play-result="lastPlayResult"
:can-submit-outcome="canSubmitOutcome" :can-submit-outcome="canSubmitOutcome"
:outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.runners?.first || gameState?.runners?.second || gameState?.runners?.third)"
@roll-dice="handleRollDice" @roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome" @submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult" @dismiss-result="handleDismissResult"
@ -303,6 +307,7 @@ const needsDefensiveDecision = computed(() => gameStore.needsDefensiveDecision)
const needsOffensiveDecision = computed(() => gameStore.needsOffensiveDecision) const needsOffensiveDecision = computed(() => gameStore.needsOffensiveDecision)
const pendingRoll = computed(() => gameStore.pendingRoll) const pendingRoll = computed(() => gameStore.pendingRoll)
const lastPlayResult = computed(() => gameStore.lastPlayResult) const lastPlayResult = computed(() => gameStore.lastPlayResult)
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
// Local UI state // Local UI state
const isLoading = ref(true) const isLoading = ref(true)
@ -353,12 +358,36 @@ const decisionPhase = computed(() => {
// Phase F6: Conditional panel rendering // Phase F6: Conditional panel rendering
const showDecisions = computed(() => { const showDecisions = computed(() => {
return gameState.value?.status === 'active' && // Don't show decision panels if there's a result pending dismissal
if (lastPlayResult.value) {
return false
}
const result = gameState.value?.status === 'active' &&
isMyTurn.value && isMyTurn.value &&
(needsDefensiveDecision.value || needsOffensiveDecision.value) (needsDefensiveDecision.value || needsOffensiveDecision.value)
// Debug logging
console.log('[Game Page] Panel visibility check:', {
gameStatus: gameState.value?.status,
isMyTurn: isMyTurn.value,
needsDefensiveDecision: needsDefensiveDecision.value,
needsOffensiveDecision: needsOffensiveDecision.value,
decision_phase: gameState.value?.decision_phase,
showDecisions: result,
currentDecisionPrompt: currentDecisionPrompt.value,
hasLastPlayResult: !!lastPlayResult.value
})
return result
}) })
const showGameplay = computed(() => { const showGameplay = computed(() => {
// Show gameplay panel if there's a result to display OR if we're in the resolution phase
if (lastPlayResult.value) {
return true
}
return gameState.value?.status === 'active' && return gameState.value?.status === 'active' &&
isMyTurn.value && isMyTurn.value &&
!needsDefensiveDecision.value && !needsDefensiveDecision.value &&

View File

@ -107,15 +107,21 @@ export const useGameStore = defineStore('game', () => {
}) })
const needsDefensiveDecision = computed(() => { const needsDefensiveDecision = computed(() => {
return currentDecisionPrompt.value?.phase === 'defense' // 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(() => { const needsOffensiveDecision = computed(() => {
return currentDecisionPrompt.value?.phase === 'offensive_approach' // 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(() => { const needsStolenBaseDecision = computed(() => {
return currentDecisionPrompt.value?.phase === 'stolen_base' // 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 canRollDice = computed(() => { const canRollDice = computed(() => {

View File

@ -223,7 +223,7 @@ describe('DefensiveSetup', () => {
expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn') expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn')
}) })
it('shows "No Changes" when setup unchanged', () => { it('shows "Submit (Keep Setup)" when setup unchanged', () => {
const currentSetup: DefensiveDecision = { const currentSetup: DefensiveDecision = {
infield_depth: 'normal', infield_depth: 'normal',
outfield_depth: 'normal', outfield_depth: 'normal',
@ -237,7 +237,7 @@ describe('DefensiveSetup', () => {
}, },
}) })
expect(wrapper.vm.submitButtonText).toBe('No Changes') expect(wrapper.vm.submitButtonText).toBe('Submit (Keep Setup)')
}) })
it('shows "Submit Defensive Setup" when active with changes', () => { it('shows "Submit Defensive Setup" when active with changes', () => {

View File

@ -31,7 +31,7 @@ const createMockGameState = (overrides?: Partial<GameState>): GameState => ({
current_batter: null, current_batter: null,
current_pitcher: null, current_pitcher: null,
current_catcher: null, current_catcher: null,
decision_phase: 'defense', decision_phase: 'awaiting_defensive',
...overrides, ...overrides,
}) })

View File

@ -34,8 +34,11 @@ export type LeagueId = 'sba' | 'pd'
/** /**
* Decision phase in the play workflow * Decision phase in the play workflow
*
* Standardized naming (2025-01-21): Uses backend convention 'awaiting_*'
* for clarity about what action is pending.
*/ */
export type DecisionPhase = 'defense' | 'stolen_base' | 'offensive_approach' | 'resolution' | 'complete' export type DecisionPhase = 'awaiting_defensive' | 'awaiting_stolen_base' | 'awaiting_offensive' | 'resolution' | 'complete'
/** /**
* Lineup player state - represents a player in the game * Lineup player state - represents a player in the game
@ -156,50 +159,54 @@ export interface RollData {
} }
/** /**
* Play outcome enumeration (subset of backend PlayOutcome) * Play outcome enumeration (matches backend PlayOutcome enum values)
* Note: These are the STRING VALUES, not enum names
*/ */
export type PlayOutcome = export type PlayOutcome =
// Standard outs // Standard outs
| 'STRIKEOUT' | 'strikeout'
| 'GROUNDOUT' | 'groundball_a'
| 'FLYOUT' | 'groundball_b'
| 'LINEOUT' | 'groundball_c'
| 'POPOUT' | 'flyout_a'
| 'DOUBLE_PLAY' | 'flyout_b'
| 'flyout_bq'
| 'flyout_c'
| 'lineout'
| 'popout'
// Walks and hits by pitch // Walks and hits by pitch
| 'WALK' | 'walk'
| 'INTENTIONAL_WALK' | 'intentional_walk'
| 'HIT_BY_PITCH' | 'hbp'
// Hits (capped - specific bases) // Hits (capped - specific bases)
| 'SINGLE_1' | 'single_1'
| 'SINGLE_2' | 'single_2'
| 'DOUBLE_2' | 'single_uncapped'
| 'DOUBLE_3' | 'double_2'
| 'TRIPLE' | 'double_3'
| 'HOMERUN' | 'double_uncapped'
| 'triple'
// Uncapped hits (require advancement decisions) | 'homerun'
| 'SINGLE_UNCAPPED'
| 'DOUBLE_UNCAPPED'
// Interrupt plays (baserunning events) // Interrupt plays (baserunning events)
| 'STOLEN_BASE' | 'stolen_base'
| 'CAUGHT_STEALING' | 'caught_stealing'
| 'WILD_PITCH' | 'wild_pitch'
| 'PASSED_BALL' | 'passed_ball'
| 'BALK' | 'balk'
| 'PICK_OFF' | 'pick_off'
// Errors // Errors
| 'ERROR' | 'error'
| 'x_check'
// Ballpark power (PD specific) // Ballpark power (PD specific)
| 'BP_HOMERUN' | 'bp_homerun'
| 'BP_FLYOUT' | 'bp_flyout'
| 'BP_SINGLE' | 'bp_single'
| 'BP_LINEOUT' | 'bp_lineout'
/** /**
* Runner advancement during a play * Runner advancement during a play