CLAUDE: Add decision_required WebSocket event and quick-create testing endpoint

Backend enhancements for real-time decision workflow:

**New Features**:
- decision_required event emission when game starts and after each decision
- Quick-create endpoint (/games/quick-create) for rapid testing with pre-configured lineups
- WebSocket connection manager integration in GameEngine

**Changes**:
- game_engine.py: Added _emit_decision_required() method and set_connection_manager()
- game_engine.py: Emit decision_required on game start with 5-minute timeout
- games.py: New /quick-create endpoint with Team 35 vs Team 38 lineups
- main.py: Wire connection manager to game_engine singleton
- state_manager.py: Enhanced state management for decision phases
- play_resolver.py: Improved play resolution logic
- handlers.py: Updated WebSocket handlers for new workflow
- backend/CLAUDE.md: Added WebSocket protocol spec reference

**Why**:
Eliminates polling - frontend now gets real-time notification when decisions are needed.
Quick-create saves 2 minutes of lineup setup during each test iteration.

**Testing**:
- Manual testing with terminal client
- WebSocket event flow verified with live frontend

🤖 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-21 15:40:27 -06:00
parent bcbf6036c7
commit 9627a79dce
7 changed files with 386 additions and 39 deletions

View File

@ -114,6 +114,7 @@ uv run pytest tests/unit/ -q # Must show all passing
## References
- **WebSocket Protocol Spec**: `../.claude/WEBSOCKET_PROTOCOL_SPEC.md` - Complete event catalog and workflow
- **Type Checking Guide**: `.claude/type-checking-guide.md`
- **Code Review**: `.claude/CODE_REVIEW_GAME_ENGINE.md`
- **Implementation Plans**: `../.claude/implementation/`

View File

@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, field_validator
from app.core.game_engine import game_engine
from app.core.state_manager import state_manager
from app.database.operations import DatabaseOperations
from app.services.lineup_service import lineup_service
@ -294,6 +295,113 @@ async def create_game(request: CreateGameRequest):
raise HTTPException(status_code=500, detail=f"Failed to create game: {str(e)}")
@router.post("/quick-create", response_model=CreateGameResponse)
async def quick_create_game():
"""
Quick-create endpoint for testing - creates a game with pre-configured lineups.
Uses the lineup configuration from the most recent game (Team 35 vs Team 38).
This eliminates the 2-minute lineup configuration process during testing.
Returns:
CreateGameResponse with game_id
"""
try:
# Generate game ID
game_id = uuid4()
# Use real team data from most recent game
home_team_id = 35
away_team_id = 38
league_id = "sba"
logger.info(
f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id}"
)
# Create game in state manager
state = await state_manager.create_game(
game_id=game_id,
league_id=league_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
# Save to database
db_ops = DatabaseOperations()
await db_ops.create_game(
game_id=game_id,
league_id=league_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
game_mode="friendly",
visibility="public",
)
# Submit home lineup (Team 35)
home_lineup_data = [
{"player_id": 1417, "position": "C", "batting_order": 1},
{"player_id": 1186, "position": "1B", "batting_order": 2},
{"player_id": 1381, "position": "2B", "batting_order": 3},
{"player_id": 1576, "position": "3B", "batting_order": 4},
{"player_id": 1242, "position": "SS", "batting_order": 5},
{"player_id": 1600, "position": "LF", "batting_order": 6},
{"player_id": 1675, "position": "CF", "batting_order": 7},
{"player_id": 1700, "position": "RF", "batting_order": 8},
{"player_id": 1759, "position": "DH", "batting_order": 9},
{"player_id": 1948, "position": "P", "batting_order": None},
]
for player in home_lineup_data:
await lineup_service.add_sba_player_to_lineup(
game_id=game_id,
team_id=home_team_id,
player_id=player["player_id"],
position=player["position"],
batting_order=player["batting_order"],
is_starter=True,
)
# Submit away lineup (Team 38)
away_lineup_data = [
{"player_id": 1080, "position": "C", "batting_order": 1},
{"player_id": 1148, "position": "1B", "batting_order": 2},
{"player_id": 1166, "position": "2B", "batting_order": 3},
{"player_id": 1513, "position": "3B", "batting_order": 4},
{"player_id": 1209, "position": "SS", "batting_order": 5},
{"player_id": 1735, "position": "LF", "batting_order": 6},
{"player_id": 1665, "position": "CF", "batting_order": 7},
{"player_id": 1961, "position": "RF", "batting_order": 8},
{"player_id": 1980, "position": "DH", "batting_order": 9},
{"player_id": 2005, "position": "P", "batting_order": None},
]
for player in away_lineup_data:
await lineup_service.add_sba_player_to_lineup(
game_id=game_id,
team_id=away_team_id,
player_id=player["player_id"],
position=player["position"],
batting_order=player["batting_order"],
is_starter=True,
)
# Start the game
await game_engine.start_game(game_id)
logger.info(f"Quick-created game {game_id} and started successfully")
return CreateGameResponse(
game_id=str(game_id),
message=f"Game quick-created with Team {home_team_id} vs Team {away_team_id}. Ready to play!",
status="active",
)
except Exception as e:
logger.exception(f"Failed to quick-create game: {e}")
raise HTTPException(status_code=500, detail=f"Failed to quick-create game: {str(e)}")
@router.post("/{game_id}/lineups", response_model=SubmitLineupsResponse)
async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
"""

View File

@ -45,6 +45,59 @@ class GameEngine:
self._rolls_this_inning: dict[UUID, list] = {}
# Locks for concurrent decision submission (prevents race conditions)
self._game_locks: dict[UUID, asyncio.Lock] = {}
# WebSocket connection manager for real-time events (set by main.py)
self._connection_manager = None
def set_connection_manager(self, connection_manager):
"""
Set the WebSocket connection manager for real-time event emission.
Called by main.py after both singletons are initialized.
"""
self._connection_manager = connection_manager
logger.info("WebSocket connection manager configured for game engine")
async def _emit_decision_required(
self, game_id: UUID, state: GameState, phase: str, timeout_seconds: int = 300
):
"""
Emit decision_required event to notify frontend a decision is needed.
Args:
game_id: Game identifier
state: Current game state
phase: Decision phase ('awaiting_defensive' or 'awaiting_offensive')
timeout_seconds: Decision timeout in seconds (default 5 minutes)
"""
if not self._connection_manager:
logger.warning("No connection manager - cannot emit decision_required event")
return
# Determine which team needs to decide
if phase == "awaiting_defensive":
# Fielding team = home if top, away if bottom
role = "home" if state.half == "top" else "away"
elif phase == "awaiting_offensive":
# Batting team = away if top, home if bottom
role = "away" if state.half == "top" else "home"
else:
logger.warning(f"Unknown decision phase for emission: {phase}")
return
try:
await self._connection_manager.broadcast_to_game(
str(game_id),
"decision_required",
{
"phase": phase,
"role": role,
"timeout_seconds": timeout_seconds,
"message": f"{role.title()} team: {phase.replace('_', ' ').title()} decision required"
}
)
logger.info(f"Emitted decision_required for game {game_id}: phase={phase}, role={role}")
except Exception as e:
logger.error(f"Failed to emit decision_required: {e}", exc_info=True)
def _get_game_lock(self, game_id: UUID) -> asyncio.Lock:
"""Get or create a lock for the specified game to prevent race conditions."""
@ -189,9 +242,16 @@ class GameEngine:
# Prepare first play snapshot
await self._prepare_next_play(state)
# Set initial decision phase to awaiting defensive decision
# This allows the frontend to immediately show the defensive setup panel
state.decision_phase = "awaiting_defensive"
# Update state
state_manager.update_state(game_id, state)
# Emit decision_required event for real-time frontend notification
await self._emit_decision_required(game_id, state, "awaiting_defensive", timeout_seconds=self.DECISION_TIMEOUT)
# Persist to DB
await self.db_ops.update_game_state(
game_id=game_id,
@ -203,7 +263,8 @@ class GameEngine:
)
logger.info(
f"Started game {game_id} - First batter: lineup_id={state.current_batter.lineup_id}"
f"Started game {game_id} - First batter: lineup_id={state.current_batter.lineup_id}, "
f"decision_phase={state.decision_phase}"
)
return state
@ -227,6 +288,7 @@ class GameEngine:
# Store decision in state (for backward compatibility)
state.decisions_this_play["defensive"] = decision.model_dump()
state.pending_decision = "offensive"
state.decision_phase = "awaiting_offensive"
state.pending_defensive_decision = decision
# Phase 3: Resolve pending future if exists
@ -243,6 +305,11 @@ class GameEngine:
state_manager.update_state(game_id, state)
logger.info(f"Defensive decision submitted for game {game_id}")
# Emit decision_required for offensive phase
await self._emit_decision_required(
game_id, state, "awaiting_offensive", timeout_seconds=self.DECISION_TIMEOUT
)
return state
async def submit_offensive_decision(
@ -265,6 +332,7 @@ class GameEngine:
# Store decision in state (for backward compatibility)
state.decisions_this_play["offensive"] = decision.model_dump()
state.pending_decision = "resolution"
state.decision_phase = "resolution"
state.pending_offensive_decision = decision
# Phase 3: Resolve pending future if exists
@ -333,6 +401,9 @@ class GameEngine:
)
state_manager.update_state(state.game_id, state)
# Emit decision_required event for real-time frontend notification
await self._emit_decision_required(state.game_id, state, "awaiting_defensive", timeout_seconds=timeout)
try:
# Wait for decision with timeout
decision = await asyncio.wait_for(
@ -396,6 +467,9 @@ class GameEngine:
)
state_manager.update_state(state.game_id, state)
# Emit decision_required event for real-time frontend notification
await self._emit_decision_required(state.game_id, state, "awaiting_offensive", timeout_seconds=timeout)
try:
# Wait for decision with timeout
decision = await asyncio.wait_for(
@ -512,6 +586,8 @@ class GameEngine:
# Prepare next play or clean up if game completed
if state.status == "active":
await self._prepare_next_play(state)
# Reset decision phase for next play
state.decision_phase = "awaiting_defensive"
elif state.status == "completed":
# Clean up per-game resources to prevent memory leaks
self._cleanup_game_resources(game_id)
@ -634,12 +710,9 @@ class GameEngine:
game_validator.validate_game_active(state)
# Validate hit location provided when required
if outcome.requires_hit_location() and not hit_location:
raise ValidationError(
f"Outcome {outcome.value} requires hit_location "
f"(one of: 1B, 2B, SS, 3B, LF, CF, RF, P, C)"
)
# NOTE: Business rule validation (e.g., when hit_location is required based on
# game state) is handled in PlayResolver, not here. The transport layer should
# not make business logic decisions about contextual requirements.
# Get decisions
defensive_decision = DefensiveDecision(
@ -1001,6 +1074,7 @@ class GameEngine:
"half": state.half,
"outs_before": state.outs, # Capture current outs BEFORE applying result
"outs_recorded": result.outs_recorded,
"batting_order": state.current_batter.batting_order if state.current_batter else 1,
# Player IDs from snapshot
"batter_id": batter_id,
"pitcher_id": pitcher_id,

View File

@ -254,12 +254,44 @@ class PlayResolver:
is_out=True,
)
# ==================== Popout ====================
if outcome == PlayOutcome.POPOUT:
return PlayResult(
outcome=outcome,
outs_recorded=1,
runs_scored=0,
batter_result=None,
runners_advanced=[],
description="Popout to infield",
ab_roll=ab_roll,
hit_location=hit_location,
is_out=True,
)
# ==================== Groundballs ====================
if outcome in [
PlayOutcome.GROUNDBALL_A,
PlayOutcome.GROUNDBALL_B,
PlayOutcome.GROUNDBALL_C,
]:
# Business rule: hit_location only matters when there are runners on base
# AND less than 2 outs (for fielding choices and runner advancement)
has_runners = (
state.on_first is not None
or state.on_second is not None
or state.on_third is not None
)
needs_hit_location = has_runners and state.outs < 2
if needs_hit_location and not hit_location:
raise ValueError(
f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. "
f"Current situation: {state.outs} outs, runners on: "
f"{'1B ' if state.on_first else ''}"
f"{'2B ' if state.on_second else ''}"
f"{'3B' if state.on_third else ''}"
)
# Delegate to RunnerAdvancement for all groundball outcomes
advancement_result = self.runner_advancement.advance_runners(
outcome=outcome,
@ -306,6 +338,25 @@ class PlayResolver:
PlayOutcome.FLYOUT_BQ,
PlayOutcome.FLYOUT_C,
]:
# Business rule: hit_location only matters for FLYOUT_B and FLYOUT_BQ
# when there are runners on base AND less than 2 outs (for tag-up decisions)
if outcome in [PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ]:
has_runners = (
state.on_first is not None
or state.on_second is not None
or state.on_third is not None
)
needs_hit_location = has_runners and state.outs < 2
if needs_hit_location and not hit_location:
raise ValueError(
f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. "
f"Current situation: {state.outs} outs, runners on: "
f"{'1B ' if state.on_first else ''}"
f"{'2B ' if state.on_second else ''}"
f"{'3B' if state.on_third else ''}"
)
# Delegate to RunnerAdvancement for all flyball outcomes
advancement_result = self.runner_advancement.advance_runners(
outcome=outcome,
@ -414,6 +465,18 @@ class PlayResolver:
)
if outcome == PlayOutcome.SINGLE_UNCAPPED:
# Business rule: hit_location only matters when there is a runner on 1st, 2nd, or both
has_runner_on_scoring_bases = state.on_first is not None or state.on_second is not None
if has_runner_on_scoring_bases and not hit_location:
raise ValueError(
f"Hit location required for {outcome.value} when runner on 1st or 2nd. "
f"Current situation: runners on: "
f"{'1B ' if state.on_first else ''}"
f"{'2B ' if state.on_second else ''}"
f"{'3B' if state.on_third else ''}"
)
# TODO Phase 3: Implement uncapped hit decision tree
# For now, treat as SINGLE_1
runners_advanced = self._advance_on_single_1(state)
@ -470,6 +533,18 @@ class PlayResolver:
)
if outcome == PlayOutcome.DOUBLE_UNCAPPED:
# Business rule: hit_location only matters when there is a runner on 1st
has_runner_on_first = state.on_first is not None
if has_runner_on_first and not hit_location:
raise ValueError(
f"Hit location required for {outcome.value} when runner on 1st. "
f"Current situation: runners on: "
f"{'1B ' if state.on_first else ''}"
f"{'2B ' if state.on_second else ''}"
f"{'3B' if state.on_third else ''}"
)
# TODO Phase 3: Implement uncapped hit decision tree
# For now, treat as DOUBLE_2
runners_advanced = self._advance_on_double_2(state)

View File

@ -415,14 +415,85 @@ class StateManager:
if state.on_third:
runner_count += 1
# Recover batter indices from lineups
# Initialize to 0 - will be corrected by _prepare_next_play()
state.away_team_batter_idx = 0
state.home_team_batter_idx = 0
# Recover outs from last play
outs_before = last_play.get("outs_before", 0)
outs_recorded = last_play.get("outs_recorded", 0)
outs_after = outs_before + outs_recorded
# Handle inning transitions - if 3+ outs, should be 0 (new inning/half)
# The games table current_inning/current_half should already reflect this
if outs_after >= 3:
state.outs = 0
else:
state.outs = outs_after
# Recover batter indices - find which batter is next based on last play
last_batter_order = last_play.get("batting_order")
logger.info(f"Recovery: last_batter_order from play #{last_play['play_number']} = {last_batter_order}")
if last_batter_order:
# Next batter is last_batter + 1, wrapping at 9
next_batter_order = (last_batter_order % 9) + 1
# Set the correct index for the batting team
# Index is order - 1 (0-indexed)
next_batter_idx = next_batter_order - 1
if batting_team_id == away_team_id:
state.away_team_batter_idx = next_batter_idx
state.home_team_batter_idx = 0
else:
state.home_team_batter_idx = next_batter_idx
state.away_team_batter_idx = 0
logger.info(f"Recovery: Set batter indices - next_order={next_batter_order}, next_idx={next_batter_idx}, batting_team={batting_team_id}")
# Update current_batter to match the recovered batter index
# Get batting lineup sorted by batting_order
batting_lineup = [
get_lineup_player(lineup["id"])
for lineup in lineups
if lineup.get("team_id") == batting_team_id
and lineup.get("batting_order") is not None
and lineup.get("is_active")
]
logger.info(f"Recovery: Found {len(batting_lineup)} batters (before None filter)")
# Filter out None values (if any)
batting_lineup = [b for b in batting_lineup if b is not None]
logger.info(f"Recovery: {len(batting_lineup)} batters after None filter")
batting_lineup_sorted = sorted(
batting_lineup, key=lambda x: x.batting_order or 0
)
logger.info(f"Recovery: Sorted lineup has {len(batting_lineup_sorted)} batters")
# Set current_batter to the batter at next_batter_idx
if next_batter_idx < len(batting_lineup_sorted):
state.current_batter = batting_lineup_sorted[next_batter_idx]
logger.info(
f"Recovery: ✓ Set current_batter to order={next_batter_order}, idx={next_batter_idx}, "
f"card_id={state.current_batter.card_id}, batting_order={state.current_batter.batting_order}"
)
else:
logger.warning(
f"Recovery: ✗ Batter index {next_batter_idx} out of range for batting order "
f"(lineup size: {len(batting_lineup_sorted)})"
)
logger.info(f"Recovery: Calculated next_order={next_batter_order}, next_idx={next_batter_idx}")
else:
logger.info("Recovery: No last_batter_order found - using defaults")
# Fallback if no batting order in last play
state.away_team_batter_idx = 0
state.home_team_batter_idx = 0
# Always start at awaiting_defensive on recovery
# (Users can re-submit decisions if they refreshed mid-workflow)
state.decision_phase = "awaiting_defensive"
logger.debug(
f"Recovered state from play {last_play['play_number']}: "
f"{runner_count} runners on base"
f"{runner_count} runners on base, {state.outs} outs"
)
else:
logger.debug("No completed plays found - initializing fresh state")

View File

@ -83,6 +83,10 @@ socket_app = socketio.ASGIApp(sio, app)
connection_manager = ConnectionManager(sio)
register_handlers(sio, connection_manager)
# Configure game engine with connection manager for real-time event emission
from app.core.game_engine import game_engine
game_engine.set_connection_manager(connection_manager)
# Include API routes
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])

View File

@ -202,6 +202,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"game_id": str(game_id),
"roll_id": ab_roll.roll_id,
"d6_one": ab_roll.d6_one,
"d6_two_a": ab_roll.d6_two_a,
"d6_two_b": ab_roll.d6_two_b,
"d6_two_total": ab_roll.d6_two_total,
"chaos_d20": ab_roll.chaos_d20,
"resolution_d20": ab_roll.resolution_d20,
@ -305,17 +307,9 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
# Convert to PlayOutcome enum
outcome = PlayOutcome(submission.outcome)
# Validate hit location is provided when required
if outcome.requires_hit_location() and not submission.hit_location:
await manager.emit_to_user(
sid,
"outcome_rejected",
{
"message": f"Outcome {outcome.value} requires hit_location",
"field": "hit_location",
},
)
return
# NOTE: Business rule validation (e.g., when hit_location is required based on
# game state) is handled in PlayResolver, not here. This layer only validates
# basic input format and type checking.
# Check for pending roll BEFORE accepting outcome
if not state.pending_manual_roll:
@ -337,27 +331,12 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
+ (f" to {submission.hit_location}" if submission.hit_location else "")
)
# Confirm acceptance to submitter
await manager.emit_to_user(
sid,
"outcome_accepted",
{
"game_id": str(game_id),
"outcome": outcome.value,
"hit_location": submission.hit_location,
},
)
logger.info(
f"Processing manual outcome with roll {ab_roll.roll_id}: "
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
f"chaos={ab_roll.chaos_d20}"
)
# Clear pending roll (one-time use)
state.pending_manual_roll = None
state_manager.update_state(game_id, state)
# Process manual outcome through game engine
try:
result = await game_engine.resolve_manual_play(
@ -367,6 +346,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
hit_location=submission.hit_location,
)
# Clear pending roll only AFTER successful validation (one-time use)
state.pending_manual_roll = None
state_manager.update_state(game_id, state)
# Confirm acceptance to submitter AFTER successful validation
await manager.emit_to_user(
sid,
"outcome_accepted",
{
"game_id": str(game_id),
"outcome": outcome.value,
"hit_location": submission.hit_location,
},
)
# Build play result data
play_result_data = {
"game_id": str(game_id),
@ -377,7 +371,9 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"outs_recorded": result.outs_recorded,
"runs_scored": result.runs_scored,
"batter_result": result.batter_result,
"runners_advanced": result.runners_advanced,
"runners_advanced": [
{"from": adv[0], "to": adv[1]} for adv in result.runners_advanced
],
"is_hit": result.is_hit,
"is_out": result.is_out,
"is_walk": result.is_walk,
@ -414,6 +410,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
f"Manual play resolved for game {game_id}: {result.description}"
)
# Broadcast updated game state so frontend sees new batter, outs, etc.
updated_state = state_manager.get_state(game_id)
if updated_state:
await manager.broadcast_to_game(
str(game_id),
"game_state_update",
updated_state.model_dump(mode="json"),
)
logger.debug(f"Broadcast updated game state after play resolution")
except GameValidationError as e:
# Game engine validation error (e.g., missing hit location)
await manager.emit_to_user(
@ -422,6 +428,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
logger.warning(f"Manual play validation failed: {e}")
return
except ValueError as e:
# Business logic validation error from PlayResolver
await manager.emit_to_user(
sid, "outcome_rejected", {"message": str(e), "field": "validation"}
)
logger.warning(f"Manual play business logic validation failed: {e}")
return
except Exception as e:
# Unexpected error during resolution
logger.error(f"Error resolving manual play: {e}", exc_info=True)