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>
526 lines
15 KiB
Vue
526 lines
15 KiB
Vue
<template>
|
|
<div class="gameplay-panel">
|
|
<div class="panel-container">
|
|
<!-- Panel Header -->
|
|
<div class="panel-header">
|
|
<h2 class="panel-title">Gameplay</h2>
|
|
<div class="panel-status">
|
|
<div :class="['status-indicator', statusClass]"/>
|
|
<span class="status-text">{{ statusText }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Workflow States -->
|
|
<div class="panel-content">
|
|
<!-- State: Idle (Waiting for decisions) -->
|
|
<div v-if="workflowState === 'idle'" class="state-idle">
|
|
<div class="state-message">
|
|
<svg class="w-12 h-12 text-gray-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 strategic decisions to complete...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- State: Ready to Roll -->
|
|
<div v-else-if="workflowState === 'ready_to_roll'" class="state-ready">
|
|
<div v-if="!isMyTurn" 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 opponent to roll dice...
|
|
</div>
|
|
</div>
|
|
<div v-else class="state-content">
|
|
<div class="instruction-box">
|
|
<svg class="w-6 h-6 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>Your turn! Roll the dice to start the play.</span>
|
|
</div>
|
|
<DiceRoller
|
|
:can-roll="canRollDice"
|
|
:pending-roll="null"
|
|
:dice-color="diceColor"
|
|
@roll="handleRollDice"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- State: Rolled (Dice results shown, enter outcome) -->
|
|
<div v-else-if="workflowState === 'rolled'" class="state-rolled">
|
|
<DiceRoller
|
|
:can-roll="false"
|
|
:pending-roll="pendingRoll"
|
|
:dice-color="diceColor"
|
|
/>
|
|
|
|
<OutcomeWizard
|
|
:can-submit="canSubmitOutcome"
|
|
@submit="handleSubmitOutcome"
|
|
@cancel="handleCancelOutcome"
|
|
/>
|
|
</div>
|
|
|
|
<!-- State: Submitted (Processing) -->
|
|
<div v-else-if="workflowState === 'submitted'" class="state-submitted">
|
|
<div class="state-message">
|
|
<svg class="animate-spin w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
<div class="message-text">
|
|
Processing play result...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- State: Uncapped Hit Pending -->
|
|
<div v-else-if="workflowState === 'uncapped_hit_pending'" class="state-uncapped-hit">
|
|
<UncappedHitWizard
|
|
:phase="currentUncappedPhase!"
|
|
:data="uncappedHitData"
|
|
:readonly="!isUncappedInteractive"
|
|
:pending-uncapped-hit="pendingUncappedHit"
|
|
@submit-lead-advance="handleUncappedLeadAdvance"
|
|
@submit-defensive-throw="handleUncappedDefensiveThrow"
|
|
@submit-trail-advance="handleUncappedTrailAdvance"
|
|
@submit-throw-target="handleUncappedThrowTarget"
|
|
@submit-safe-out="handleUncappedSafeOut"
|
|
/>
|
|
</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
|
|
:result="lastPlayResult"
|
|
:auto-hide="false"
|
|
@dismiss="handleDismissResult"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-if="error" class="error-message">
|
|
<svg class="w-6 h-6 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import type { RollData, PlayResult, PlayOutcome, XCheckData, DecisionPhase, UncappedHitData } 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'
|
|
import UncappedHitWizard from './UncappedHitWizard.vue'
|
|
|
|
interface Props {
|
|
gameId: string
|
|
isMyTurn: boolean
|
|
canRollDice: boolean
|
|
pendingRoll: RollData | null
|
|
lastPlayResult: PlayResult | null
|
|
canSubmitOutcome: boolean
|
|
outs?: number
|
|
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 }]
|
|
submitUncappedLeadAdvance: [advance: boolean]
|
|
submitUncappedDefensiveThrow: [willThrow: boolean]
|
|
submitUncappedTrailAdvance: [advance: boolean]
|
|
submitUncappedThrowTarget: [target: 'lead' | 'trail']
|
|
submitUncappedSafeOut: [result: 'safe' | 'out']
|
|
}>()
|
|
|
|
// 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)
|
|
|
|
// Uncapped hit data from store
|
|
const uncappedHitData = computed(() => gameStore.uncappedHitData)
|
|
const pendingUncappedHit = computed(() => {
|
|
const hit = gameStore.gameState?.pending_uncapped_hit
|
|
return (hit ?? null) as import('~/types').PendingUncappedHit | null
|
|
})
|
|
|
|
// Current uncapped hit phase
|
|
const currentUncappedPhase = computed<DecisionPhase | null>(() => {
|
|
if (gameStore.needsUncappedLeadAdvance) return 'awaiting_uncapped_lead_advance'
|
|
if (gameStore.needsUncappedDefensiveThrow) return 'awaiting_uncapped_defensive_throw'
|
|
if (gameStore.needsUncappedTrailAdvance) return 'awaiting_uncapped_trail_advance'
|
|
if (gameStore.needsUncappedThrowTarget) return 'awaiting_uncapped_throw_target'
|
|
if (gameStore.needsUncappedSafeOut) return 'awaiting_uncapped_safe_out'
|
|
return null
|
|
})
|
|
|
|
// Offensive phases: user's team must be the batting team
|
|
// Defensive phases: user's team must be the fielding team
|
|
const UNCAPPED_OFFENSIVE_PHASES: DecisionPhase[] = [
|
|
'awaiting_uncapped_lead_advance',
|
|
'awaiting_uncapped_trail_advance',
|
|
'awaiting_uncapped_safe_out',
|
|
]
|
|
|
|
const isUncappedInteractive = computed(() => {
|
|
if (!currentUncappedPhase.value || !props.userTeamId || !gameStore.gameState) return false
|
|
const isOffensivePhase = UNCAPPED_OFFENSIVE_PHASES.includes(currentUncappedPhase.value)
|
|
if (isOffensivePhase) {
|
|
return props.userTeamId === gameStore.battingTeamId
|
|
} else {
|
|
return props.userTeamId === gameStore.fieldingTeamId
|
|
}
|
|
})
|
|
|
|
// 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' | 'uncapped_hit_pending' | 'x_check_result_pending'
|
|
|
|
const workflowState = computed<WorkflowState>(() => {
|
|
// Show result if we have one
|
|
if (props.lastPlayResult) {
|
|
return 'result'
|
|
}
|
|
|
|
// Show uncapped hit wizard if in any uncapped phase
|
|
if (gameStore.needsUncappedDecision) {
|
|
return 'uncapped_hit_pending'
|
|
}
|
|
|
|
// 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'
|
|
}
|
|
|
|
// Show dice results and outcome entry
|
|
if (props.pendingRoll) {
|
|
return 'rolled'
|
|
}
|
|
|
|
// Show roll button if can roll
|
|
if (props.canRollDice) {
|
|
return 'ready_to_roll'
|
|
}
|
|
|
|
// Default to idle
|
|
return 'idle'
|
|
})
|
|
|
|
// Status indicator
|
|
const statusClass = computed(() => {
|
|
if (error.value) return 'status-error'
|
|
if (workflowState.value === 'result') return 'status-success'
|
|
if (workflowState.value === 'uncapped_hit_pending') return 'status-active'
|
|
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'
|
|
return 'status-idle'
|
|
})
|
|
|
|
const statusText = computed(() => {
|
|
if (error.value) return 'Error'
|
|
if (workflowState.value === 'result') return 'Play Complete'
|
|
if (workflowState.value === 'uncapped_hit_pending') {
|
|
return isUncappedInteractive.value ? 'Uncapped Hit Decision' : 'Waiting for Decision'
|
|
}
|
|
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') {
|
|
return props.isMyTurn ? 'Your Turn' : 'Opponent Turn'
|
|
}
|
|
return 'Waiting'
|
|
})
|
|
|
|
// Methods
|
|
const handleRollDice = () => {
|
|
error.value = null
|
|
emit('rollDice')
|
|
}
|
|
|
|
const handleSubmitOutcome = (payload: { outcome: PlayOutcome; hitLocation?: string }) => {
|
|
error.value = null
|
|
isSubmitting.value = true
|
|
emit('submitOutcome', payload)
|
|
|
|
// Reset submitting state after a delay (will be cleared by server response)
|
|
setTimeout(() => {
|
|
isSubmitting.value = false
|
|
}, 3000)
|
|
}
|
|
|
|
const handleCancelOutcome = () => {
|
|
error.value = null
|
|
// Could emit cancel event if needed
|
|
}
|
|
|
|
const handleDismissResult = () => {
|
|
error.value = null
|
|
emit('dismissResult')
|
|
}
|
|
|
|
const handleUncappedLeadAdvance = (advance: boolean) => {
|
|
emit('submitUncappedLeadAdvance', advance)
|
|
}
|
|
|
|
const handleUncappedDefensiveThrow = (willThrow: boolean) => {
|
|
emit('submitUncappedDefensiveThrow', willThrow)
|
|
}
|
|
|
|
const handleUncappedTrailAdvance = (advance: boolean) => {
|
|
emit('submitUncappedTrailAdvance', advance)
|
|
}
|
|
|
|
const handleUncappedThrowTarget = (target: 'lead' | 'trail') => {
|
|
emit('submitUncappedThrowTarget', target)
|
|
}
|
|
|
|
const handleUncappedSafeOut = (result: 'safe' | 'out') => {
|
|
emit('submitUncappedSafeOut', result)
|
|
}
|
|
|
|
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>
|
|
.gameplay-panel {
|
|
@apply w-full;
|
|
}
|
|
|
|
.panel-container {
|
|
@apply bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl shadow-lg;
|
|
@apply border border-gray-200;
|
|
}
|
|
|
|
/* Header */
|
|
.panel-header {
|
|
@apply flex justify-between items-center px-6 py-4 border-b border-gray-200;
|
|
@apply bg-white rounded-t-xl;
|
|
}
|
|
|
|
.panel-title {
|
|
@apply text-xl font-bold text-gray-900;
|
|
}
|
|
|
|
.panel-status {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.status-indicator {
|
|
@apply w-3 h-3 rounded-full;
|
|
}
|
|
|
|
.status-idle {
|
|
@apply bg-gray-400;
|
|
}
|
|
|
|
.status-active {
|
|
@apply bg-blue-500 animate-pulse;
|
|
}
|
|
|
|
.status-processing {
|
|
@apply bg-yellow-500 animate-pulse;
|
|
}
|
|
|
|
.status-success {
|
|
@apply bg-green-500;
|
|
}
|
|
|
|
.status-error {
|
|
@apply bg-red-500;
|
|
}
|
|
|
|
.status-text {
|
|
@apply text-sm font-medium text-gray-700;
|
|
}
|
|
|
|
/* Content */
|
|
.panel-content {
|
|
@apply p-6 space-y-4;
|
|
}
|
|
|
|
/* State Messages */
|
|
.state-message {
|
|
@apply flex flex-col items-center justify-center py-12 space-y-4;
|
|
}
|
|
|
|
.message-text {
|
|
@apply text-lg text-gray-600 text-center;
|
|
}
|
|
|
|
/* State: Ready */
|
|
.state-ready {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
.state-content {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
.instruction-box {
|
|
@apply flex items-center gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg;
|
|
}
|
|
|
|
.instruction-box span {
|
|
@apply text-sm font-medium text-blue-800;
|
|
}
|
|
|
|
/* State: Rolled */
|
|
.state-rolled {
|
|
@apply space-y-6;
|
|
}
|
|
|
|
/* State: Uncapped Hit */
|
|
.state-uncapped-hit {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
/* State: X-Check */
|
|
.state-x-check {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
/* State: Result */
|
|
.state-result {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
/* Error Message */
|
|
.error-message {
|
|
@apply flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg;
|
|
@apply text-red-800 font-medium;
|
|
}
|
|
|
|
/* Dark mode support */
|
|
@media (prefers-color-scheme: dark) {
|
|
.panel-container {
|
|
@apply from-gray-800 to-gray-900 border-gray-600;
|
|
@apply ring-1 ring-gray-700;
|
|
}
|
|
|
|
.panel-header {
|
|
@apply bg-gray-800 border-gray-600;
|
|
}
|
|
|
|
.panel-title {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.status-text {
|
|
@apply text-gray-300;
|
|
}
|
|
|
|
.message-text {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.instruction-box {
|
|
@apply bg-blue-900 bg-opacity-30 border-blue-700;
|
|
}
|
|
|
|
.instruction-box span {
|
|
@apply text-blue-300;
|
|
}
|
|
|
|
.error-message {
|
|
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
|
|
}
|
|
}
|
|
|
|
/* Mobile optimizations */
|
|
@media (max-width: 640px) {
|
|
.panel-header {
|
|
@apply px-4 py-3;
|
|
}
|
|
|
|
.panel-title {
|
|
@apply text-lg;
|
|
}
|
|
|
|
.panel-content {
|
|
@apply p-4;
|
|
}
|
|
|
|
.state-message {
|
|
@apply py-8;
|
|
}
|
|
|
|
.message-text {
|
|
@apply text-base;
|
|
}
|
|
}
|
|
</style>
|