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:
parent
bcbf6036c7
commit
9627a79dce
@ -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/`
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user