strat-gameplay-webapp/frontend-sba/components/Gameplay/UncappedHitWizard.vue
Cal Corum fa3fadd14c CLAUDE: Implement uncapped hit decision UI + backend bugfixes (Issue #7)
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>
2026-02-12 13:54:57 -06:00

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) }} &rarr; {{ 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) }} &rarr; {{ 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 }} &rarr; {{ 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 }} &rarr; {{ 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 &rarr; {{ 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>