Conversation
Add schema for persisting blackjack game state: - games table stores full game state including deck, hands, bets, timers - game_channels table maps games to Discord channels for bot recovery Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add functions for serializing/deserializing cards and players: - card_to_str/str_to_card: Convert cards to/from "H10", "S14" format - serialize_hand/deserialize_hand: Handle lists of cards - serialize_player/deserialize_player: Handle player state with hands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Full game state serialization/deserialization: - to_dict() serializes all game state including deck, hands, bets, timing - from_dict() restores game with timing adjustment to preserve elapsed time - Timing fields are offset relative to time_last_event Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add CRUD methods for game state persistence: - save_game/load_game: Save and load individual game state - load_all_active_games: Load all games on server startup - delete_game: Remove game when finished - save_game_channel/load_game_channels/delete_game_channel: Bot recovery Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Test card, player, and game serialization roundtrips: - Card and hand serialization format verification - Player state preservation - Full game state roundtrip through to_dict/from_dict - Timing adjustment logic verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Server-side persistence: - Load games from database on startup via _load_games_from_db() - Save games after player actions in listen() - Save games after state changes in tick() - Save channel info on new_game for bot recovery - Accept guild_id/channel_id in new_game requests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Server handles list_games request: - Bot sends list_games with request_id - Server responds with all active games and their channel associations - Response includes game_id, state, guild_id, channel_id Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bot recovery protocol: - On ready, bot sends list_games request to server - Handles list_games response and recreates game wrappers - Subscribes to game topics and announces reconnection - Include guild_id/channel_id in new_game requests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Test server restart game persistence: - Game state preserved across server restart - Player bets preserved (no double-deduction) - list_games returns active games with channel info for bot recovery Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This pull request implements game state persistence to allow the SaloonBot server and Discord bot to survive restarts without losing active games. The changes enable games to be saved to MySQL and restored on server startup, with the bot able to reconnect to ongoing games.
Changes:
- Added database tables (
gamesandgame_channels) and operations for saving/loading game state - Implemented serialization/deserialization methods for complete game state including cards, players, bets, and timing information
- Added bot recovery mechanism to restore game tracking when bot reconnects
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| cardgames/database.py | Added database schema for games/game_channels tables and CRUD operations for game state persistence |
| cardgames/blackjack.py | Added serialization helpers and to_dict/from_dict methods for game state with timing adjustment logic |
| cardgames/casino.py | Added game loading on startup, auto-save after actions/state changes, and list_games handler for bot recovery |
| bot.py | Added list_games request on startup and game restoration logic to resubscribe to active games |
| test.py | Added comprehensive unit tests for serialization/deserialization roundtrip and timing adjustments |
| test_e2e.py | Added E2E tests for game persistence across server restarts and list_games functionality |
| # Try to get the channel | ||
| channel = self.bot.get_channel(channel_id) | ||
| if not channel: | ||
| logging.warning(f"Could not find channel {channel_id} for game {game_id}") | ||
| continue |
There was a problem hiding this comment.
When the bot cannot find a channel during game restoration, the game remains active in the database but the bot skips tracking it. This creates orphaned games that persist in the database but cannot be interacted with.
Consider notifying the server to clean up games where the bot cannot restore the channel, or implementing a mechanism to mark these games as abandoned so they can be cleaned up later.
| return { | ||
| 'game_id': result['game_id'], | ||
| 'state': result['state'], | ||
| 'current_player_idx': result['current_player_idx'], | ||
| 'time_betting_started': result['time_betting_started'], | ||
| 'time_last_hand_ended': result['time_last_hand_ended'], | ||
| 'time_last_event': result['time_last_event'], | ||
| 'deck': json.loads(result['deck_json']), | ||
| 'discards': json.loads(result['discards_json']), | ||
| 'dealer_hand': json.loads(result['dealer_hand_json']), | ||
| 'players': json.loads(result['players_json']), | ||
| 'players_waiting': json.loads(result['players_waiting_json']), | ||
| 'bets': json.loads(result['bets_json']), | ||
| } |
There was a problem hiding this comment.
If the JSON data stored in the database becomes corrupted or malformed, the json.loads() calls will raise a json.JSONDecodeError which is not caught. This will cause the entire game loading process to fail with an unhandled exception.
Consider wrapping the JSON parsing in try-except blocks and either:
- Logging a warning and skipping corrupted game records
- Implementing a fallback or recovery mechanism
- At minimum, catching and re-raising with more context about which game and field failed to parse
| games.append({ | ||
| 'game_id': result['game_id'], | ||
| 'state': result['state'], | ||
| 'current_player_idx': result['current_player_idx'], | ||
| 'time_betting_started': result['time_betting_started'], | ||
| 'time_last_hand_ended': result['time_last_hand_ended'], | ||
| 'time_last_event': result['time_last_event'], | ||
| 'deck': json.loads(result['deck_json']), | ||
| 'discards': json.loads(result['discards_json']), | ||
| 'dealer_hand': json.loads(result['dealer_hand_json']), | ||
| 'players': json.loads(result['players_json']), | ||
| 'players_waiting': json.loads(result['players_waiting_json']), | ||
| 'bets': json.loads(result['bets_json']), | ||
| }) |
There was a problem hiding this comment.
Similar to the issue in load_game, corrupted JSON data will cause load_all_active_games to fail with an unhandled exception. This is particularly problematic because it happens during server startup in _load_games_from_db, which could prevent the entire server from starting.
Consider implementing error recovery to skip corrupted games and continue loading valid ones, with appropriate logging.
| games.append({ | |
| 'game_id': result['game_id'], | |
| 'state': result['state'], | |
| 'current_player_idx': result['current_player_idx'], | |
| 'time_betting_started': result['time_betting_started'], | |
| 'time_last_hand_ended': result['time_last_hand_ended'], | |
| 'time_last_event': result['time_last_event'], | |
| 'deck': json.loads(result['deck_json']), | |
| 'discards': json.loads(result['discards_json']), | |
| 'dealer_hand': json.loads(result['dealer_hand_json']), | |
| 'players': json.loads(result['players_json']), | |
| 'players_waiting': json.loads(result['players_waiting_json']), | |
| 'bets': json.loads(result['bets_json']), | |
| }) | |
| try: | |
| game = { | |
| 'game_id': result['game_id'], | |
| 'state': result['state'], | |
| 'current_player_idx': result['current_player_idx'], | |
| 'time_betting_started': result['time_betting_started'], | |
| 'time_last_hand_ended': result['time_last_hand_ended'], | |
| 'time_last_event': result['time_last_event'], | |
| 'deck': json.loads(result['deck_json']), | |
| 'discards': json.loads(result['discards_json']), | |
| 'dealer_hand': json.loads(result['dealer_hand_json']), | |
| 'players': json.loads(result['players_json']), | |
| 'players_waiting': json.loads(result['players_waiting_json']), | |
| 'bets': json.loads(result['bets_json']), | |
| } | |
| except json.JSONDecodeError as e: | |
| logging.error( | |
| "Error decoding JSON for game %s: %s", | |
| result.get('game_id'), | |
| e, | |
| ) | |
| continue | |
| games.append(game) |
| @commands.Cog.listener() | ||
| async def on_ready(self): | ||
| if self.subscribe_task is None or self.subscribe_task.done(): | ||
| self.subscribe_task = asyncio.create_task(self.try_subscribe()) | ||
| if not self.listen.is_running(): | ||
| self.listen.start() | ||
|
|
||
| # Request list of active games for recovery | ||
| await self._request_list_games() | ||
|
|
||
| logging.info("Blackjack cog initialized.") |
There was a problem hiding this comment.
The _request_list_games call happens in on_ready before the subscribed Event is set, which means the bot might attempt to publish the list_games request before it's subscribed to the response channel. While the error is caught, the bot won't receive the response if the server replies before the subscription is complete.
Consider waiting for the subscription to complete before requesting the list of games, or implementing a retry mechanism if no response is received within a timeout period.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
No description provided.