- 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>
216 lines
6.2 KiB
Vue
216 lines
6.2 KiB
Vue
<template>
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
|
|
Offensive Action
|
|
</h3>
|
|
<span
|
|
v-if="!isActive"
|
|
class="px-3 py-1 text-xs font-semibold rounded-full bg-gray-200 text-gray-600"
|
|
>
|
|
Opponent's Turn
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Form -->
|
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
|
<!-- Action Selection -->
|
|
<div>
|
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
|
Select Action
|
|
</label>
|
|
<div class="grid grid-cols-1 gap-3 select-none">
|
|
<button
|
|
v-for="option in availableActions"
|
|
:key="option.value"
|
|
type="button"
|
|
:disabled="!isActive"
|
|
:class="getActionButtonClasses(option.value)"
|
|
class="touch-manipulation"
|
|
@click="selectAction(option.value)"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex-1 text-left">
|
|
<div class="font-semibold text-base">{{ option.label }}</div>
|
|
<div class="text-sm opacity-90 mt-0.5">
|
|
{{ option.description }}
|
|
</div>
|
|
</div>
|
|
<div v-if="localDecision.action === option.value" class="flex-shrink-0">
|
|
<span class="text-xl">✓</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<ActionButton
|
|
type="submit"
|
|
variant="success"
|
|
size="lg"
|
|
:disabled="!isActive"
|
|
:loading="submitting"
|
|
full-width
|
|
>
|
|
{{ submitButtonText }}
|
|
</ActionButton>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import type { OffensiveDecision } from '~/types/game'
|
|
import ActionButton from '~/components/UI/ActionButton.vue'
|
|
import { useGameStore } from '~/store/game'
|
|
|
|
interface ActionOption {
|
|
value: OffensiveDecision['action']
|
|
label: string
|
|
description: string
|
|
}
|
|
|
|
interface Props {
|
|
gameId: string
|
|
isActive: boolean
|
|
currentDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
isActive: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
submit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
|
}>()
|
|
|
|
const gameStore = useGameStore()
|
|
|
|
// Local state
|
|
const submitting = ref(false)
|
|
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
|
|
action: props.currentDecision?.action || 'swing_away',
|
|
})
|
|
|
|
// Read game state from store (fixes bug where props were never passed from DecisionPanel)
|
|
const storeGameState = computed(() => gameStore.gameState)
|
|
|
|
const hasRunnersOnBase = computed(() => {
|
|
const gs = storeGameState.value
|
|
if (!gs) return false
|
|
return !!(gs.on_first || gs.on_second || gs.on_third)
|
|
})
|
|
|
|
const runnerOnFirst = computed(() => !!storeGameState.value?.on_first)
|
|
const runnerOnThird = computed(() => !!storeGameState.value?.on_third)
|
|
const outs = computed(() => storeGameState.value?.outs ?? 0)
|
|
|
|
// Action options: only include actions whose conditions are met (hidden, not disabled)
|
|
const availableActions = computed<ActionOption[]>(() => {
|
|
const actions: ActionOption[] = [
|
|
{
|
|
value: 'swing_away',
|
|
label: 'Swing Away',
|
|
description: 'Normal swing, no special tactics',
|
|
},
|
|
]
|
|
|
|
// Check jump: requires any runner on base
|
|
if (hasRunnersOnBase.value) {
|
|
actions.push({
|
|
value: 'check_jump',
|
|
label: 'Check Jump',
|
|
description: 'Lead runner checks jump at start of delivery',
|
|
})
|
|
}
|
|
|
|
// Hit and run: requires runner on first and/or third (NOT second only)
|
|
if (runnerOnFirst.value || runnerOnThird.value) {
|
|
actions.push({
|
|
value: 'hit_and_run',
|
|
label: 'Hit and Run',
|
|
description: 'Runner(s) take off as pitcher delivers; batter must make contact',
|
|
})
|
|
}
|
|
|
|
// Sac bunt: requires < 2 outs AND runners on base
|
|
if (outs.value < 2 && hasRunnersOnBase.value) {
|
|
actions.push({
|
|
value: 'sac_bunt',
|
|
label: 'Sacrifice Bunt',
|
|
description: 'Bunt to advance runners, batter likely out',
|
|
})
|
|
}
|
|
|
|
// Squeeze bunt: requires < 2 outs AND runner on third
|
|
if (outs.value < 2 && runnerOnThird.value) {
|
|
actions.push({
|
|
value: 'squeeze_bunt',
|
|
label: 'Squeeze Bunt',
|
|
description: 'Runner on 3rd breaks for home as pitcher delivers',
|
|
})
|
|
}
|
|
|
|
return actions
|
|
})
|
|
|
|
// Computed
|
|
const hasChanges = computed(() => {
|
|
if (!props.currentDecision) return true
|
|
return localDecision.value.action !== props.currentDecision.action
|
|
})
|
|
|
|
const submitButtonText = computed(() => {
|
|
if (!props.isActive) return 'Wait for Your Turn'
|
|
if (!hasChanges.value) return 'Submit (Keep Action)'
|
|
return 'Submit Offensive Strategy'
|
|
})
|
|
|
|
// Methods
|
|
const selectAction = (action: OffensiveDecision['action']) => {
|
|
if (!props.isActive) return
|
|
localDecision.value.action = action
|
|
}
|
|
|
|
const getActionButtonClasses = (action: OffensiveDecision['action']) => {
|
|
const isSelected = localDecision.value.action === action
|
|
const base = 'w-full p-4 rounded-lg border-2 transition-all duration-200 disabled:cursor-not-allowed'
|
|
|
|
if (isSelected) {
|
|
return `${base} bg-gradient-to-r from-blue-600 to-blue-700 text-white border-blue-700 shadow-lg`
|
|
} else {
|
|
return `${base} bg-white dark:bg-gray-700 text-gray-900 dark:text-white border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400`
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!props.isActive) return
|
|
|
|
submitting.value = true
|
|
try {
|
|
emit('submit', { ...localDecision.value })
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// Watch for prop changes
|
|
watch(() => props.currentDecision, (newDecision) => {
|
|
if (newDecision) {
|
|
localDecision.value = { ...newDecision }
|
|
}
|
|
}, { deep: true })
|
|
|
|
// Auto-reset to swing_away if current action is no longer available
|
|
watch(() => availableActions.value, (actions) => {
|
|
const currentAction = localDecision.value.action
|
|
const stillAvailable = actions.some(opt => opt.value === currentAction)
|
|
|
|
if (!stillAvailable) {
|
|
localDecision.value.action = 'swing_away'
|
|
}
|
|
}, { deep: true })
|
|
</script>
|