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>
This commit is contained in:
Cal Corum 2025-11-14 15:13:34 -06:00
parent b0d79ef7ef
commit 4bdadeca07
3 changed files with 129 additions and 115 deletions

View File

@ -4,7 +4,7 @@
<div class="flex items-center justify-between mb-6"> <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"> <h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<span class="text-2xl"></span> <span class="text-2xl"></span>
Offensive Approach Offensive Action
</h3> </h3>
<span <span
v-if="!isActive" v-if="!isActive"
@ -16,27 +16,30 @@
<!-- Form --> <!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-6"> <form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Batting Approach --> <!-- Action Selection -->
<div> <div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> <label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Batting Approach Select Action
</label> </label>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<button <button
v-for="option in approachOptions" v-for="option in availableActions"
:key="option.value" :key="option.value"
type="button" type="button"
:disabled="!isActive" :disabled="!isActive || option.disabled"
:class="getApproachButtonClasses(option.value)" :class="getActionButtonClasses(option.value, option.disabled)"
@click="selectApproach(option.value)" @click="selectAction(option.value)"
:title="option.disabledReason"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="text-2xl flex-shrink-0">{{ option.icon }}</span> <span class="text-2xl flex-shrink-0">{{ option.icon }}</span>
<div class="flex-1 text-left"> <div class="flex-1 text-left">
<div class="font-semibold text-base">{{ option.label }}</div> <div class="font-semibold text-base">{{ option.label }}</div>
<div class="text-sm opacity-90 mt-0.5">{{ option.description }}</div> <div class="text-sm opacity-90 mt-0.5">
{{ option.disabled ? option.disabledReason : option.description }}
</div>
</div> </div>
<div v-if="localDecision.approach === option.value" class="flex-shrink-0"> <div v-if="localDecision.action === option.value" class="flex-shrink-0">
<span class="text-xl"></span> <span class="text-xl"></span>
</div> </div>
</div> </div>
@ -44,40 +47,6 @@
</div> </div>
</div> </div>
<!-- Special Tactics -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Special Tactics
</label>
<div class="space-y-3">
<!-- Hit and Run -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<ToggleSwitch
v-model="localDecision.hit_and_run"
label="Hit and Run"
:disabled="!isActive || !canUseHitAndRun"
size="md"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 ml-14">
Runner(s) take off as pitcher delivers; batter must make contact
</p>
</div>
<!-- Bunt Attempt -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<ToggleSwitch
v-model="localDecision.bunt_attempt"
label="Bunt Attempt"
:disabled="!isActive"
size="md"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 ml-14">
Attempt to bunt for a hit or sacrifice
</p>
</div>
</div>
</div>
<!-- Current Strategy Summary --> <!-- 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"> <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"> <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
@ -85,14 +54,11 @@
</h4> </h4>
<div class="space-y-1 text-xs"> <div class="space-y-1 text-xs">
<div> <div>
<span class="font-medium text-gray-600 dark:text-gray-400">Approach:</span> <span class="font-medium text-gray-600 dark:text-gray-400">Action:</span>
<span class="ml-1 text-gray-900 dark:text-white">{{ currentApproachLabel }}</span> <span class="ml-1 text-gray-900 dark:text-white">{{ currentActionLabel }}</span>
</div> </div>
<div v-if="localDecision.hit_and_run || localDecision.bunt_attempt"> <div v-if="actionRequiresSpecialHandling" class="mt-2 p-2 bg-yellow-100 dark:bg-yellow-900/30 rounded">
<span class="font-medium text-gray-600 dark:text-gray-400">Tactics:</span> <span class="font-medium text-yellow-800 dark:text-yellow-200">{{ specialHandlingNote }}</span>
<span class="ml-1 text-gray-900 dark:text-white">
{{ activeTactics }}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -115,14 +81,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { OffensiveDecision } from '~/types/game' import type { OffensiveDecision } from '~/types/game'
import ToggleSwitch from '~/components/UI/ToggleSwitch.vue'
import ActionButton from '~/components/UI/ActionButton.vue' import ActionButton from '~/components/UI/ActionButton.vue'
interface ApproachOption { interface ActionOption {
value: OffensiveDecision['approach'] value: OffensiveDecision['action']
label: string label: string
icon: string icon: string
description: string description: string
disabled: boolean
disabledReason?: string
} }
interface Props { interface Props {
@ -130,11 +97,21 @@ interface Props {
isActive: boolean isActive: boolean
currentDecision?: Omit<OffensiveDecision, 'steal_attempts'> currentDecision?: Omit<OffensiveDecision, 'steal_attempts'>
hasRunnersOnBase?: boolean hasRunnersOnBase?: boolean
runnerOnFirst?: boolean
runnerOnSecond?: boolean
runnerOnThird?: boolean
basesLoaded?: boolean
outs?: number
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isActive: false, isActive: false,
hasRunnersOnBase: false, hasRunnersOnBase: false,
runnerOnFirst: false,
runnerOnSecond: false,
runnerOnThird: false,
basesLoaded: false,
outs: 0,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -144,63 +121,90 @@ const emit = defineEmits<{
// Local state // Local state
const submitting = ref(false) const submitting = ref(false)
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({ const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
approach: props.currentDecision?.approach || 'normal', action: props.currentDecision?.action || 'swing_away',
hit_and_run: props.currentDecision?.hit_and_run || false,
bunt_attempt: props.currentDecision?.bunt_attempt || false,
}) })
// Approach options // Action options with smart filtering
const approachOptions: ApproachOption[] = [ const availableActions = computed<ActionOption[]>(() => {
{ const twoOuts = props.outs >= 2
value: 'normal',
label: 'Normal', return [
icon: '⚾', {
description: 'Balanced approach, no specific tendencies', value: 'swing_away',
}, label: 'Swing Away',
{ icon: '⚾',
value: 'contact', description: 'Normal swing, no special tactics',
label: 'Contact', disabled: false,
icon: '🎯', },
description: 'Focus on making contact, avoid strikeouts', {
}, value: 'steal',
{ label: 'Steal',
value: 'power', icon: '🏃',
label: 'Power', description: 'Attempt to steal base(s) - configure on steal inputs tab',
icon: '💪', disabled: false,
description: 'Swing for extra bases, higher strikeout risk', },
}, {
{ value: 'check_jump',
value: 'patient', label: 'Check Jump',
label: 'Patient', icon: '👀',
icon: '🧘', description: 'Lead runner checks jump at start of delivery',
description: 'Work the count, draw walks, wait for your pitch', 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 // Computed
const canUseHitAndRun = computed(() => { const currentActionLabel = computed(() => {
return props.hasRunnersOnBase const option = availableActions.value.find(opt => opt.value === localDecision.value.action)
return option?.label || 'Swing Away'
}) })
const currentApproachLabel = computed(() => { const actionRequiresSpecialHandling = computed(() => {
const option = approachOptions.find(opt => opt.value === localDecision.value.approach) return localDecision.value.action === 'steal' || localDecision.value.action === 'squeeze_bunt'
return option?.label || 'Normal'
}) })
const activeTactics = computed(() => { const specialHandlingNote = computed(() => {
const tactics: string[] = [] if (localDecision.value.action === 'steal') {
if (localDecision.value.hit_and_run) tactics.push('Hit & Run') return 'Configure which bases to steal on the Stolen Base Inputs tab'
if (localDecision.value.bunt_attempt) tactics.push('Bunt') }
return tactics.join(', ') || 'None' if (localDecision.value.action === 'squeeze_bunt') {
return 'R3 will break for home as pitcher delivers - high risk, high reward!'
}
return ''
}) })
const hasChanges = computed(() => { const hasChanges = computed(() => {
if (!props.currentDecision) return true if (!props.currentDecision) return true
return ( return localDecision.value.action !== props.currentDecision.action
localDecision.value.approach !== props.currentDecision.approach ||
localDecision.value.hit_and_run !== props.currentDecision.hit_and_run ||
localDecision.value.bunt_attempt !== props.currentDecision.bunt_attempt
)
}) })
const submitButtonText = computed(() => { const submitButtonText = computed(() => {
@ -210,14 +214,23 @@ const submitButtonText = computed(() => {
}) })
// Methods // Methods
const selectApproach = (approach: OffensiveDecision['approach']) => { const selectAction = (action: OffensiveDecision['action']) => {
if (!props.isActive) return if (!props.isActive) return
localDecision.value.approach = approach
// Check if action is disabled
const option = availableActions.value.find(opt => opt.value === action)
if (option?.disabled) return
localDecision.value.action = action
} }
const getApproachButtonClasses = (approach: OffensiveDecision['approach']) => { const getActionButtonClasses = (action: OffensiveDecision['action'], disabled: boolean) => {
const isSelected = localDecision.value.approach === approach const isSelected = localDecision.value.action === action
const base = 'w-full p-4 rounded-lg border-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed' 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) { if (isSelected) {
return `${base} bg-gradient-to-r from-blue-600 to-blue-700 text-white border-blue-700 shadow-lg` return `${base} bg-gradient-to-r from-blue-600 to-blue-700 text-white border-blue-700 shadow-lg`
@ -244,10 +257,13 @@ watch(() => props.currentDecision, (newDecision) => {
} }
}, { deep: true }) }, { deep: true })
// Disable hit and run if no runners // Auto-reset to swing_away if current action becomes invalid
watch(() => props.hasRunnersOnBase, (hasRunners) => { watch(() => availableActions.value, (actions) => {
if (!hasRunners) { const currentAction = localDecision.value.action
localDecision.value.hit_and_run = false const currentOption = actions.find(opt => opt.value === currentAction)
if (currentOption?.disabled) {
localDecision.value.action = 'swing_away'
} }
}) }, { deep: true })
</script> </script>

View File

@ -130,12 +130,12 @@ export interface DefensiveDecision {
/** /**
* Offensive strategic decision * Offensive strategic decision
* Backend: OffensiveDecision * Backend: OffensiveDecision
*
* Session 2 Update (2025-01-14): Replaced approach/hit_and_run/bunt_attempt with action field.
*/ */
export interface OffensiveDecision { export interface OffensiveDecision {
approach: 'normal' | 'contact' | 'power' | 'patient' action: 'swing_away' | 'steal' | 'check_jump' | 'hit_and_run' | 'sac_bunt' | 'squeeze_bunt'
steal_attempts: number[] // Bases to steal (2, 3, or 4) steal_attempts: number[] // Bases to steal (2, 3, or 4) - only used when action="steal"
hit_and_run: boolean
bunt_attempt: boolean
} }
/** /**

View File

@ -125,10 +125,8 @@ export interface DefensiveDecisionRequest {
export interface OffensiveDecisionRequest { export interface OffensiveDecisionRequest {
game_id: string game_id: string
approach: OffensiveDecision['approach'] action: OffensiveDecision['action']
steal_attempts: number[] steal_attempts: number[]
hit_and_run: boolean
bunt_attempt: boolean
} }
export interface RollDiceRequest { export interface RollDiceRequest {