strat-gameplay-webapp/frontend-sba/components/Decisions/OffensiveApproach.vue
Cal Corum 4bdadeca07 CLAUDE: Refactor offensive decisions - replace approach with action field
Frontend refactor complete - updated TypeScript interfaces and OffensiveApproach
component to use new action-based system with smart filtering.

Changes:
- TypeScript interfaces: Replaced approach/hit_and_run/bunt_attempt with action field
- OffensiveApproach.vue: Complete refactor with 6 action choices and smart filtering
- Smart filtering: Automatically disables invalid actions based on game state
- Auto-reset: If current action becomes invalid, resets to swing_away

TypeScript updates (types/game.ts, types/websocket.ts):
- OffensiveDecision.action: 6 valid choices (swing_away, steal, check_jump,
  hit_and_run, sac_bunt, squeeze_bunt)
- Removed deprecated fields: approach, hit_and_run, bunt_attempt
- OffensiveDecisionRequest updated to match

Component features:
- Smart filtering based on game state (runners, outs)
- Visual feedback for disabled actions with explanatory text
- Special handling notes for steal and squeeze_bunt
- Auto-reset to swing_away when actions become invalid
- Clean, modern UI with action icons and descriptions

Action requirements enforced in UI:
- check_jump: requires runner on base
- hit_and_run: requires runner on base
- sac_bunt: disabled with 2 outs
- squeeze_bunt: requires R3, disabled with 2 outs
- steal/swing_away: always available

Files modified:
- types/game.ts
- types/websocket.ts
- components/Decisions/OffensiveApproach.vue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 15:13:34 -06:00

270 lines
8.2 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 flex items-center gap-2">
<span class="text-2xl"></span>
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 @submit.prevent="handleSubmit" class="space-y-6">
<!-- 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">
<button
v-for="option in availableActions"
:key="option.value"
type="button"
:disabled="!isActive || option.disabled"
:class="getActionButtonClasses(option.value, option.disabled)"
@click="selectAction(option.value)"
:title="option.disabledReason"
>
<div class="flex items-start gap-3">
<span class="text-2xl flex-shrink-0">{{ option.icon }}</span>
<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.disabled ? option.disabledReason : 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>
<!-- Current Strategy Summary -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Current Strategy
</h4>
<div class="space-y-1 text-xs">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Action:</span>
<span class="ml-1 text-gray-900 dark:text-white">{{ currentActionLabel }}</span>
</div>
<div v-if="actionRequiresSpecialHandling" class="mt-2 p-2 bg-yellow-100 dark:bg-yellow-900/30 rounded">
<span class="font-medium text-yellow-800 dark:text-yellow-200">{{ specialHandlingNote }}</span>
</div>
</div>
</div>
<!-- Submit Button -->
<ActionButton
type="submit"
variant="success"
size="lg"
:disabled="!isActive || !hasChanges"
: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'
interface ActionOption {
value: OffensiveDecision['action']
label: string
icon: string
description: string
disabled: boolean
disabledReason?: string
}
interface Props {
gameId: string
isActive: boolean
currentDecision?: Omit<OffensiveDecision, 'steal_attempts'>
hasRunnersOnBase?: boolean
runnerOnFirst?: boolean
runnerOnSecond?: boolean
runnerOnThird?: boolean
basesLoaded?: boolean
outs?: number
}
const props = withDefaults(defineProps<Props>(), {
isActive: false,
hasRunnersOnBase: false,
runnerOnFirst: false,
runnerOnSecond: false,
runnerOnThird: false,
basesLoaded: false,
outs: 0,
})
const emit = defineEmits<{
submit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
}>()
// Local state
const submitting = ref(false)
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
action: props.currentDecision?.action || 'swing_away',
})
// Action options with smart filtering
const availableActions = computed<ActionOption[]>(() => {
const twoOuts = props.outs >= 2
return [
{
value: 'swing_away',
label: 'Swing Away',
icon: '⚾',
description: 'Normal swing, no special tactics',
disabled: false,
},
{
value: 'steal',
label: 'Steal',
icon: '🏃',
description: 'Attempt to steal base(s) - configure on steal inputs tab',
disabled: false,
},
{
value: 'check_jump',
label: 'Check Jump',
icon: '👀',
description: 'Lead runner checks jump at start of delivery',
disabled: !props.hasRunnersOnBase,
disabledReason: 'Requires runner on base',
},
{
value: 'hit_and_run',
label: 'Hit and Run',
icon: '💨',
description: 'Runner(s) take off as pitcher delivers; batter must make contact',
disabled: !props.hasRunnersOnBase,
disabledReason: 'Requires runner on base',
},
{
value: 'sac_bunt',
label: 'Sacrifice Bunt',
icon: '🎯',
description: 'Bunt to advance runners, batter likely out',
disabled: twoOuts,
disabledReason: twoOuts ? 'Cannot bunt with 2 outs' : undefined,
},
{
value: 'squeeze_bunt',
label: 'Squeeze Bunt',
icon: '🔥',
description: 'Runner on 3rd breaks for home as pitcher delivers',
disabled: !props.runnerOnThird || twoOuts,
disabledReason: twoOuts
? 'Cannot squeeze with 2 outs'
: !props.runnerOnThird
? 'Requires runner on third'
: undefined,
},
]
})
// Computed
const currentActionLabel = computed(() => {
const option = availableActions.value.find(opt => opt.value === localDecision.value.action)
return option?.label || 'Swing Away'
})
const actionRequiresSpecialHandling = computed(() => {
return localDecision.value.action === 'steal' || localDecision.value.action === 'squeeze_bunt'
})
const specialHandlingNote = computed(() => {
if (localDecision.value.action === 'steal') {
return 'Configure which bases to steal on the Stolen Base Inputs tab'
}
if (localDecision.value.action === 'squeeze_bunt') {
return 'R3 will break for home as pitcher delivers - high risk, high reward!'
}
return ''
})
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 'No Changes'
return 'Submit Offensive Strategy'
})
// Methods
const selectAction = (action: OffensiveDecision['action']) => {
if (!props.isActive) return
// Check if action is disabled
const option = availableActions.value.find(opt => opt.value === action)
if (option?.disabled) return
localDecision.value.action = action
}
const getActionButtonClasses = (action: OffensiveDecision['action'], disabled: boolean) => {
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 (disabled) {
return `${base} bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border-gray-200 dark:border-gray-700 opacity-60`
}
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 || !hasChanges.value) 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 becomes invalid
watch(() => availableActions.value, (actions) => {
const currentAction = localDecision.value.action
const currentOption = actions.find(opt => opt.value === currentAction)
if (currentOption?.disabled) {
localDecision.value.action = 'swing_away'
}
}, { deep: true })
</script>