268 lines
9.8 KiB
TypeScript
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);
|
|
}
|
|
}
|