feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8

Merged
cal merged 11 commits from feature/uncapped-hit-decision-tree into main 2026-02-12 15:37:34 +00:00
6 changed files with 239 additions and 5 deletions
Showing only changes of commit 453280487c - Show all commits

View File

@ -78,6 +78,24 @@
</div>
</div>
<!-- State: X-Check Result Pending -->
<div v-else-if="workflowState === 'x_check_result_pending'" class="state-x-check">
<div v-if="!isXCheckInteractive" class="state-message">
<svg class="w-12 h-12 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<div class="message-text">
Waiting for defense to select x-check result...
</div>
</div>
<XCheckWizard
v-else-if="xCheckData"
:x-check-data="xCheckData"
:readonly="!isXCheckInteractive"
@submit="handleXCheckSubmit"
/>
</div>
<!-- State: Result (Play completed) -->
<div v-else-if="workflowState === 'result'" class="state-result">
<PlayResultDisplay
@ -101,10 +119,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RollData, PlayResult, PlayOutcome } from '~/types'
import type { RollData, PlayResult, PlayOutcome, XCheckData } from '~/types'
import { useGameStore } from '~/store/game'
import DiceRoller from './DiceRoller.vue'
import OutcomeWizard from './OutcomeWizard.vue'
import PlayResultDisplay from './PlayResult.vue'
import XCheckWizard from './XCheckWizard.vue'
interface Props {
gameId: string
@ -117,26 +137,44 @@ interface Props {
hasRunners?: boolean
// Dice color from home team (hex without #)
diceColor?: string
// User's team ID (for determining interactive mode in x-check)
userTeamId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
diceColor: 'cc0000', // Default red
userTeamId: null,
})
const emit = defineEmits<{
rollDice: []
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
dismissResult: []
submitXCheckResult: [{ resultCode: string; errorResult: string }]
}>()
// Store access
const gameStore = useGameStore()
// Local state
const error = ref<string | null>(null)
const isSubmitting = ref(false)
// X-Check data from store
const xCheckData = computed(() => gameStore.xCheckData)
// Determine if current user should have interactive mode
// Uses active_team_id from x-check data (set by backend to indicate which team should interact)
const isXCheckInteractive = computed(() => {
if (!xCheckData.value || !props.userTeamId) return false
// Backend sets active_team_id to indicate which team should have interactive controls
return xCheckData.value.active_team_id === props.userTeamId
})
// Workflow state computation
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result' | 'x_check_result_pending'
const workflowState = computed<WorkflowState>(() => {
// Show result if we have one
@ -144,6 +182,11 @@ const workflowState = computed<WorkflowState>(() => {
return 'result'
}
// Show x-check result selection if awaiting
if (gameStore.needsXCheckResult && xCheckData.value) {
return 'x_check_result_pending'
}
// Show submitted/processing state
if (isSubmitting.value) {
return 'submitted'
@ -167,6 +210,7 @@ const workflowState = computed<WorkflowState>(() => {
const statusClass = computed(() => {
if (error.value) return 'status-error'
if (workflowState.value === 'result') return 'status-success'
if (workflowState.value === 'x_check_result_pending') return 'status-active'
if (workflowState.value === 'submitted') return 'status-processing'
if (workflowState.value === 'rolled') return 'status-active'
if (workflowState.value === 'ready_to_roll' && props.isMyTurn) return 'status-active'
@ -176,6 +220,9 @@ const statusClass = computed(() => {
const statusText = computed(() => {
if (error.value) return 'Error'
if (workflowState.value === 'result') return 'Play Complete'
if (workflowState.value === 'x_check_result_pending') {
return isXCheckInteractive.value ? 'Select X-Check Result' : 'Waiting for Defense'
}
if (workflowState.value === 'submitted') return 'Processing'
if (workflowState.value === 'rolled') return 'Enter Outcome'
if (workflowState.value === 'ready_to_roll') {
@ -210,6 +257,17 @@ const handleDismissResult = () => {
error.value = null
emit('dismissResult')
}
const handleXCheckSubmit = (payload: { resultCode: string; errorResult: string }) => {
error.value = null
isSubmitting.value = true
emit('submitXCheckResult', payload)
// Reset submitting state after a delay
setTimeout(() => {
isSubmitting.value = false
}, 3000)
}
</script>
<style scoped>
@ -300,6 +358,11 @@ const handleDismissResult = () => {
@apply space-y-6;
}
/* State: X-Check */
.state-x-check {
@apply space-y-4;
}
/* State: Result */
.state-result {
@apply space-y-4;

View File

@ -174,6 +174,75 @@ export function useGameActions(gameId?: string) {
uiStore.showInfo('Submitting outcome...', 2000)
}
// ============================================================================
// X-Check Interactive Workflow
// ============================================================================
/**
* Submit x-check result selection (result code + error)
*/
function submitXCheckResult(resultCode: string, errorResult: string) {
if (!validateConnection()) return
console.log('[GameActions] Submitting x-check result:', resultCode, errorResult)
socket.value!.emit('submit_x_check_result', {
game_id: currentGameId.value!,
result_code: resultCode,
error_result: errorResult,
})
uiStore.showInfo('Submitting x-check result...', 2000)
}
/**
* Submit DECIDE advance decision (offensive player)
*/
function submitDecideAdvance(advance: boolean) {
if (!validateConnection()) return
console.log('[GameActions] Submitting DECIDE advance:', advance)
socket.value!.emit('submit_decide_advance', {
game_id: currentGameId.value!,
advance,
})
uiStore.showInfo('Submitting advance decision...', 2000)
}
/**
* Submit DECIDE throw target (defensive player)
*/
function submitDecideThrow(target: 'runner' | 'first') {
if (!validateConnection()) return
console.log('[GameActions] Submitting DECIDE throw:', target)
socket.value!.emit('submit_decide_throw', {
game_id: currentGameId.value!,
target,
})
uiStore.showInfo('Submitting throw decision...', 2000)
}
/**
* Submit DECIDE speed check result (offensive player)
*/
function submitDecideResult(outcome: 'safe' | 'out') {
if (!validateConnection()) return
console.log('[GameActions] Submitting DECIDE result:', outcome)
socket.value!.emit('submit_decide_result', {
game_id: currentGameId.value!,
outcome,
})
uiStore.showInfo('Submitting speed check result...', 2000)
}
// ============================================================================
// Substitution Actions
// ============================================================================
@ -374,6 +443,12 @@ export function useGameActions(gameId?: string) {
rollDice,
submitManualOutcome,
// X-Check interactive workflow
submitXCheckResult,
submitDecideAdvance,
submitDecideThrow,
submitDecideResult,
// Substitutions
submitSubstitution,

View File

@ -497,8 +497,23 @@ export function useWebSocket() {
// ========================================
state.socketInstance.on('decision_required', (prompt) => {
console.log('[WebSocket] Decision required:', prompt.phase)
console.log('[WebSocket] Decision required:', prompt.phase, 'type:', prompt.type)
gameStore.setDecisionPrompt(prompt)
// Handle x-check specific decision types
if (prompt.type === 'x_check_result' && prompt.data) {
console.log('[WebSocket] X-Check result decision, position:', prompt.data.position)
gameStore.setXCheckData(prompt.data)
} else if (prompt.type === 'decide_advance' && prompt.data) {
console.log('[WebSocket] DECIDE advance decision')
gameStore.setDecideData(prompt.data)
} else if (prompt.type === 'decide_throw' && prompt.data) {
console.log('[WebSocket] DECIDE throw decision')
gameStore.setDecideData(prompt.data)
} else if (prompt.type === 'decide_speed_check' && prompt.data) {
console.log('[WebSocket] DECIDE speed check decision')
gameStore.setDecideData(prompt.data)
}
})
state.socketInstance.on('defensive_decision_submitted', (data) => {
@ -587,6 +602,8 @@ export function useWebSocket() {
// Clear pending decisions since the play is complete and we'll need new ones for next batter
gameStore.clearPendingDecisions()
gameStore.clearXCheckData()
gameStore.clearDecideData()
uiStore.showSuccess(data.description, 5000)
})

View File

@ -16,6 +16,10 @@ import type {
RollData,
Lineup,
BenchPlayer,
XCheckData,
DecideAdvanceData,
DecideThrowData,
DecideSpeedCheckData,
} from '~/types'
export const useGameStore = defineStore('game', () => {
@ -36,6 +40,10 @@ export const useGameStore = defineStore('game', () => {
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)
@ -128,6 +136,26 @@ export const useGameStore = defineStore('game', () => {
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
})
@ -332,6 +360,34 @@ export const useGameStore = defineStore('game', () => {
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)
*/
@ -351,6 +407,8 @@ export const useGameStore = defineStore('game', () => {
pendingOffensiveDecision.value = null
pendingStealAttempts.value = []
decisionHistory.value = []
xCheckData.value = null
decideData.value = null
}
/**
@ -404,6 +462,8 @@ export const useGameStore = defineStore('game', () => {
pendingOffensiveDecision: readonly(pendingOffensiveDecision),
pendingStealAttempts: readonly(pendingStealAttempts),
decisionHistory: readonly(decisionHistory),
xCheckData: readonly(xCheckData),
decideData: readonly(decideData),
// Getters
gameId,
@ -432,6 +492,10 @@ export const useGameStore = defineStore('game', () => {
needsDefensiveDecision,
needsOffensiveDecision,
needsStolenBaseDecision,
needsXCheckResult,
needsDecideAdvance,
needsDecideThrow,
needsDecideResult,
canRollDice,
canSubmitOutcome,
recentPlays,
@ -458,6 +522,10 @@ export const useGameStore = defineStore('game', () => {
setPendingStealAttempts,
addDecisionToHistory,
clearPendingDecisions,
setXCheckData,
clearXCheckData,
setDecideData,
clearDecideData,
resetGame,
getActiveLineup,
getBenchPlayers,

View File

@ -376,8 +376,8 @@ export interface XCheckData {
position: string
d20_roll: number
d6_total: number
d6_individual: number[]
chart_row: string[] // 5 values: ["G1", "G2", "G3#", "SI1", "SI2"]
d6_individual: readonly number[] | number[]
chart_row: readonly string[] | string[] // 5 values: ["G1", "G2", "G3#", "SI1", "SI2"]
chart_type: 'infield' | 'outfield' | 'catcher'
spd_d20: number | null // Pre-rolled, shown on click-to-reveal
defender_lineup_id: number

View File

@ -30,6 +30,12 @@ export type {
GameListItem,
CreateGameRequest,
CreateGameResponse,
// X-Check workflow types
XCheckData,
DecideAdvanceData,
DecideThrowData,
DecideSpeedCheckData,
PendingXCheck,
} from './game'
// Player types
@ -65,6 +71,11 @@ export type {
GetLineupRequest,
GetBoxScoreRequest,
RequestGameStateRequest,
// X-Check workflow request types
SubmitXCheckResultRequest,
SubmitDecideAdvanceRequest,
SubmitDecideThrowRequest,
SubmitDecideResultRequest,
// Event types
ConnectedEvent,
GameJoinedEvent,