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>
537 lines
14 KiB
Vue
537 lines
14 KiB
Vue
<template>
|
|
<div class="uncapped-wizard" :class="{ 'read-only': readonly }">
|
|
<!-- Header -->
|
|
<div class="wizard-header">
|
|
<div class="hit-badge" :class="hitBadgeClass">
|
|
{{ hitTypeLabel }}
|
|
</div>
|
|
<div v-if="hitLocation" class="hit-location">
|
|
to {{ hitLocation }}
|
|
</div>
|
|
<p v-if="readonly" class="waiting-message">
|
|
Waiting for {{ isDefensivePhase ? 'defense' : 'offense' }} to decide...
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Auto-scoring runners info -->
|
|
<div v-if="autoRunnersDisplay.length > 0" class="auto-runners">
|
|
<div v-for="(info, idx) in autoRunnersDisplay" :key="idx" class="auto-runner-line">
|
|
<svg class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>{{ info }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 1: Lead Runner Advance (OFFENSE) -->
|
|
<div v-if="phase === 'awaiting_uncapped_lead_advance'" class="phase-content">
|
|
<div class="phase-label offense-label">Offense Decides</div>
|
|
<div class="runner-info">
|
|
<span class="runner-name">{{ leadRunnerName }}</span>
|
|
<span class="runner-movement">{{ baseLabel(leadData?.lead_runner_base) }} → {{ baseLabel(leadData?.lead_target_base) }}</span>
|
|
</div>
|
|
<div class="decision-buttons">
|
|
<button
|
|
class="decision-btn advance-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitLeadAdvance', true)"
|
|
>
|
|
Advance
|
|
</button>
|
|
<button
|
|
class="decision-btn hold-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitLeadAdvance', false)"
|
|
>
|
|
Hold
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 2: Defensive Throw (DEFENSE) -->
|
|
<div v-else-if="phase === 'awaiting_uncapped_defensive_throw'" class="phase-content">
|
|
<div class="phase-label defense-label">Defense Decides</div>
|
|
<div class="runner-info">
|
|
<span class="runner-name">{{ leadRunnerNameFromThrow }}</span>
|
|
<span class="runner-movement">attempting {{ baseLabel(throwData?.lead_target_base) }}</span>
|
|
</div>
|
|
<div class="decision-buttons">
|
|
<button
|
|
class="decision-btn throw-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitDefensiveThrow', true)"
|
|
>
|
|
Throw
|
|
</button>
|
|
<button
|
|
class="decision-btn letgo-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitDefensiveThrow', false)"
|
|
>
|
|
Let it Go
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 3: Trail Runner Advance (OFFENSE) -->
|
|
<div v-else-if="phase === 'awaiting_uncapped_trail_advance'" class="phase-content">
|
|
<div class="phase-label offense-label">Offense Decides</div>
|
|
<div class="runner-info">
|
|
<span class="runner-name">{{ trailRunnerName }}</span>
|
|
<span class="runner-movement">{{ baseLabel(trailData?.trail_runner_base) }} → {{ baseLabel(trailData?.trail_target_base) }}</span>
|
|
</div>
|
|
<div class="decision-buttons">
|
|
<button
|
|
class="decision-btn advance-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitTrailAdvance', true)"
|
|
>
|
|
Advance
|
|
</button>
|
|
<button
|
|
class="decision-btn hold-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitTrailAdvance', false)"
|
|
>
|
|
Hold
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 4: Throw Target (DEFENSE) -->
|
|
<div v-else-if="phase === 'awaiting_uncapped_throw_target'" class="phase-content">
|
|
<div class="phase-label defense-label">Defense Decides</div>
|
|
<p class="phase-description">Both runners are advancing. Choose your throw target:</p>
|
|
<div class="target-options">
|
|
<button
|
|
class="target-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitThrowTarget', 'lead')"
|
|
>
|
|
<div class="target-label">Lead Runner</div>
|
|
<div class="target-detail">
|
|
{{ leadRunnerNameFromTarget }} → {{ baseLabel(targetData?.lead_target_base) }}
|
|
</div>
|
|
</button>
|
|
<button
|
|
class="target-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitThrowTarget', 'trail')"
|
|
>
|
|
<div class="target-label">Trail Runner</div>
|
|
<div class="target-detail">
|
|
{{ trailRunnerNameFromTarget }} → {{ baseLabel(targetData?.trail_target_base) }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 5: Safe/Out Result (OFFENSE) -->
|
|
<div v-else-if="phase === 'awaiting_uncapped_safe_out'" class="phase-content">
|
|
<div class="phase-label offense-label">Offense Decides</div>
|
|
<div class="d20-display">
|
|
<div class="d20-label">Speed Check d20</div>
|
|
<div class="d20-value">{{ safeOutData?.d20_roll }}</div>
|
|
</div>
|
|
<div class="runner-info">
|
|
<span class="runner-name">{{ safeOutRunnerName }}</span>
|
|
<span class="runner-movement">
|
|
{{ safeOutData?.runner === 'lead' ? 'Lead' : 'Trail' }} runner → {{ baseLabel(safeOutData?.target_base) }}
|
|
</span>
|
|
</div>
|
|
<p class="phase-description">Check runner's speed rating on card vs d20 roll.</p>
|
|
<div class="decision-buttons">
|
|
<button
|
|
class="decision-btn safe-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitSafeOut', 'safe')"
|
|
>
|
|
SAFE
|
|
</button>
|
|
<button
|
|
class="decision-btn out-btn"
|
|
:disabled="readonly"
|
|
@click="$emit('submitSafeOut', 'out')"
|
|
>
|
|
OUT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type {
|
|
DecisionPhase,
|
|
UncappedLeadAdvanceData,
|
|
UncappedDefensiveThrowData,
|
|
UncappedTrailAdvanceData,
|
|
UncappedThrowTargetData,
|
|
UncappedSafeOutData,
|
|
UncappedHitData,
|
|
PendingUncappedHit,
|
|
} from '~/types'
|
|
import { useGameStore } from '~/store/game'
|
|
|
|
interface Props {
|
|
phase: DecisionPhase
|
|
data: UncappedHitData | null
|
|
readonly: boolean
|
|
pendingUncappedHit: PendingUncappedHit | null
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
defineEmits<{
|
|
submitLeadAdvance: [advance: boolean]
|
|
submitDefensiveThrow: [willThrow: boolean]
|
|
submitTrailAdvance: [advance: boolean]
|
|
submitThrowTarget: [target: 'lead' | 'trail']
|
|
submitSafeOut: [result: 'safe' | 'out']
|
|
}>()
|
|
|
|
const gameStore = useGameStore()
|
|
|
|
// Defensive phases (defense decides)
|
|
const DEFENSIVE_PHASES: DecisionPhase[] = [
|
|
'awaiting_uncapped_defensive_throw',
|
|
'awaiting_uncapped_throw_target',
|
|
]
|
|
|
|
const isDefensivePhase = computed(() => DEFENSIVE_PHASES.includes(props.phase))
|
|
|
|
// Typed data accessors per phase
|
|
const leadData = computed(() => {
|
|
if (props.phase === 'awaiting_uncapped_lead_advance') {
|
|
return props.data as UncappedLeadAdvanceData | null
|
|
}
|
|
return null
|
|
})
|
|
|
|
const throwData = computed(() => {
|
|
if (props.phase === 'awaiting_uncapped_defensive_throw') {
|
|
return props.data as UncappedDefensiveThrowData | null
|
|
}
|
|
return null
|
|
})
|
|
|
|
const trailData = computed(() => {
|
|
if (props.phase === 'awaiting_uncapped_trail_advance') {
|
|
return props.data as UncappedTrailAdvanceData | null
|
|
}
|
|
return null
|
|
})
|
|
|
|
const targetData = computed(() => {
|
|
if (props.phase === 'awaiting_uncapped_throw_target') {
|
|
return props.data as UncappedThrowTargetData | null
|
|
}
|
|
return null
|
|
})
|
|
|
|
const safeOutData = computed(() => {
|
|
if (props.phase === 'awaiting_uncapped_safe_out') {
|
|
return props.data as UncappedSafeOutData | null
|
|
}
|
|
return null
|
|
})
|
|
|
|
// Hit type from phase data or pending state
|
|
const hitType = computed(() => {
|
|
if (leadData.value) return leadData.value.hit_type
|
|
return props.pendingUncappedHit?.hit_type ?? ''
|
|
})
|
|
|
|
const hitLocation = computed(() => {
|
|
const loc = leadData.value?.hit_location
|
|
?? throwData.value?.hit_location
|
|
?? trailData.value?.hit_location
|
|
?? targetData.value?.hit_location
|
|
?? safeOutData.value?.hit_location
|
|
?? props.pendingUncappedHit?.hit_location
|
|
return loc ?? ''
|
|
})
|
|
|
|
const hitTypeLabel = computed(() => {
|
|
const ht = hitType.value
|
|
if (ht === 'single' || ht === 'single_uncapped') return 'SINGLE UNCAPPED'
|
|
if (ht === 'double' || ht === 'double_uncapped') return 'DOUBLE UNCAPPED'
|
|
return 'UNCAPPED HIT'
|
|
})
|
|
|
|
const hitBadgeClass = computed(() => {
|
|
const ht = hitType.value
|
|
if (ht === 'single' || ht === 'single_uncapped') return 'badge-single'
|
|
return 'badge-double'
|
|
})
|
|
|
|
// Auto-scoring runners display (from phase 1 data or pending state)
|
|
const autoRunnersDisplay = computed(() => {
|
|
const autos = leadData.value?.auto_runners ?? props.pendingUncappedHit?.auto_runners ?? []
|
|
return autos.map((runner) => {
|
|
const from = runner[0]
|
|
const to = runner[1]
|
|
const lid = runner[2]
|
|
const player = lid != null ? gameStore.findPlayerInLineup(lid) : undefined
|
|
const name = player?.player?.name ?? `#${lid ?? '?'}`
|
|
if (to === 4) return `${name} scores automatically`
|
|
return `${name}: ${baseLabel(from)} → ${baseLabel(to)} automatically`
|
|
})
|
|
})
|
|
|
|
// Player name lookups
|
|
const leadRunnerName = computed(() => {
|
|
const lid = leadData.value?.lead_runner_lineup_id
|
|
if (!lid) return 'Runner'
|
|
const player = gameStore.findPlayerInLineup(lid)
|
|
return player?.player?.name ?? `#${lid}`
|
|
})
|
|
|
|
const leadRunnerNameFromThrow = computed(() => {
|
|
const lid = throwData.value?.lead_runner_lineup_id
|
|
if (!lid) return 'Runner'
|
|
const player = gameStore.findPlayerInLineup(lid)
|
|
return player?.player?.name ?? `#${lid}`
|
|
})
|
|
|
|
const trailRunnerName = computed(() => {
|
|
const lid = trailData.value?.trail_runner_lineup_id
|
|
if (!lid) return 'Runner'
|
|
const player = gameStore.findPlayerInLineup(lid)
|
|
return player?.player?.name ?? `#${lid}`
|
|
})
|
|
|
|
const leadRunnerNameFromTarget = computed(() => {
|
|
const lid = targetData.value?.lead_runner_lineup_id
|
|
if (!lid) return 'Lead Runner'
|
|
const player = gameStore.findPlayerInLineup(lid)
|
|
return player?.player?.name ?? `#${lid}`
|
|
})
|
|
|
|
const trailRunnerNameFromTarget = computed(() => {
|
|
const lid = targetData.value?.trail_runner_lineup_id
|
|
if (!lid) return 'Trail Runner'
|
|
const player = gameStore.findPlayerInLineup(lid)
|
|
return player?.player?.name ?? `#${lid}`
|
|
})
|
|
|
|
const safeOutRunnerName = computed(() => {
|
|
const lid = safeOutData.value?.runner_lineup_id
|
|
if (!lid) return 'Runner'
|
|
const player = gameStore.findPlayerInLineup(lid)
|
|
return player?.player?.name ?? `#${lid}`
|
|
})
|
|
|
|
// Base label helper
|
|
function baseLabel(base: number | undefined | null): string {
|
|
if (base === undefined || base === null) return '?'
|
|
if (base === 0) return 'Home'
|
|
if (base === 4) return 'Home'
|
|
return `${base}B`
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.uncapped-wizard {
|
|
@apply bg-white rounded-lg shadow-lg p-6 space-y-5 max-w-4xl mx-auto;
|
|
}
|
|
|
|
.uncapped-wizard.read-only {
|
|
@apply opacity-75;
|
|
}
|
|
|
|
/* Header */
|
|
.wizard-header {
|
|
@apply text-center pb-4 border-b border-gray-200 space-y-2;
|
|
}
|
|
|
|
.hit-badge {
|
|
@apply inline-block px-4 py-1 rounded-full text-sm font-bold uppercase tracking-wide;
|
|
}
|
|
|
|
.badge-single {
|
|
@apply bg-green-100 text-green-800;
|
|
}
|
|
|
|
.badge-double {
|
|
@apply bg-blue-100 text-blue-800;
|
|
}
|
|
|
|
.hit-location {
|
|
@apply text-sm text-gray-600 font-medium;
|
|
}
|
|
|
|
.waiting-message {
|
|
@apply mt-2 text-sm text-orange-600 font-medium;
|
|
}
|
|
|
|
/* Auto runners */
|
|
.auto-runners {
|
|
@apply bg-green-50 border border-green-200 rounded-lg p-3 space-y-1;
|
|
}
|
|
|
|
.auto-runner-line {
|
|
@apply flex items-center gap-2 text-sm text-green-800;
|
|
}
|
|
|
|
/* Phase content */
|
|
.phase-content {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
.phase-label {
|
|
@apply text-xs font-bold uppercase tracking-wider text-center py-1 rounded;
|
|
}
|
|
|
|
.offense-label {
|
|
@apply bg-blue-100 text-blue-800;
|
|
}
|
|
|
|
.defense-label {
|
|
@apply bg-red-100 text-red-800;
|
|
}
|
|
|
|
.phase-description {
|
|
@apply text-sm text-gray-600 text-center;
|
|
}
|
|
|
|
/* Runner info */
|
|
.runner-info {
|
|
@apply flex flex-col items-center gap-1;
|
|
}
|
|
|
|
.runner-name {
|
|
@apply text-lg font-bold text-gray-900;
|
|
}
|
|
|
|
.runner-movement {
|
|
@apply text-sm text-gray-600;
|
|
}
|
|
|
|
/* Decision buttons */
|
|
.decision-buttons {
|
|
@apply grid grid-cols-2 gap-3;
|
|
}
|
|
|
|
.decision-btn {
|
|
@apply px-6 py-4 font-bold text-lg rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
|
}
|
|
|
|
.advance-btn {
|
|
@apply bg-green-600 text-white hover:bg-green-700;
|
|
}
|
|
|
|
.hold-btn {
|
|
@apply bg-gray-300 text-gray-800 hover:bg-gray-400;
|
|
}
|
|
|
|
.throw-btn {
|
|
@apply bg-red-600 text-white hover:bg-red-700;
|
|
}
|
|
|
|
.letgo-btn {
|
|
@apply bg-gray-300 text-gray-800 hover:bg-gray-400;
|
|
}
|
|
|
|
.safe-btn {
|
|
@apply bg-green-600 text-white hover:bg-green-700;
|
|
}
|
|
|
|
.out-btn {
|
|
@apply bg-red-600 text-white hover:bg-red-700;
|
|
}
|
|
|
|
/* Target options (phase 4) */
|
|
.target-options {
|
|
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
|
|
}
|
|
|
|
.target-btn {
|
|
@apply flex flex-col items-center p-5 border-2 border-gray-300 rounded-lg hover:border-red-500 hover:bg-red-50 transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50;
|
|
}
|
|
|
|
.target-label {
|
|
@apply text-lg font-bold text-gray-900 mb-1;
|
|
}
|
|
|
|
.target-detail {
|
|
@apply text-sm text-gray-600;
|
|
}
|
|
|
|
/* D20 display (phase 5) */
|
|
.d20-display {
|
|
@apply text-center;
|
|
}
|
|
|
|
.d20-label {
|
|
@apply text-sm font-medium text-gray-600 mb-2;
|
|
}
|
|
|
|
.d20-value {
|
|
@apply text-4xl font-bold rounded-lg px-6 py-3 shadow-md bg-blue-100 text-blue-900 inline-block;
|
|
}
|
|
|
|
/* Mobile responsive */
|
|
@media (max-width: 640px) {
|
|
.uncapped-wizard {
|
|
@apply p-4 space-y-4;
|
|
}
|
|
|
|
.decision-buttons {
|
|
@apply grid-cols-1;
|
|
}
|
|
|
|
.decision-btn {
|
|
@apply w-full;
|
|
}
|
|
}
|
|
|
|
/* Dark mode */
|
|
@media (prefers-color-scheme: dark) {
|
|
.uncapped-wizard {
|
|
@apply bg-gray-800;
|
|
}
|
|
|
|
.wizard-header {
|
|
@apply border-gray-600;
|
|
}
|
|
|
|
.hit-location {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.auto-runners {
|
|
@apply bg-green-900/30 border-green-700;
|
|
}
|
|
|
|
.auto-runner-line {
|
|
@apply text-green-300;
|
|
}
|
|
|
|
.runner-name {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.runner-movement {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.phase-description {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.target-btn {
|
|
@apply border-gray-600 hover:border-red-400 hover:bg-red-900/30;
|
|
}
|
|
|
|
.target-label {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.target-detail {
|
|
@apply text-gray-400;
|
|
}
|
|
}
|
|
</style>
|