import { IGameSnapshotRepository } from '../../Domain/IRepository/IGameSnapshotRepository'; import { GameSnapshotAggregate, SnapshotTrigger, GameStateSnapshot, PlayerSnapshot } from '../../Domain/Game/GameSnapshotAggregate'; import { RedisService } from './RedisService'; import { logOther, logError } from './Logger'; export class GameSnapshotService { private static readonly SNAPSHOT_INTERVAL = 5; // Every 5 turns private static readonly MAX_SNAPSHOTS_PER_GAME = 20; // Keep last 20 snapshots constructor( private snapshotRepository: IGameSnapshotRepository, private redisService: RedisService ) {} /** * Create a game state snapshot */ async createSnapshot( gameId: string, turnNumber: number, trigger: SnapshotTrigger, notes?: string ): Promise { try { // Gather current game state from Redis const gameState = await this.getCurrentGameState(gameId); if (!gameState) { logError('Cannot create snapshot: game state not found', new Error(`Game ${gameId} not in Redis`)); return; } // Gather Redis state (pending actions, timers, etc.) const redisState = await this.getRedisState(gameId); // Create snapshot const snapshot = new GameSnapshotAggregate(); snapshot.gameid = gameId; snapshot.turnNumber = turnNumber; snapshot.trigger = trigger; snapshot.gameState = gameState; snapshot.redisState = redisState; snapshot.notes = notes || null; await this.snapshotRepository.save(snapshot); // Cleanup old snapshots await this.snapshotRepository.deleteOldSnapshots( gameId, GameSnapshotService.MAX_SNAPSHOTS_PER_GAME ); logOther(`Game snapshot created: ${trigger}`, { gameId, turnNumber, trigger }); } catch (error) { logError('Failed to create game snapshot', error as Error); // Don't throw - snapshots shouldn't break game flow } } /** * Check if snapshot should be created (every N turns) */ shouldCreateSnapshot(turnNumber: number): boolean { return turnNumber % GameSnapshotService.SNAPSHOT_INTERVAL === 0; } /** * Restore game state from latest snapshot */ async restoreFromSnapshot(gameId: string): Promise { try { const snapshot = await this.snapshotRepository.findLatestByGameId(gameId); if (!snapshot) { logOther(`No snapshot found for game ${gameId}`); return false; } // Restore game state to Redis await this.restoreGameState(gameId, snapshot.gameState); // Restore Redis state (pending actions, timers) if (snapshot.redisState) { await this.restoreRedisState(gameId, snapshot.redisState); } logOther(`Game state restored from snapshot`, { gameId, turnNumber: snapshot.turnNumber, trigger: snapshot.trigger, age: Date.now() - snapshot.createdat.getTime() }); return true; } catch (error) { logError('Failed to restore game from snapshot', error as Error); return false; } } /** * Get current game state from Redis */ private async getCurrentGameState(gameId: string): Promise { try { // Get game state const gameStateKey = `game_state:${gameId}`; const gameStateJson = await this.redisService.get(gameStateKey); if (!gameStateJson) return null; const gameState = JSON.parse(gameStateJson); // Get player positions const playerPositions: PlayerSnapshot[] = []; const positionsKey = `player_positions:${gameId}`; const positionsJson = await this.redisService.get(positionsKey); if (positionsJson) { const positions = JSON.parse(positionsJson); for (const [playerId, data] of Object.entries(positions)) { const posData = data as any; // Get extra turns const extraTurnsKey = `extra_turns:${gameId}:${playerId}`; const extraTurns = parseInt(await this.redisService.get(extraTurnsKey) || '0'); // Get turns to lose const turnsToLoseKey = `turns_to_lose:${gameId}:${playerId}`; const turnsToLose = parseInt(await this.redisService.get(turnsToLoseKey) || '0'); playerPositions.push({ playerId: playerId, playerName: posData.playerName || 'Unknown', boardPosition: posData.boardPosition || 0, extraTurns, turnsToLose, isOnline: posData.isOnline !== false }); } } // Get board data const boardKey = `board_data:${gameId}`; const boardJson = await this.redisService.get(boardKey); const boardFields = boardJson ? JSON.parse(boardJson).fields : undefined; return { currentPlayer: gameState.currentPlayer, currentPlayerName: gameState.currentPlayerName || 'Unknown', turnNumber: gameState.turnNumber || 1, turnOrder: gameState.turnOrder || [], playerPositions, boardFields, deckStates: undefined, // TODO: Add deck states if needed pendingActions: undefined }; } catch (error) { logError('Error getting current game state', error as Error); return null; } } /** * Get Redis state (pending cards, decisions, etc.) */ private async getRedisState(gameId: string): Promise { const redisState: any = { pendingCards: {}, pendingDecisions: {}, timers: {} }; try { // Get all keys for this game const pattern = `*${gameId}*`; const keys = await this.redisService['client'].keys(pattern); for (const key of keys) { // Store non-critical state for reference if (key.includes('pending_card') || key.includes('pending_decision')) { const value = await this.redisService.get(key); if (value) { redisState.pendingCards[key] = value; } } } } catch (error) { logError('Error getting Redis state', error as Error); } return redisState; } /** * Restore game state to Redis */ private async restoreGameState(gameId: string, state: GameStateSnapshot): Promise { // Restore game state const gameStateKey = `game_state:${gameId}`; await this.redisService.setWithExpiry(gameStateKey, JSON.stringify({ currentPlayer: state.currentPlayer, currentPlayerName: state.currentPlayerName, turnNumber: state.turnNumber, turnOrder: state.turnOrder }), 3600); // Restore player positions const positionsKey = `player_positions:${gameId}`; const positions: any = {}; for (const player of state.playerPositions) { positions[player.playerId] = { playerName: player.playerName, boardPosition: player.boardPosition, isOnline: player.isOnline }; // Restore extra turns if (player.extraTurns > 0) { const extraTurnsKey = `extra_turns:${gameId}:${player.playerId}`; await this.redisService.setWithExpiry(extraTurnsKey, player.extraTurns.toString(), 3600); } // Restore turns to lose if (player.turnsToLose > 0) { const turnsToLoseKey = `turns_to_lose:${gameId}:${player.playerId}`; await this.redisService.setWithExpiry(turnsToLoseKey, player.turnsToLose.toString(), 3600); } } await this.redisService.setWithExpiry(positionsKey, JSON.stringify(positions), 3600); // Restore board data if available if (state.boardFields) { const boardKey = `board_data:${gameId}`; await this.redisService.setWithExpiry(boardKey, JSON.stringify({ fields: state.boardFields }), 3600); } } /** * Restore Redis state (partial - pending actions may need re-triggering) */ private async restoreRedisState(gameId: string, redisState: any): Promise { // Note: Pending cards and timers should be recreated by game logic // This is just for reference/debugging logOther('Redis state reference saved (timers/pending actions need manual restart)'); } /** * Cleanup snapshots for finished game */ async cleanupGameSnapshots(gameId: string): Promise { try { await this.snapshotRepository.deleteByGameId(gameId); logOther(`Game snapshots cleaned up for game ${gameId}`); } catch (error) { logError('Failed to cleanup game snapshots', error as Error); } } /** * Get snapshot history for debugging */ async getSnapshotHistory(gameId: string): Promise { return await this.snapshotRepository.findByGameId(gameId); } }