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:
parent
8ecce0f5ad
commit
9b03fb555b
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user