- OffensiveApproach: read game state from store (fix same prop-passing bug as DefensiveSetup), remove steal option (check_jump encompasses it), hide unavailable actions instead of disabling, fix conditions (sac/squeeze: <2 outs + runners, hit-and-run: R1/R3 not R2-only) - Remove all emoji icons from decision components (OffensiveApproach, DefensiveSetup, DecisionPanel) - RunnerCard: always show hold/not-held pills on occupied bases (status indicator in all phases) - DecisionPanel: remove dead hasRunnersOnBase computed and prop pass-through - Rewrite OffensiveApproach tests (32 new tests with Pinia store integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
208 lines
7.5 KiB
Vue
208 lines
7.5 KiB
Vue
<template>
|
|
<div
|
|
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4"
|
|
:class="isActive ? 'ring-2 ring-green-500/60 shadow-green-500/20' : ''"
|
|
>
|
|
<form class="space-y-3" @submit.prevent="handleSubmit">
|
|
<!-- Header row: icon + title -->
|
|
<div class="flex items-center">
|
|
<h3 class="text-base font-bold text-gray-900 dark:text-white">
|
|
Defense
|
|
</h3>
|
|
</div>
|
|
|
|
<!-- Infield depth: segmented control (only shows extra options when runner on 3rd) -->
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Infield</span>
|
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
|
<button
|
|
v-for="option in infieldOptions"
|
|
:key="option.value"
|
|
type="button"
|
|
:disabled="!isActive"
|
|
:class="segmentClasses(option.value === infieldDepth)"
|
|
class="flex-1 py-2 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@click="infieldDepth = option.value"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Outfield depth: only rendered when shallow option exists (walk-off scenario) -->
|
|
<div v-if="showOutfieldRow" class="flex items-center gap-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Outfield</span>
|
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
|
<button
|
|
v-for="option in outfieldOptions"
|
|
:key="option.value"
|
|
type="button"
|
|
:disabled="!isActive"
|
|
:class="segmentClasses(option.value === outfieldDepth)"
|
|
class="flex-1 py-2 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@click="outfieldDepth = option.value"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hold runners: pill toggles for occupied bases only -->
|
|
<div v-if="occupiedBases.length > 0" class="flex items-center gap-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Hold</span>
|
|
<div class="flex gap-1.5">
|
|
<button
|
|
v-for="base in occupiedBases"
|
|
:key="base"
|
|
type="button"
|
|
:disabled="!isActive"
|
|
:class="holdPillClasses(isHeld(base))"
|
|
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@click="toggleHold(base)"
|
|
>
|
|
{{ baseLabel(base) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirm button: full-width, prominent (matches Roll Dice style) -->
|
|
<div class="flex justify-center">
|
|
<button
|
|
type="submit"
|
|
:disabled="!isActive || submitting"
|
|
:class="[
|
|
'px-8 py-4 rounded-lg font-bold text-lg transition-all duration-200 shadow-lg min-h-[60px] min-w-[200px] w-full',
|
|
isActive && !submitting
|
|
? 'bg-gradient-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 hover:shadow-xl active:scale-95'
|
|
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
|
]"
|
|
>
|
|
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
|
<svg class="animate-spin h-5 w-5" 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>
|
|
Confirming...
|
|
</span>
|
|
<span v-else>Confirm Defense</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import type { DefensiveDecision } from '~/types/game'
|
|
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
|
|
import { useGameStore } from '~/store/game'
|
|
|
|
interface Props {
|
|
gameId: string
|
|
isActive: boolean
|
|
currentSetup?: DefensiveDecision
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
isActive: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
submit: [setup: DefensiveDecision]
|
|
}>()
|
|
|
|
const gameStore = useGameStore()
|
|
const { infieldDepth, outfieldDepth, holdRunnersArray, isHeld, toggleHold, getDecision, syncFromDecision } = useDefensiveSetup()
|
|
|
|
// Local state
|
|
const submitting = ref(false)
|
|
|
|
// Read game state from store instead of prop (fixes bug where gameState was never passed)
|
|
const storeGameState = computed(() => gameStore.gameState)
|
|
|
|
// Determine which bases are occupied
|
|
const occupiedBases = computed<number[]>(() => {
|
|
const gs = storeGameState.value
|
|
if (!gs) return []
|
|
const bases: number[] = []
|
|
if (gs.on_first) bases.push(1)
|
|
if (gs.on_second) bases.push(2)
|
|
if (gs.on_third) bases.push(3)
|
|
return bases
|
|
})
|
|
|
|
// Infield options: always show Normal; add IF In + Corners when runner on 3rd
|
|
const infieldOptions = computed(() => {
|
|
const options = [{ value: 'normal' as const, label: 'Normal' }]
|
|
if (storeGameState.value?.on_third) {
|
|
options.push({ value: 'infield_in' as const, label: 'IF In' })
|
|
options.push({ value: 'corners_in' as const, label: 'Corners' })
|
|
}
|
|
return options
|
|
})
|
|
|
|
// Outfield options: only show row when shallow is available (walk-off scenario)
|
|
const isWalkOffScenario = computed(() => {
|
|
const gs = storeGameState.value
|
|
if (!gs) return false
|
|
const isHomeBatting = gs.half === 'bottom'
|
|
const isLateInning = gs.inning >= 9
|
|
const isCloseGame = isHomeBatting
|
|
? gs.home_score <= gs.away_score
|
|
: gs.away_score <= gs.home_score
|
|
const hasRunners = gs.on_first || gs.on_second || gs.on_third
|
|
return isHomeBatting && isLateInning && isCloseGame && !!hasRunners
|
|
})
|
|
|
|
const showOutfieldRow = computed(() => isWalkOffScenario.value)
|
|
|
|
const outfieldOptions = computed(() => {
|
|
const options = [{ value: 'normal' as const, label: 'Normal' }]
|
|
if (isWalkOffScenario.value) {
|
|
options.push({ value: 'shallow' as const, label: 'Shallow' })
|
|
}
|
|
return options
|
|
})
|
|
|
|
// Style helpers
|
|
const segmentClasses = (selected: boolean) => {
|
|
if (selected) {
|
|
return 'bg-gradient-to-r from-primary to-blue-600 text-white border-r border-blue-600 last:border-r-0'
|
|
}
|
|
return 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 border-r border-gray-300 dark:border-gray-600 last:border-r-0'
|
|
}
|
|
|
|
const holdPillClasses = (held: boolean) => {
|
|
if (held) {
|
|
return 'border-blue-500 bg-gradient-to-r from-primary to-blue-600 text-white'
|
|
}
|
|
return 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
|
}
|
|
|
|
const baseLabel = (base: number) => {
|
|
if (base === 1) return '1B'
|
|
if (base === 2) return '2B'
|
|
if (base === 3) return '3B'
|
|
return `${base}B`
|
|
}
|
|
|
|
// Handle form submission
|
|
const handleSubmit = async () => {
|
|
if (!props.isActive) return
|
|
|
|
submitting.value = true
|
|
try {
|
|
emit('submit', getDecision())
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// Sync composable state from prop when it changes (e.g. server-confirmed state)
|
|
watch(() => props.currentSetup, (newSetup) => {
|
|
if (newSetup) {
|
|
syncFromDecision(newSetup)
|
|
}
|
|
}, { deep: true })
|
|
</script>
|