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">
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<span class="text-2xl"></span>
Offensive Approach
Offensive Action
</h3>
<span
v-if="!isActive"
@ -16,27 +16,30 @@
<!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Batting Approach -->
<!-- Action Selection -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Batting Approach
Select Action
</label>
<div class="grid grid-cols-1 gap-3">
<button
v-for="option in approachOptions"
v-for="option in availableActions"
:key="option.value"
type="button"
:disabled="!isActive"
:class="getApproachButtonClasses(option.value)"
@click="selectApproach(option.value)"
: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.description }}</div>
<div class="text-sm opacity-90 mt-0.5">
{{ option.disabled ? option.disabledReason : option.description }}
</div>
<div v-if="localDecision.approach === option.value" class="flex-shrink-0">
</div>
<div v-if="localDecision.action === option.value" class="flex-shrink-0">
<span class="text-xl"></span>
</div>
</div>
@ -44,40 +47,6 @@
</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 -->
<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">
@ -85,14 +54,11 @@
</h4>
<div class="space-y-1 text-xs">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Approach:</span>
<span class="ml-1 text-gray-900 dark:text-white">{{ currentApproachLabel }}</span>
<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="localDecision.hit_and_run || localDecision.bunt_attempt">
<span class="font-medium text-gray-600 dark:text-gray-400">Tactics:</span>
<span class="ml-1 text-gray-900 dark:text-white">
{{ activeTactics }}
</span>
<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>
@ -115,14 +81,15 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { OffensiveDecision } from '~/types/game'
import ToggleSwitch from '~/components/UI/ToggleSwitch.vue'
import ActionButton from '~/components/UI/ActionButton.vue'
interface ApproachOption {
value: OffensiveDecision['approach']
interface ActionOption {
value: OffensiveDecision['action']
label: string
icon: string
description: string
disabled: boolean
disabledReason?: string
}
interface Props {
@ -130,11 +97,21 @@ interface Props {
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<{
@ -144,63 +121,90 @@ const emit = defineEmits<{
// Local state
const submitting = ref(false)
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
approach: props.currentDecision?.approach || 'normal',
hit_and_run: props.currentDecision?.hit_and_run || false,
bunt_attempt: props.currentDecision?.bunt_attempt || false,
action: props.currentDecision?.action || 'swing_away',
})
// Approach options
const approachOptions: ApproachOption[] = [
// Action options with smart filtering
const availableActions = computed<ActionOption[]>(() => {
const twoOuts = props.outs >= 2
return [
{
value: 'normal',
label: 'Normal',
value: 'swing_away',
label: 'Swing Away',
icon: '⚾',
description: 'Balanced approach, no specific tendencies',
description: 'Normal swing, no special tactics',
disabled: false,
},
{
value: 'contact',
label: 'Contact',
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: 'Focus on making contact, avoid strikeouts',
description: 'Bunt to advance runners, batter likely out',
disabled: twoOuts,
disabledReason: twoOuts ? 'Cannot bunt with 2 outs' : undefined,
},
{
value: 'power',
label: 'Power',
icon: '💪',
description: 'Swing for extra bases, higher strikeout risk',
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,
},
{
value: 'patient',
label: 'Patient',
icon: '🧘',
description: 'Work the count, draw walks, wait for your pitch',
},
]
]
})
// Computed
const canUseHitAndRun = computed(() => {
return props.hasRunnersOnBase
const currentActionLabel = computed(() => {
const option = availableActions.value.find(opt => opt.value === localDecision.value.action)
return option?.label || 'Swing Away'
})
const currentApproachLabel = computed(() => {
const option = approachOptions.find(opt => opt.value === localDecision.value.approach)
return option?.label || 'Normal'
const actionRequiresSpecialHandling = computed(() => {
return localDecision.value.action === 'steal' || localDecision.value.action === 'squeeze_bunt'
})
const activeTactics = computed(() => {
const tactics: string[] = []
if (localDecision.value.hit_and_run) tactics.push('Hit & Run')
if (localDecision.value.bunt_attempt) tactics.push('Bunt')
return tactics.join(', ') || 'None'
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.approach !== props.currentDecision.approach ||
localDecision.value.hit_and_run !== props.currentDecision.hit_and_run ||
localDecision.value.bunt_attempt !== props.currentDecision.bunt_attempt
)
return localDecision.value.action !== props.currentDecision.action
})
const submitButtonText = computed(() => {
@ -210,14 +214,23 @@ const submitButtonText = computed(() => {
})
// Methods
const selectApproach = (approach: OffensiveDecision['approach']) => {
const selectAction = (action: OffensiveDecision['action']) => {
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 isSelected = localDecision.value.approach === approach
const base = 'w-full p-4 rounded-lg border-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed'
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`
@ -244,10 +257,13 @@ watch(() => props.currentDecision, (newDecision) => {
}
}, { deep: true })
// Disable hit and run if no runners
watch(() => props.hasRunnersOnBase, (hasRunners) => {
if (!hasRunners) {
localDecision.value.hit_and_run = false
// 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>

View File

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

View File

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