Terminal Client Enhancements: - Added list_outcomes command to display all PlayOutcome values - Added resolve_with <outcome> command for testing specific scenarios - TAB completion for all outcome names - Full help documentation and examples - Infrastructure ready for Week 7 integration Files Modified: - terminal_client/commands.py - list_outcomes() and forced outcome support - terminal_client/repl.py - do_list_outcomes() and do_resolve_with() commands - terminal_client/completions.py - VALID_OUTCOMES and complete_resolve_with() - terminal_client/help_text.py - Help entries for new commands Phase 3 Planning: - Created comprehensive Week 7 implementation plan (25 pages) - 6 major tasks covering strategic decisions and result charts - Updated 00-index.md to mark Week 6 as 100% complete - Documented manual outcome testing feature Week 6: 100% Complete ✅ Phase 3 Week 7: Ready to begin 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
41 KiB
Week 7 Implementation Plan - Strategic Decisions & Result Charts
Phase: Phase 3 - Complete Game Features Duration: Week 7 (Est. 20-25 hours) Status: Not Started Prerequisites: Week 6 Complete (PlayOutcome enum, League configs, Player models)
Overview
Week 7 focuses on implementing the complete strategic decision system and full result charts for both leagues. This transforms the game from a simplified simulator into a feature-complete baseball strategy game.
Goals
By end of Week 7, you should have:
- ✅ All strategic decisions integrated into game flow
- ✅ Decision validation against game state
- ✅ Complete result charts with situational outcomes
- ✅ Hit location and runner advancement logic
- ✅ Double play mechanics for GROUNDBALL_A outcomes
- ✅ Uncapped hit decision trees (SINGLE_UNCAPPED, DOUBLE_UNCAPPED)
- ✅ WebSocket handlers for decision submission
- ✅ Terminal client support for all decision types
Architecture Overview
Current State (Week 6 Complete)
Already Implemented:
DefensiveDecisionmodel with validators (game_models.py:123-160)OffensiveDecisionmodel with validators (game_models.py:162-190)PlayOutcomeenum with 30+ granular variants (result_charts.py)- League configs (SbaConfig, PdConfig) with immutable settings
- Play model supports strategic decisions JSON (defensive_choices, offensive_choices)
- GameState tracks current batter/pitcher lineup IDs
What's Missing:
- Integration of decisions into play resolution
- Decision validation in validators.py
- Complete result charts (currently SimplifiedResultChart only)
- Hit location and advancement logic
- Double play mechanics
- Uncapped hit decision workflows
Target Architecture
User Decision → WebSocket Handler → Validator → GameEngine
↓
DecisionState stored
↓
Dice Roll → ResultChart
↓
PlayResolver (with decisions)
↓
PlayResult (affected by decisions)
↓
Apply to GameState
Implementation Tasks
Task 1: Strategic Decision Integration (6-8 hours)
1.1 Enhance GameEngine Decision Workflow
File: backend/app/core/game_engine.py
Current Flow:
# STEP 1: Prepare next play
await _prepare_next_play(state)
# Missing: Await strategic decisions from both teams
# Missing: Validate decisions against game state
# STEP 2: Roll dice and resolve
roll = self.dice.roll_ab(...)
result = self.play_resolver.resolve(roll)
Enhanced Flow:
# STEP 1: Prepare next play snapshot
await _prepare_next_play(state)
# STEP 2: Await defensive decision
defensive_decision = await _await_defensive_decision(state)
state.pending_defensive_decision = defensive_decision
# STEP 3: Await offensive decision
offensive_decision = await _await_offensive_decision(state)
state.pending_offensive_decision = offensive_decision
# STEP 4: Validate decisions
self.validators.validate_defensive_decision(state, defensive_decision)
self.validators.validate_offensive_decision(state, offensive_decision)
# STEP 5: Roll dice with decision context
roll = self.dice.roll_ab(...)
# STEP 6: Resolve with decisions applied
result = self.play_resolver.resolve(
roll=roll,
state=state,
defensive_decision=defensive_decision,
offensive_decision=offensive_decision
)
# STEP 7: Handle special outcomes (uncapped hits, steals, etc.)
if result.outcome.is_uncapped():
await _handle_uncapped_hit(state, result)
if offensive_decision.steal_attempts:
await _resolve_steal_attempts(state, offensive_decision)
New Methods to Add:
async def _await_defensive_decision(
self,
state: GameState,
timeout: int = 30
) -> DefensiveDecision:
"""
Wait for defensive team to submit decision.
For AI teams: Generate decision immediately
For human teams: Wait for WebSocket submission (with timeout)
Args:
state: Current game state
timeout: Seconds to wait before using default decision
Returns:
DefensiveDecision (validated)
Raises:
TimeoutError: If timeout exceeded (async games only)
"""
# Check if fielding team is AI
if state.is_fielding_team_ai():
return await self.ai_opponent.generate_defensive_decision(state)
# Wait for human decision via WebSocket
decision = await self._wait_for_decision(
game_id=state.game_id,
team_id=state.get_fielding_team_id(),
decision_type="defensive",
timeout=timeout
)
return decision
async def _await_offensive_decision(
self,
state: GameState,
timeout: int = 30
) -> OffensiveDecision:
"""
Wait for offensive team to submit decision.
Similar to _await_defensive_decision but for batting team.
"""
if state.is_batting_team_ai():
return await self.ai_opponent.generate_offensive_decision(state)
decision = await self._wait_for_decision(
game_id=state.game_id,
team_id=state.get_batting_team_id(),
decision_type="offensive",
timeout=timeout
)
return decision
async def _wait_for_decision(
self,
game_id: UUID,
team_id: int,
decision_type: str,
timeout: int
) -> Union[DefensiveDecision, OffensiveDecision]:
"""
Generic decision waiting with timeout.
Uses asyncio.wait_for with timeout.
Emits WebSocket event to notify frontend.
Returns:
Decision object (when received)
Raises:
TimeoutError: If no decision within timeout
"""
# Store pending decision request
self.state_manager.set_pending_decision(
game_id=game_id,
team_id=team_id,
decision_type=decision_type
)
# Emit WebSocket notification
await self.connection_manager.emit_decision_required(
game_id=game_id,
team_id=team_id,
decision_type=decision_type,
timeout=timeout,
game_situation=state.to_situation_summary()
)
# Wait for decision with timeout
try:
decision = await asyncio.wait_for(
self.state_manager.await_decision(game_id, team_id),
timeout=timeout
)
return decision
except asyncio.TimeoutError:
# Use default decision
logger.warning(f"Decision timeout for game {game_id}, using default")
return self._get_default_decision(decision_type)
Decision Storage in GameState:
Add to GameState model in game_models.py:
class GameState(BaseModel):
# ... existing fields ...
# Pending decisions for current play
pending_defensive_decision: Optional[DefensiveDecision] = None
pending_offensive_decision: Optional[OffensiveDecision] = None
# Decision phase tracking
decision_phase: str = "awaiting_defensive" # awaiting_defensive, awaiting_offensive, resolving
decision_deadline: Optional[pendulum.DateTime] = None
1.2 Enhance StateManager Decision Handling
File: backend/app/core/state_manager.py
Add Decision Queue:
class StateManager:
def __init__(self):
self._states: Dict[UUID, GameState] = {}
self._lineups: Dict[UUID, Dict[int, TeamLineupState]] = {}
self._pending_decisions: Dict[UUID, asyncio.Future] = {} # NEW
def set_pending_decision(
self,
game_id: UUID,
team_id: int,
decision_type: str
) -> None:
"""Mark that a decision is required."""
key = (game_id, team_id, decision_type)
self._pending_decisions[key] = asyncio.Future()
async def await_decision(
self,
game_id: UUID,
team_id: int
) -> Union[DefensiveDecision, OffensiveDecision]:
"""Wait for a decision to be submitted."""
# Find pending decision future
for key, future in self._pending_decisions.items():
if key[0] == game_id and key[1] == team_id:
return await future
raise ValueError(f"No pending decision for game {game_id}, team {team_id}")
def submit_decision(
self,
game_id: UUID,
team_id: int,
decision: Union[DefensiveDecision, OffensiveDecision]
) -> None:
"""Submit a decision (called by WebSocket handler)."""
# Find and resolve the pending future
for key, future in list(self._pending_decisions.items()):
if key[0] == game_id and key[1] == team_id:
if not future.done():
future.set_result(decision)
del self._pending_decisions[key]
return
raise ValueError(f"No pending decision for game {game_id}, team {team_id}")
Acceptance Criteria:
- GameEngine workflow includes decision phases
- StateManager manages decision futures
- Timeout handling works (default decisions applied)
- AI teams generate decisions immediately
- Human teams wait for WebSocket submission
Task 2: Decision Validators (3-4 hours)
File: backend/app/core/validators.py
Current State: Has basic validation for outs, innings, lineups Target: Add strategic decision validation
2.1 Defensive Decision Validation
def validate_defensive_decision(
state: GameState,
decision: DefensiveDecision
) -> None:
"""
Validate defensive decision against game state.
Raises:
ValueError: If decision is invalid for current situation
"""
# Validate hold runners
for base in decision.hold_runners:
if base not in [1, 2, 3]:
raise ValueError(f"Cannot hold runner on base {base}")
# Check if runner exists on that base
runner = state.get_runner_at_base(base)
if runner is None:
raise ValueError(f"Cannot hold runner on empty base {base}")
# Validate infield depth vs situation
if decision.infield_depth == "double_play":
if state.outs >= 2:
raise ValueError("Cannot play for double play with 2 outs")
if not state.is_runner_on_first():
raise ValueError("Cannot play for double play without runner on first")
# Validate infield in
if decision.infield_depth == "in":
if state.outs >= 2:
# Warning but allow (sometimes done in desperation)
logger.warning("Infield in with 2 outs - risky play")
2.2 Offensive Decision Validation
def validate_offensive_decision(
state: GameState,
decision: OffensiveDecision
) -> None:
"""
Validate offensive decision against game state.
Raises:
ValueError: If decision is invalid for current situation
"""
# Validate steal attempts
for base in decision.steal_attempts:
stealing_from = base - 1
if stealing_from not in [1, 2, 3]:
raise ValueError(f"Invalid steal attempt to base {base}")
runner = state.get_runner_at_base(stealing_from)
if runner is None:
raise ValueError(f"No runner on base {stealing_from} to steal")
# Validate bunt attempt
if decision.bunt_attempt:
if state.outs >= 2:
raise ValueError("Cannot bunt with 2 outs")
if decision.hit_and_run:
raise ValueError("Cannot bunt and hit-and-run simultaneously")
# Validate hit and run
if decision.hit_and_run:
if not any(state.get_runner_at_base(b) for b in [1, 2, 3]):
raise ValueError("Hit and run requires at least one runner on base")
Acceptance Criteria:
- All defensive decision rules validated
- All offensive decision rules validated
- Clear error messages for invalid decisions
- Edge cases handled (e.g., bunt with 2 outs)
Task 3: Complete Result Charts (8-10 hours)
File: backend/app/config/result_charts.py
Current State: Has SimplifiedResultChart with basic d20 distribution
Target: Complete result chart system with situational modifiers
3.1 Enhanced Result Chart Interface
from abc import ABC, abstractmethod
from app.models.game_models import DefensiveDecision, OffensiveDecision
class ResultChart(ABC):
"""Base class for result chart implementations."""
@abstractmethod
def get_outcome(
self,
roll: AbRoll,
state: GameState,
defensive_decision: DefensiveDecision,
offensive_decision: OffensiveDecision
) -> PlayOutcome:
"""
Determine play outcome from roll and game situation.
Args:
roll: Dice roll result
state: Current game state
defensive_decision: Defensive team's decisions
offensive_decision: Offensive team's decisions
Returns:
PlayOutcome enum value
"""
pass
@abstractmethod
def get_hit_location(
self,
outcome: PlayOutcome,
batter_handedness: str, # 'L' or 'R'
defensive_alignment: str
) -> str:
"""
Determine where the ball was hit.
Returns:
Hit location code (e.g., "7" = LF, "8" = CF, "9" = RF)
"""
pass
3.2 Standard Result Chart (SBA League)
class StandardResultChart(ResultChart):
"""
Standard result chart for SBA league.
Based on simplified Strat-O-Matic mechanics with defensive modifiers.
"""
def get_outcome(
self,
roll: AbRoll,
state: GameState,
defensive_decision: DefensiveDecision,
offensive_decision: OffensiveDecision
) -> PlayOutcome:
"""
Resolve outcome with strategic decision modifiers.
"""
resolution_d20 = roll.resolution_d20
# Base outcome from roll
outcome = self._get_base_outcome(resolution_d20)
# Apply defensive modifiers
outcome = self._apply_defensive_modifiers(
outcome=outcome,
decision=defensive_decision,
state=state
)
# Apply offensive modifiers
outcome = self._apply_offensive_modifiers(
outcome=outcome,
decision=offensive_decision,
state=state
)
return outcome
def _get_base_outcome(self, roll: int) -> PlayOutcome:
"""Base outcome distribution."""
if roll <= 2:
return PlayOutcome.STRIKEOUT
elif roll <= 5:
return PlayOutcome.WALK
elif roll <= 8:
# Groundball variants
return self._get_groundball_variant(roll)
elif roll <= 11:
# Flyout variants
return self._get_flyout_variant(roll)
elif roll <= 13:
return PlayOutcome.WALK
elif roll <= 15:
# Single variants
return self._get_single_variant(roll)
elif roll <= 17:
# Double variants
return self._get_double_variant(roll)
elif roll == 18:
return PlayOutcome.LINEOUT
elif roll == 19:
return PlayOutcome.TRIPLE
else: # roll == 20
return PlayOutcome.HOMERUN
def _get_groundball_variant(self, roll: int) -> PlayOutcome:
"""Determine groundball type."""
if roll == 6:
return PlayOutcome.GROUNDBALL_A # DP opportunity
elif roll == 7:
return PlayOutcome.GROUNDBALL_B # Standard
else: # roll == 8
return PlayOutcome.GROUNDBALL_C # Slow roller
def _apply_defensive_modifiers(
self,
outcome: PlayOutcome,
decision: DefensiveDecision,
state: GameState
) -> PlayOutcome:
"""
Modify outcome based on defensive positioning.
Examples:
- Infield in: GROUNDBALL_B → GROUNDOUT (faster plays)
- Infield back: GROUNDBALL_A → GROUNDBALL_B (sacrifice DP for out)
- Shifted: More likely to convert GB to outs on pull side
"""
# Infield depth modifiers
if decision.infield_depth == "in":
# Better chance to get lead runner, but harder DP
if outcome == PlayOutcome.GROUNDBALL_A:
return PlayOutcome.GROUNDBALL_B # Sacrifice DP
elif decision.infield_depth == "double_play":
# Optimized for turning two
if outcome == PlayOutcome.GROUNDBALL_B:
return PlayOutcome.GROUNDBALL_A # Upgrade to DP chance
# Shift modifiers
if decision.alignment != "normal":
# Shifts make pull-side grounders more likely to be outs
# But give up hits on opposite side
# TODO: Implement based on hit location
pass
return outcome
def _apply_offensive_modifiers(
self,
outcome: PlayOutcome,
decision: OffensiveDecision,
state: GameState
) -> PlayOutcome:
"""
Modify outcome based on offensive approach.
Examples:
- Contact approach: Fewer strikeouts, more weak contact
- Power approach: More strikeouts, more extra-base hits
- Bunt: Convert normal swing to bunt outcome
"""
if decision.bunt_attempt:
# Convert to bunt outcome if contact made
if outcome.is_hit() or outcome in [
PlayOutcome.GROUNDBALL_A,
PlayOutcome.GROUNDBALL_B,
PlayOutcome.GROUNDBALL_C
]:
return PlayOutcome.BUNT_GROUNDOUT # TODO: Add to PlayOutcome enum
if decision.approach == "contact":
# Reduce strikeouts, increase contact outs
if outcome == PlayOutcome.STRIKEOUT:
# 50% chance to convert to weak contact
if random.random() < 0.5:
return PlayOutcome.GROUNDBALL_C
elif decision.approach == "power":
# More extra bases, but more strikeouts
if outcome == PlayOutcome.SINGLE_1:
# 20% chance to upgrade to double
if random.random() < 0.2:
return PlayOutcome.DOUBLE_2
return outcome
def get_hit_location(
self,
outcome: PlayOutcome,
batter_handedness: str,
defensive_alignment: str
) -> str:
"""
Determine hit location using probability distribution.
Location codes:
- "7": Left field
- "8": Center field
- "9": Right field
- "5": Third base
- "6": Shortstop
- "4": Second base
- "3": First base
"""
# Default pull rates by handedness
if batter_handedness == "R":
# RHB pulls to left
pull_rate = 0.45
center_rate = 0.35
oppo_rate = 0.20
else: # LHB
# LHB pulls to right
pull_rate = 0.45
center_rate = 0.35
oppo_rate = 0.20
# Modify based on defensive shift
if defensive_alignment == "shifted_left":
# Defense shifted to pull side (left for RHB)
pull_rate -= 0.15 # Harder to pull
oppo_rate += 0.15 # More opposite field
elif defensive_alignment == "shifted_right":
pull_rate -= 0.15
oppo_rate += 0.15
# Roll for location
roll = random.random()
if outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]:
# Ground ball locations (infield)
if batter_handedness == "R":
if roll < pull_rate:
return "5" if roll < pull_rate/2 else "6" # 3B or SS
elif roll < pull_rate + center_rate:
return "4" if roll < pull_rate + center_rate/2 else "6" # 2B or SS
else:
return "3" # 1B
else: # LHB
if roll < pull_rate:
return "4" if roll < pull_rate/2 else "3" # 2B or 1B
elif roll < pull_rate + center_rate:
return "6" if roll < pull_rate + center_rate/2 else "4" # SS or 2B
else:
return "5" # 3B
else:
# Fly ball / line drive locations (outfield)
if batter_handedness == "R":
if roll < pull_rate:
return "7" # LF
elif roll < pull_rate + center_rate:
return "8" # CF
else:
return "9" # RF
else: # LHB
if roll < pull_rate:
return "9" # RF
elif roll < pull_rate + center_rate:
return "8" # CF
else:
return "7" # LF
3.3 PD Result Chart (Card-Based)
class PdResultChart(ResultChart):
"""
PD league result chart using player card ratings.
Resolves outcomes from batting/pitching card probabilities.
"""
def get_outcome(
self,
roll: AbRoll,
state: GameState,
defensive_decision: DefensiveDecision,
offensive_decision: OffensiveDecision
) -> PlayOutcome:
"""
Resolve from player cards with column selection.
1. Roll offense_d6 (1-3: batter card, 4-6: pitcher card)
2. Get rating from selected card
3. Roll 2d6 to select outcome row (2-12)
4. Roll split_d20 if needed for resolution
"""
# Determine which card to use
use_batter_card = roll.offense_d6 <= 3
# Get appropriate ratings
if use_batter_card:
rating = self._get_batter_rating(state)
else:
rating = self._get_pitcher_rating(state)
if rating is None:
# Fallback to simplified chart
logger.warning("No card data available, using simplified chart")
return SimplifiedResultChart().get_outcome(roll, state, defensive_decision, offensive_decision)
# Convert 2d6 roll to outcome
card_roll = roll.offense_d6 # Reuse as card row selector
outcome = self._get_outcome_from_rating(
rating=rating,
card_roll=card_roll,
split_roll=roll.split_d20
)
# Apply decision modifiers (same as StandardResultChart)
outcome = self._apply_defensive_modifiers(outcome, defensive_decision, state)
outcome = self._apply_offensive_modifiers(outcome, offensive_decision, state)
return outcome
def _get_batter_rating(
self,
state: GameState
) -> Optional[PdBattingRating]:
"""Get batter's rating vs pitcher handedness."""
# Get current batter from lineup
batter = state.get_current_batter()
if not batter or not isinstance(batter.player_data, PdPlayer):
return None
# Get pitcher handedness
pitcher = state.get_current_pitcher()
pitcher_hand = pitcher.player_data.hand if pitcher else 'R'
# Return appropriate rating
return batter.player_data.get_batting_rating(pitcher_hand)
def _get_outcome_from_rating(
self,
rating: Union[PdBattingRating, PdPitchingRating],
card_roll: int,
split_roll: int
) -> PlayOutcome:
"""
Convert card rating probabilities to PlayOutcome.
Uses cumulative probability distribution to select outcome.
"""
# Build cumulative distribution
cumulative = 0.0
roll_pct = split_roll / 20.0 # Convert 1-20 to 0.05-1.0
outcomes = [
(rating.homerun, PlayOutcome.HOMERUN),
(rating.triple, PlayOutcome.TRIPLE),
(rating.double_3, PlayOutcome.DOUBLE_3),
(rating.double_2, PlayOutcome.DOUBLE_2),
(rating.single_2, PlayOutcome.SINGLE_2),
(rating.single_1, PlayOutcome.SINGLE_1),
(rating.walk, PlayOutcome.WALK),
(rating.strikeout, PlayOutcome.STRIKEOUT),
# ... all other outcomes
]
for probability, outcome in outcomes:
cumulative += probability
if roll_pct <= cumulative:
return outcome
# Default fallback
return PlayOutcome.GROUNDBALL_B
3.4 Runner Advancement Logic
class RunnerAdvancer:
"""
Handles runner advancement for different play outcomes.
"""
def advance_runners(
self,
state: GameState,
outcome: PlayOutcome,
hit_location: str,
outs_recorded: int
) -> List[RunnerMovement]:
"""
Calculate runner advancement for a play.
Returns:
List of RunnerMovement (from_base, to_base, lineup_id)
"""
movements = []
# Get runners in reverse order (3rd, 2nd, 1st)
for base in [3, 2, 1]:
runner = state.get_runner_at_base(base)
if runner is None:
continue
destination = self._calculate_destination(
outcome=outcome,
from_base=base,
hit_location=hit_location,
outs_recorded=outs_recorded,
state=state
)
movements.append(RunnerMovement(
lineup_id=runner.lineup_id,
from_base=base,
to_base=destination
))
return movements
def _calculate_destination(
self,
outcome: PlayOutcome,
from_base: int,
hit_location: str,
outs_recorded: int,
state: GameState
) -> int:
"""
Calculate where a runner advances to.
Returns:
Base number (1-3) or 4 (home/scored) or 0 (out)
"""
# Forced advances (walks, hit by pitch)
if outcome == PlayOutcome.WALK:
if from_base == 1 or state.bases_are_loaded():
return from_base + 1
else:
return from_base # Stay put
# Singles
if outcome in [PlayOutcome.SINGLE_1, PlayOutcome.SINGLE_2]:
if from_base == 3:
return 4 # Score from third
elif from_base == 2:
if outcome == PlayOutcome.SINGLE_2:
return 4 # Score from second on enhanced single
else:
return 3 # Third on standard single
else: # from_base == 1
if outcome == PlayOutcome.SINGLE_2:
return 3 # Extra base
else:
return 2 # Standard
# Doubles
if outcome in [PlayOutcome.DOUBLE_2, PlayOutcome.DOUBLE_3]:
if from_base >= 2:
return 4 # Score from 2nd or 3rd
else: # from_base == 1
if outcome == PlayOutcome.DOUBLE_3:
return 4 # Score from first (rare)
else:
return 3 # Third on standard double
# Triples
if outcome == PlayOutcome.TRIPLE:
return 4 # All runners score
# Homeruns
if outcome == PlayOutcome.HOMERUN:
return 4 # All runners score
# Groundouts (runner may or may not advance)
if outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]:
if outs_recorded == 0:
# No outs made, runners likely forced or held
if from_base == 3:
return 4 # Score on contact
elif self._is_force_play(from_base, state):
return from_base + 1
else:
return from_base # Hold
else:
# Outs recorded
if from_base == 3 and outs_recorded == 1:
return 4 # Score on groundout (sac)
else:
return from_base # Hold
# Flyouts
if outcome in [PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_C]:
if outs_recorded > 0:
# Tag up rules
if from_base == 3:
if outcome == PlayOutcome.FLYOUT_B: # Medium depth
return 4 # Tag from third
elif from_base == 2:
if outcome == PlayOutcome.FLYOUT_C: # Deep fly
return 3 # Tag to third
return from_base # Hold if no tag
# Default: stay put
return from_base
Acceptance Criteria:
- StandardResultChart with defensive/offensive modifiers
- PdResultChart using player card ratings
- Hit location logic with pull/center/opposite distribution
- Runner advancement rules for all outcome types
- Double play mechanics for GROUNDBALL_A
- Tests for all result chart scenarios
Task 4: WebSocket Handlers (3-4 hours)
File: backend/app/websocket/handlers.py
4.1 Decision Submission Handlers
@sio.event
async def submit_defensive_decision(sid: str, data: dict):
"""
Handle defensive decision submission.
Expected data:
{
"game_id": "uuid",
"team_id": 1,
"alignment": "normal",
"infield_depth": "normal",
"outfield_depth": "normal",
"hold_runners": []
}
"""
try:
# Validate data
game_id = UUID(data["game_id"])
team_id = int(data["team_id"])
# Create decision object
decision = DefensiveDecision(
alignment=data.get("alignment", "normal"),
infield_depth=data.get("infield_depth", "normal"),
outfield_depth=data.get("outfield_depth", "normal"),
hold_runners=data.get("hold_runners", [])
)
# Validate against game state
state = state_manager.get_state(game_id)
if state is None:
await sio.emit('error', {
"message": f"Game {game_id} not found"
}, room=sid)
return
# Validate it's this team's turn
if state.get_fielding_team_id() != team_id:
await sio.emit('error', {
"message": "Not your turn to make defensive decision"
}, room=sid)
return
# Validate decision
validators.validate_defensive_decision(state, decision)
# Submit decision (resolves pending future)
state_manager.submit_decision(game_id, team_id, decision)
# Acknowledge
await sio.emit('decision_accepted', {
"type": "defensive",
"team_id": team_id
}, room=sid)
# Broadcast to game room
await connection_manager.broadcast_to_game(
game_id,
'defensive_decision_submitted',
{"team_id": team_id}
)
except ValidationError as e:
await sio.emit('error', {
"message": f"Invalid decision: {str(e)}"
}, room=sid)
except Exception as e:
logger.error(f"Error handling defensive decision: {e}", exc_info=True)
await sio.emit('error', {
"message": "Internal server error"
}, room=sid)
@sio.event
async def submit_offensive_decision(sid: str, data: dict):
"""
Handle offensive decision submission.
Expected data:
{
"game_id": "uuid",
"team_id": 1,
"approach": "normal",
"steal_attempts": [],
"hit_and_run": false,
"bunt_attempt": false
}
"""
# Similar implementation to submit_defensive_decision
pass
Acceptance Criteria:
- WebSocket handlers for both decision types
- Validation before submission
- Error handling with clear messages
- Broadcast to game room on submission
Task 5: Terminal Client Enhancement (2-3 hours)
File: backend/terminal_client/commands.py
Current State: Has basic defensive/offensive commands Target: Support all decision options
5.1 Enhanced Command Options
def submit_defensive_decision(
engine: GameEngine,
state: GameState,
alignment: str = "normal",
infield_depth: str = "normal",
outfield_depth: str = "normal",
hold_runners: List[int] = None
) -> None:
"""
Submit defensive decision with full options.
Examples:
defensive normal normal normal
defensive shifted_left double_play normal --hold 1,3
defensive extreme_shift in back
"""
decision = DefensiveDecision(
alignment=alignment,
infield_depth=infield_depth,
outfield_depth=outfield_depth,
hold_runners=hold_runners or []
)
# Store in state
state.pending_defensive_decision = decision
print(f"✓ Defensive decision: {alignment}, IF: {infield_depth}, OF: {outfield_depth}")
if hold_runners:
print(f" Holding runners: {', '.join(str(b) for b in hold_runners)}")
5.2 Enhanced Help Text
DEFENSIVE_HELP = """
Submit defensive decision for the current play.
Usage:
defensive [alignment] [infield_depth] [outfield_depth] [--hold BASES]
Alignment Options:
normal Standard positioning (default)
shifted_left Shift defense to left side
shifted_right Shift defense to right side
extreme_shift Maximum shift (PD only)
Infield Depth:
in Infield in (prevent run from scoring)
normal Standard depth (default)
back Playing back (more range, less arm strength needed)
double_play Optimized for turning two
Outfield Depth:
in Shallow (prevent sacrifice fly)
normal Standard depth (default)
back Deep (prevent extra bases)
Examples:
defensive # All defaults
defensive shifted_left double_play normal
defensive normal in normal --hold 1,3
"""
Acceptance Criteria:
- Terminal client supports all decision options
- Help text documents all options
- Validation errors shown clearly
Task 6: Double Play Mechanics (2-3 hours)
File: backend/app/core/play_resolver.py
6.1 Double Play Resolution
def _resolve_double_play_attempt(
self,
state: GameState,
hit_location: str,
defensive_decision: DefensiveDecision
) -> Tuple[int, List[int]]: # (outs_recorded, runners_out_lineup_ids)
"""
Resolve double play attempt on GROUNDBALL_A.
Factors:
- Defensive positioning (double_play depth increases success)
- Runner speed on first
- Hit location (up the middle easier than to corners)
- Number of outs already
Returns:
(outs_recorded, [lineup_ids of runners thrown out])
"""
if state.outs >= 2:
# Can't turn DP with 2 outs
return (1, [])
if not state.is_runner_on_first():
# Need runner on first for DP
return (1, [])
# Base DP probability
dp_probability = 0.45 # 45% chance
# Modify based on defensive positioning
if defensive_decision.infield_depth == "double_play":
dp_probability += 0.20 # 65% with DP depth
elif defensive_decision.infield_depth == "back":
dp_probability -= 0.15 # 30% playing back
# Modify based on hit location
if hit_location in ["4", "6"]: # Up the middle
dp_probability += 0.10
elif hit_location in ["3", "5"]: # Corners
dp_probability -= 0.10
# Modify based on runner speed (TODO: use player ratings)
runner_on_first = state.on_first
if runner_on_first and hasattr(runner_on_first.player_data, 'speed'):
speed = runner_on_first.player_data.speed
if speed >= 15: # Fast runner
dp_probability -= 0.15
elif speed <= 5: # Slow runner
dp_probability += 0.10
# Roll for DP
if random.random() < dp_probability:
# Double play!
return (2, [runner_on_first.lineup_id, state.current_batter_lineup_id])
else:
# Only one out (force at second or first)
return (1, [runner_on_first.lineup_id]) # Runner forced at second
Acceptance Criteria:
- Double play logic considers all factors
- Defensive positioning affects DP probability
- Runner speed affects outcome (when ratings available)
- Hit location affects DP chance
Testing Strategy
Unit Tests
New Test Files:
-
tests/unit/core/test_result_charts.py(40+ tests)- StandardResultChart outcomes
- PdResultChart card-based resolution
- Hit location distribution
- Runner advancement rules
- Double play mechanics
-
tests/unit/core/test_decision_validators.py(20+ tests)- Defensive decision validation
- Offensive decision validation
- Edge cases (2 outs, no runners, etc.)
-
tests/unit/websocket/test_decision_handlers.py(15+ tests)- Decision submission
- Validation errors
- Turn enforcement
- Timeout handling
Integration Tests
New Test Files:
tests/integration/test_strategic_gameplay.py(10+ tests)- Complete play with strategic decisions
- Decision timeout with default
- AI opponent decision generation
- WebSocket decision flow
Terminal Client Testing
Manual Test Scenarios:
- Play with infield in, runner scores from third
- Play for double play, DP successful
- Steal attempt with different leads
- Bunt attempt execution
- Hit and run play
Deliverables
Code Files
New Files:
backend/app/core/runner_advancer.py- Runner advancement logicbackend/app/core/ai_opponent.py- AI decision generation (stub for Week 9)backend/tests/unit/core/test_result_charts.pybackend/tests/unit/core/test_decision_validators.pybackend/tests/unit/websocket/test_decision_handlers.pybackend/tests/integration/test_strategic_gameplay.py
Modified Files:
backend/app/core/game_engine.py- Decision workflow integrationbackend/app/core/state_manager.py- Decision queue managementbackend/app/core/validators.py- Strategic decision validatorsbackend/app/core/play_resolver.py- Enhanced resolution with decisionsbackend/app/config/result_charts.py- Complete result chartsbackend/app/models/game_models.py- GameState decision fieldsbackend/app/websocket/handlers.py- Decision submission handlersbackend/terminal_client/commands.py- Enhanced decision commandsbackend/terminal_client/help_text.py- Updated help documentation
Documentation
Updated Files:
.claude/implementation/00-index.md- Mark Week 7 complete.claude/implementation/NEXT_SESSION.md- Week 8 planbackend/CLAUDE.md- Document decision system
Test Coverage
Target: 90%+ coverage for new code
Test Counts:
- Unit tests: 75+ new tests
- Integration tests: 10+ new tests
- Total: 85+ new tests
Success Criteria
Week 7 is complete when:
- ✅ All strategic decisions integrated into game flow
- ✅ Decision validation enforces game rules
- ✅ Complete result charts with situational modifiers
- ✅ Hit location and runner advancement working
- ✅ Double play mechanics functional
- ✅ WebSocket handlers accept and validate decisions
- ✅ Terminal client supports all decision types
- ✅ 85+ new tests passing
- ✅ AI opponent stubs ready for Week 9 (generate_defensive_decision, generate_offensive_decision)
- ✅ Documentation updated
Risks & Mitigation
Risk 1: Decision Workflow Complexity
Impact: High Mitigation: Start with synchronous flow (both decisions submitted before resolution), add async support later
Risk 2: Result Chart Balance
Impact: Medium Mitigation: Use SimplifiedResultChart as baseline, test extensively with real gameplay
Risk 3: WebSocket State Management
Impact: High Mitigation: Use asyncio.Future for decision awaiting, add timeout handling from start
Risk 4: Runner Advancement Edge Cases
Impact: Medium Mitigation: Implement conservative advancement rules, document TODOs for complex scenarios
Week 8 Preview
After Week 7, focus shifts to:
- Substitution system (pinch hitters, defensive replacements)
- Pitching changes (bullpen management)
- Frontend game interface (mobile-first)
- Decision workflows UI
Quick Reference
Key Files to Modify:
app/core/game_engine.py- Decision workflowapp/core/validators.py- Decision validationapp/config/result_charts.py- Result chartsapp/websocket/handlers.py- WebSocket handlersterminal_client/commands.py- Terminal client
Key Imports:
from app.models.game_models import DefensiveDecision, OffensiveDecision, GameState
from app.config import PlayOutcome, get_league_config
from app.core.dice import AbRoll
from app.core.validators import validate_defensive_decision, validate_offensive_decision
Test Commands:
# Run all Week 7 tests
pytest tests/unit/core/test_result_charts.py -v
pytest tests/unit/core/test_decision_validators.py -v
pytest tests/integration/test_strategic_gameplay.py -v
# Terminal client testing
python -m terminal_client
> new_game
> defensive shifted_left double_play normal
> offensive normal --steal 2
> resolve
Estimated Completion: 20-25 hours Priority: High (blocks Week 8 frontend work) Dependencies: Week 6 Complete (✅) Next Milestone: Week 8 - Substitutions + Frontend UI