CLAUDE: Implement play rollback functionality for error recovery

Add ability to roll back the last N plays, useful for correcting mistakes
or recovering from corrupted plays. Deletes plays from database and
reconstructs game state by replaying remaining plays.

Database Operations (app/database/operations.py):
- delete_plays_after(): Delete plays with play_number > target
- delete_substitutions_after(): Delete lineup entries with after_play >= target
- delete_rolls_after(): Delete dice rolls (kept for reference, not used)

Game Engine (app/core/game_engine.py):
- rollback_plays(): Main rollback orchestration
  - Validates: num_plays > 0, enough plays exist, game not completed
  - Deletes plays and substitutions from database
  - Clears in-memory roll tracking
  - Calls state_manager.recover_game() to rebuild state
  - Returns updated GameState

Terminal Client (terminal_client/commands.py, terminal_client/repl.py):
- rollback_plays(): Command wrapper with user-friendly output
- do_rollback(): REPL command with argument parsing

Usage:
   > rollback 3

Validations:
- Cannot roll back more plays than exist
- Cannot roll back completed games
- Rolling back across innings is allowed
- Substitutions after rolled-back plays are undone

Testing:
-  Successfully rolls back 2 plays from 5-play game
-  Correctly validates rollback of 10 plays when only 2 exist
-  Game state properly reconstructed via replay

Note: Dice rolls kept in database for auditing (don't affect state).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-30 16:02:51 -05:00
parent 8ecce0f5ad
commit 9b03fb555b
4 changed files with 271 additions and 0 deletions

View File

@ -789,6 +789,73 @@ class GameEngine:
"""Get current game state"""
return state_manager.get_state(game_id)
async def rollback_plays(self, game_id: UUID, num_plays: int) -> GameState:
"""
Roll back the last N plays.
Deletes plays from the database and reconstructs game state by replaying
remaining plays. Also removes any substitutions that occurred during the
rolled-back plays.
Args:
game_id: Game to roll back
num_plays: Number of plays to roll back (must be > 0)
Returns:
Updated GameState after rollback
Raises:
ValueError: If num_plays invalid, game not found, or game completed
"""
# 1. Validate
state = state_manager.get_state(game_id)
if not state:
raise ValueError(f"Game {game_id} not found")
if num_plays <= 0:
raise ValueError("num_plays must be greater than 0")
if state.play_count < num_plays:
raise ValueError(
f"Cannot roll back {num_plays} plays (only {state.play_count} exist)"
)
if state.status == "completed":
raise ValueError("Cannot roll back a completed game")
# 2. Calculate target play number
target_play = state.play_count - num_plays
logger.info(
f"Rolling back {num_plays} plays for game {game_id} "
f"(from play {state.play_count} to play {target_play})"
)
# 3. Delete plays from database
deleted_plays = await self.db_ops.delete_plays_after(game_id, target_play)
logger.info(f"Deleted {deleted_plays} plays")
# 4. Delete substitutions that occurred after target play
deleted_subs = await self.db_ops.delete_substitutions_after(game_id, target_play)
logger.info(f"Deleted {deleted_subs} substitutions")
# Note: We don't delete dice rolls from the rolls table - they're kept for auditing
# and don't affect game state reconstruction
# 5. Clear in-memory roll tracking for this game
if game_id in self._rolls_this_inning:
del self._rolls_this_inning[game_id]
# 6. Recover game state by replaying remaining plays
logger.info(f"Recovering game state for {game_id}")
new_state = await state_manager.recover_game(game_id)
logger.info(
f"Rollback complete - now at play {new_state.play_count}, "
f"inning {new_state.inning} {new_state.half}"
)
return new_state
async def end_game(self, game_id: UUID) -> GameState:
"""
Manually end a game

View File

@ -751,3 +751,121 @@ class DatabaseOperations:
except Exception as e:
logger.error(f"Failed to get rolls for game: {e}")
raise
# ============================================================================
# ROLLBACK OPERATIONS
# ============================================================================
async def delete_plays_after(
self,
game_id: UUID,
after_play_number: int
) -> int:
"""
Delete all plays after a specific play number.
Used for rolling back plays when a mistake is made.
Args:
game_id: Game to delete plays from
after_play_number: Delete plays with play_number > this value
Returns:
Number of plays deleted
"""
async with AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(Play).where(
Play.game_id == game_id,
Play.play_number > after_play_number
)
result = await session.execute(stmt)
await session.commit()
deleted_count = result.rowcount
logger.info(f"Deleted {deleted_count} plays after play {after_play_number} for game {game_id}")
return deleted_count
except Exception as e:
await session.rollback()
logger.error(f"Failed to delete plays: {e}")
raise
async def delete_substitutions_after(
self,
game_id: UUID,
after_play_number: int
) -> int:
"""
Delete all substitutions that occurred after a specific play number.
Used for rolling back lineups when plays are deleted.
Args:
game_id: Game to delete substitutions from
after_play_number: Delete lineup entries with after_play >= this value
Returns:
Number of lineup entries deleted
"""
async with AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(Lineup).where(
Lineup.game_id == game_id,
Lineup.after_play >= after_play_number
)
result = await session.execute(stmt)
await session.commit()
deleted_count = result.rowcount
logger.info(f"Deleted {deleted_count} substitutions after play {after_play_number} for game {game_id}")
return deleted_count
except Exception as e:
await session.rollback()
logger.error(f"Failed to delete substitutions: {e}")
raise
async def delete_rolls_after(
self,
game_id: UUID,
after_play_number: int
) -> int:
"""
Delete all dice rolls after a specific play number.
Used for rolling back dice roll history when plays are deleted.
Args:
game_id: Game to delete rolls from
after_play_number: Delete rolls with play_number > this value
Returns:
Number of rolls deleted
"""
async with AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(Roll).where(
Roll.game_id == game_id,
Roll.play_number > after_play_number
)
result = await session.execute(stmt)
await session.commit()
deleted_count = result.rowcount
logger.info(f"Deleted {deleted_count} rolls after play {after_play_number} for game {game_id}")
return deleted_count
except Exception as e:
await session.rollback()
logger.error(f"Failed to delete rolls: {e}")
raise

View File

@ -498,6 +498,43 @@ class GameCommands:
else:
display.console.print(f"[dim]LHB pulls right (1B, 2B, RF)[/dim]")
async def rollback_plays(self, game_id: UUID, num_plays: int) -> bool:
"""
Roll back the last N plays.
Deletes plays and reconstructs game state from remaining plays.
Args:
game_id: Game to roll back
num_plays: Number of plays to roll back
Returns:
True if successful, False otherwise
"""
try:
display.print_info(f"Rolling back {num_plays} play(s)...")
# Call game engine rollback
state = await game_engine.rollback_plays(game_id, num_plays)
display.print_success(f"✓ Rolled back {num_plays} play(s)")
display.console.print(f" [cyan]Now at play:[/cyan] {state.play_count}")
display.console.print(f" [cyan]Inning:[/cyan] {state.inning} {state.half}")
display.console.print(f" [cyan]Score:[/cyan] Away {state.away_score} - {state.home_score} Home")
# Show current game state
display.display_game_state(state)
return True
except ValueError as e:
display.print_error(f"Cannot roll back: {e}")
return False
except Exception as e:
display.print_error(f"Failed to roll back plays: {e}")
logger.exception("Rollback error")
return False
# Singleton instance
game_commands = GameCommands()

View File

@ -359,6 +359,55 @@ Press Ctrl+D or type 'quit' to exit.
self._run_async(_status())
def do_rollback(self, arg):
"""
Roll back the last N plays.
Usage: rollback <num_plays>
Arguments:
num_plays Number of plays to roll back (must be > 0)
Deletes the specified number of plays from the database and
reconstructs the game state by replaying the remaining plays.
Also removes any substitutions that occurred during the
rolled-back plays.
Use this for correcting mistakes or recovering from corrupted plays.
Examples:
rollback 1 # Undo the last play
rollback 3 # Undo the last 3 plays
rollback 5 # Undo the last 5 plays
"""
async def _rollback():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse argument
if not arg:
display.print_error("Usage: rollback <num_plays>")
display.console.print("Example: [cyan]rollback 3[/cyan]")
return
try:
num_plays = int(arg.strip())
except ValueError:
display.print_error(f"Invalid number: {arg}")
return
# Use shared command
await game_commands.rollback_plays(gid, num_plays)
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
logger.exception("Rollback error")
self._run_async(_rollback())
def do_quick_play(self, arg):
"""
Auto-play multiple plays with default decisions.