Files
SerpentRace/SerpentRace_Backend/src/Application/Services/GameSnapshotService.ts
T
2025-11-24 23:28:57 +01:00

268 lines
9.8 KiB
TypeScript

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<void> {
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<boolean> {
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<GameStateSnapshot | null> {
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<any> {
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<void> {
// 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<void> {
// 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<void> {
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<GameSnapshotAggregate[]> {
return await this.snapshotRepository.findByGameId(gameId);
}
}