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
- **WebSocket Protocol Spec**: `../.claude/WEBSOCKET_PROTOCOL_SPEC.md` - Complete event catalog and workflow
- **Implementation Guide**: `../.claude/implementation/01-infrastructure.md`
- **WebSocket Protocol**: `../.claude/implementation/websocket-protocol.md`
- **Full PRD**: `../prd-web-scorecard-1.1.md`
---

View File

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

View File

@ -68,7 +68,7 @@
type="submit"
variant="success"
size="lg"
:disabled="!isActive || !hasChanges"
:disabled="!isActive"
:loading="submitting"
full-width
>
@ -209,7 +209,7 @@ const hasChanges = computed(() => {
const submitButtonText = computed(() => {
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'
})
@ -240,7 +240,7 @@ const getActionButtonClasses = (action: OffensiveDecision['action'], disabled: b
}
const handleSubmit = async () => {
if (!props.isActive || !hasChanges.value) return
if (!props.isActive) return
submitting.value = true
try {

View File

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

View File

@ -107,9 +107,14 @@ import type { PlayOutcome, RollData } from '~/types'
interface Props {
rollData: RollData | null
canSubmit: boolean
outs?: number
hasRunners?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
})
const emit = defineEmits<{
submit: [{ outcome: PlayOutcome; hitLocation?: string }]
@ -120,48 +125,25 @@ const emit = defineEmits<{
const selectedOutcome = ref<PlayOutcome | null>(null)
const selectedHitLocation = ref<string | null>(null)
// Outcome categories
const outcomeCategories = [
{
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[],
},
]
// Import centralized outcome constants
import { OUTCOME_CATEGORIES, OUTCOMES_REQUIRING_HIT_LOCATION, HIT_LOCATIONS } from '~/constants/outcomes'
// Hit location options
const infieldPositions = ['P', 'C', '1B', '2B', '3B', 'SS']
const outfieldPositions = ['LF', 'CF', 'RF']
// Outcomes that require 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
]
// Use imported constants
const outcomeCategories = OUTCOME_CATEGORIES
const infieldPositions = HIT_LOCATIONS.infield
const outfieldPositions = HIT_LOCATIONS.outfield
const outcomesNeedingHitLocation = OUTCOMES_REQUIRING_HIT_LOCATION
// 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(() => {
@ -174,7 +156,7 @@ const canSubmitForm = computed(() => {
const selectOutcome = (outcome: PlayOutcome) => {
selectedOutcome.value = outcome
// 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
}
}

View File

@ -235,6 +235,11 @@ export function useWebSocket() {
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', () => {
// Heartbeat acknowledged - connection is healthy
// 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) => {
console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`)
uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000)
@ -294,6 +293,14 @@ export function useWebSocket() {
socketInstance.on('defensive_decision_submitted', (data) => {
console.log('[WebSocket] Defensive decision submitted')
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) {
uiStore.showInfo('Defense set. Waiting for offense...', 3000)
} else {
@ -304,6 +311,14 @@ export function useWebSocket() {
socketInstance.on('offensive_decision_submitted', (data) => {
console.log('[WebSocket] Offensive decision submitted')
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)
})
@ -316,8 +331,8 @@ export function useWebSocket() {
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_a: data.d6_two_a,
d6_two_b: data.d6_two_b,
d6_two_total: data.d6_two_total,
chaos_d20: data.chaos_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"
:last-play-result="lastPlayResult"
:can-submit-outcome="canSubmitOutcome"
:outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.runners?.first || gameState?.runners?.second || gameState?.runners?.third)"
@roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult"
@ -137,6 +139,8 @@
:pending-roll="pendingRoll"
:last-play-result="lastPlayResult"
:can-submit-outcome="canSubmitOutcome"
:outs="gameState?.outs ?? 0"
:has-runners="!!(gameState?.runners?.first || gameState?.runners?.second || gameState?.runners?.third)"
@roll-dice="handleRollDice"
@submit-outcome="handleSubmitOutcome"
@dismiss-result="handleDismissResult"
@ -303,6 +307,7 @@ const needsDefensiveDecision = computed(() => gameStore.needsDefensiveDecision)
const needsOffensiveDecision = computed(() => gameStore.needsOffensiveDecision)
const pendingRoll = computed(() => gameStore.pendingRoll)
const lastPlayResult = computed(() => gameStore.lastPlayResult)
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
// Local UI state
const isLoading = ref(true)
@ -353,12 +358,36 @@ const decisionPhase = computed(() => {
// Phase F6: Conditional panel rendering
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 &&
(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(() => {
// 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' &&
isMyTurn.value &&
!needsDefensiveDecision.value &&

View File

@ -107,15 +107,21 @@ export const useGameStore = defineStore('game', () => {
})
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(() => {
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(() => {
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(() => {

View File

@ -223,7 +223,7 @@ describe('DefensiveSetup', () => {
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 = {
infield_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', () => {

View File

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

View File

@ -34,8 +34,11 @@ export type LeagueId = 'sba' | 'pd'
/**
* 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
@ -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 =
// Standard outs
| 'STRIKEOUT'
| 'GROUNDOUT'
| 'FLYOUT'
| 'LINEOUT'
| 'POPOUT'
| 'DOUBLE_PLAY'
| 'strikeout'
| 'groundball_a'
| 'groundball_b'
| 'groundball_c'
| 'flyout_a'
| 'flyout_b'
| 'flyout_bq'
| 'flyout_c'
| 'lineout'
| 'popout'
// Walks and hits by pitch
| 'WALK'
| 'INTENTIONAL_WALK'
| 'HIT_BY_PITCH'
| 'walk'
| 'intentional_walk'
| 'hbp'
// Hits (capped - specific bases)
| 'SINGLE_1'
| 'SINGLE_2'
| 'DOUBLE_2'
| 'DOUBLE_3'
| 'TRIPLE'
| 'HOMERUN'
// Uncapped hits (require advancement decisions)
| 'SINGLE_UNCAPPED'
| 'DOUBLE_UNCAPPED'
| 'single_1'
| 'single_2'
| 'single_uncapped'
| 'double_2'
| 'double_3'
| 'double_uncapped'
| 'triple'
| 'homerun'
// Interrupt plays (baserunning events)
| 'STOLEN_BASE'
| 'CAUGHT_STEALING'
| 'WILD_PITCH'
| 'PASSED_BALL'
| 'BALK'
| 'PICK_OFF'
| 'stolen_base'
| 'caught_stealing'
| 'wild_pitch'
| 'passed_ball'
| 'balk'
| 'pick_off'
// Errors
| 'ERROR'
| 'error'
| 'x_check'
// Ballpark power (PD specific)
| 'BP_HOMERUN'
| 'BP_FLYOUT'
| 'BP_SINGLE'
| 'BP_LINEOUT'
| 'bp_homerun'
| 'bp_flyout'
| 'bp_single'
| 'bp_lineout'
/**
* Runner advancement during a play