CLAUDE: Extract common resolution logic to _finalize_play method

Issue #5 from code review - resolve_play and resolve_manual_play shared
~70% of their code causing maintenance burden and divergent behavior risk.

Changes:
- Extracted common finalization logic to new _finalize_play() method
- resolve_play reduced from ~150 lines to ~60 lines
- resolve_manual_play reduced from ~135 lines to ~60 lines
- Single source of truth for: roll tracking, state capture, transaction
  handling, inning advance, cleanup, and state updates

Benefits:
- Changes to play finalization only need to be made in one place
- Reduced risk of divergent behavior between resolution modes
- Better testability and maintainability

All 739 unit tests passing (1 flaky probabilistic test occasionally fails).

🤖 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-19 16:44:04 -06:00
parent 72a3b94ce7
commit b95c5837b0
2 changed files with 124 additions and 191 deletions

View File

@ -286,7 +286,12 @@ Some methods seem designed for testing but are public API.
- Added optional `session` parameter to `save_play` and `update_game_state` in DatabaseOperations
- Wrapped multi-step DB ops in `resolve_play` and `resolve_manual_play` with single transaction
- Transaction commits atomically or rolls back entirely on failure
- [ ] High issues #5-11 pending
- [x] High issue #5 fixed (2025-11-19)
- Extracted common logic to `_finalize_play()` method
- `resolve_play` reduced from ~150 lines to ~60 lines
- `resolve_manual_play` reduced from ~135 lines to ~60 lines
- Single source of truth for: roll tracking, state capture, transaction handling, inning advance, cleanup
- [ ] High issues #6-11 pending
- [ ] Medium issues addressed
- [ ] Low issues addressed

View File

@ -404,6 +404,118 @@ class GameEngine:
logger.warning(f"Offensive decision timeout for game {state.game_id}, using default")
return OffensiveDecision() # All defaults
async def _finalize_play(
self,
state: GameState,
result: PlayResult,
ab_roll,
log_suffix: str = ""
) -> None:
"""
Common finalization logic for both resolve_play and resolve_manual_play.
Handles:
- Roll tracking for batch saving
- State capture and result application
- Transaction with DB operations (save play, update state, advance inning)
- Batch save rolls at inning boundary
- Prepare next play or cleanup on completion
- Clear decisions and update state
Args:
state: Current game state (modified in place)
result: Play result to apply
ab_roll: The dice roll to track
log_suffix: Optional suffix for log message (e.g., " (hit to SS)")
"""
game_id = state.game_id
# Track roll for batch saving at end of inning
if game_id not in self._rolls_this_inning:
self._rolls_this_inning[game_id] = []
self._rolls_this_inning[game_id].append(ab_roll)
# Capture state before applying result
state_before = {
'inning': state.inning,
'half': state.half,
'home_score': state.home_score,
'away_score': state.away_score,
'status': state.status
}
# Apply result to state (outs, score, runners) - before transaction
self._apply_play_result(state, result)
# Database operations in single transaction
async with AsyncSessionLocal() as session:
try:
# Save play to DB (uses snapshot from GameState)
await self._save_play_to_db(state, result, session=session)
# Update game state in DB only if something changed
if (state.inning != state_before['inning'] or
state.half != state_before['half'] or
state.home_score != state_before['home_score'] or
state.away_score != state_before['away_score'] or
state.status != state_before['status']):
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# Check for inning change
if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
# Commit entire transaction
await session.commit()
logger.debug("Committed play transaction successfully")
except Exception as e:
await session.rollback()
logger.error(f"Transaction failed, rolled back: {e}")
raise
# Batch save rolls at half-inning boundary (separate transaction - audit data)
if state.outs >= 3:
await self._batch_save_inning_rolls(game_id)
# Prepare next play or clean up if game completed
if state.status == "active":
await self._prepare_next_play(state)
elif state.status == "completed":
# Clean up per-game resources to prevent memory leaks
self._cleanup_game_resources(game_id)
# Clear decisions for next play
state.decisions_this_play = {}
state.pending_decision = "defensive"
# Update in-memory state
state_manager.update_state(game_id, state)
logger.info(f"Resolved play {state.play_count} for game {game_id}: {result.description}{log_suffix}")
async def resolve_play(
self,
game_id: UUID,
@ -413,15 +525,7 @@ class GameEngine:
xcheck_error: Optional[str] = None
) -> PlayResult:
"""
Resolve the current play with dice roll
Explicit orchestration sequence:
1. Resolve play with dice rolls
2. Save play to DB (uses snapshot from GameState)
3. Apply result to state (outs, score, runners)
4. Update game state in DB
5. Check for inning change (outs >= 3)
6. Prepare next play (always last step)
Resolve the current play with dice roll (testing/forced outcome method).
Args:
game_id: Game to resolve
@ -443,7 +547,6 @@ class GameEngine:
defensive_decision = DefensiveDecision(**state.decisions_this_play.get('defensive', {}))
offensive_decision = OffensiveDecision(**state.decisions_this_play.get('offensive', {}))
# STEP 1: Resolve play
# Create resolver for this game's league and mode
resolver = PlayResolver(league_id=state.league_id, auto_mode=state.auto_mode, state_manager=state_manager)
@ -471,91 +574,9 @@ class GameEngine:
forced_xcheck_error=xcheck_error
)
# Track roll for batch saving at end of inning
if game_id not in self._rolls_this_inning:
self._rolls_this_inning[game_id] = []
self._rolls_this_inning[game_id].append(result.ab_roll)
# Finalize the play (common logic)
await self._finalize_play(state, result, result.ab_roll)
# Capture state before applying result
state_before = {
'inning': state.inning,
'half': state.half,
'home_score': state.home_score,
'away_score': state.away_score,
'status': state.status
}
# STEP 3: Apply result to state (outs, score, runners) - before transaction
self._apply_play_result(state, result)
# STEP 2, 4, 5: Database operations in single transaction
async with AsyncSessionLocal() as session:
try:
# STEP 2: Save play to DB (uses snapshot from GameState)
await self._save_play_to_db(state, result, session=session)
# STEP 4: Update game state in DB only if something changed
if (state.inning != state_before['inning'] or
state.half != state_before['half'] or
state.home_score != state_before['home_score'] or
state.away_score != state_before['away_score'] or
state.status != state_before['status']):
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# STEP 5: Check for inning change
if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
# Commit entire transaction
await session.commit()
logger.debug("Committed play transaction successfully")
except Exception as e:
await session.rollback()
logger.error(f"Transaction failed, rolled back: {e}")
raise
# Batch save rolls at half-inning boundary (separate transaction - audit data)
if state.outs >= 3:
await self._batch_save_inning_rolls(game_id)
# STEP 6: Prepare next play or clean up if game completed
if state.status == "active":
await self._prepare_next_play(state)
elif state.status == "completed":
# Clean up per-game resources to prevent memory leaks
self._cleanup_game_resources(game_id)
# Clear decisions for next play
state.decisions_this_play = {}
state.pending_decision = "defensive"
# Update in-memory state
state_manager.update_state(game_id, state)
logger.info(f"Resolved play {state.play_count} for game {game_id}: {result.description}")
return result
async def resolve_manual_play(
@ -574,14 +595,6 @@ class GameEngine:
3. Players submit the outcome they see
4. Server validates and processes with the provided outcome
Orchestration sequence (same as resolve_play):
1. Resolve play with manual outcome (uses ab_roll for audit trail)
2. Save play to DB
3. Apply result to state
4. Update game state in DB
5. Check for inning change
6. Prepare next play
Args:
game_id: Game to resolve
ab_roll: The dice roll (server-rolled for fairness)
@ -611,7 +624,6 @@ class GameEngine:
defensive_decision = DefensiveDecision(**state.decisions_this_play.get('defensive', {}))
offensive_decision = OffensiveDecision(**state.decisions_this_play.get('offensive', {}))
# STEP 1: Resolve play with manual outcome
# Create resolver for this game's league and mode
resolver = PlayResolver(league_id=state.league_id, auto_mode=state.auto_mode, state_manager=state_manager)
@ -625,94 +637,10 @@ class GameEngine:
ab_roll=ab_roll
)
# Track roll for batch saving at end of inning (same as auto mode)
if game_id not in self._rolls_this_inning:
self._rolls_this_inning[game_id] = []
self._rolls_this_inning[game_id].append(ab_roll)
# Finalize the play (common logic)
log_suffix = f" (hit to {hit_location})" if hit_location else ""
await self._finalize_play(state, result, ab_roll, log_suffix)
# Capture state before applying result
state_before = {
'inning': state.inning,
'half': state.half,
'home_score': state.home_score,
'away_score': state.away_score,
'status': state.status
}
# STEP 3: Apply result to state - before transaction
self._apply_play_result(state, result)
# STEP 2, 4, 5: Database operations in single transaction
async with AsyncSessionLocal() as session:
try:
# STEP 2: Save play to DB
await self._save_play_to_db(state, result, session=session)
# STEP 4: Update game state in DB only if something changed
if (state.inning != state_before['inning'] or
state.half != state_before['half'] or
state.home_score != state_before['home_score'] or
state.away_score != state_before['away_score'] or
state.status != state_before['status']):
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# STEP 5: Check for inning change
if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
# Commit entire transaction
await session.commit()
logger.debug("Committed manual play transaction successfully")
except Exception as e:
await session.rollback()
logger.error(f"Manual play transaction failed, rolled back: {e}")
raise
# Batch save rolls at half-inning boundary (separate transaction - audit data)
if state.outs >= 3:
await self._batch_save_inning_rolls(game_id)
# STEP 6: Prepare next play or clean up if game completed
if state.status == "active":
await self._prepare_next_play(state)
elif state.status == "completed":
# Clean up per-game resources to prevent memory leaks
self._cleanup_game_resources(game_id)
# Clear decisions for next play
state.decisions_this_play = {}
state.pending_decision = "defensive"
# Update in-memory state
state_manager.update_state(game_id, state)
logger.info(
f"Resolved manual play {state.play_count} for game {game_id}: "
f"{result.description}" + (f" (hit to {hit_location})" if hit_location else "")
)
return result
def _apply_play_result(self, state: GameState, result: PlayResult) -> None: