final POC
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user