final POC

This commit is contained in:
magdo
2025-11-24 23:28:57 +01:00
parent ce02f55a99
commit 6b3446e9b6
49 changed files with 4634 additions and 4620 deletions
+21
View File
@@ -188,6 +188,17 @@ AppDataSource.initialize()
container.setSocketIO(webSocketService['io']);
gameWebSocketService = container.gameWebSocketService;
logStartup('Game WebSocket service initialized for /game namespace');
// Restore active games from snapshots (if any exist)
gameWebSocketService.restoreAllActiveGames()
.then(restoredCount => {
if (restoredCount > 0) {
logStartup(`Restored ${restoredCount} active game(s) from snapshots`);
}
})
.catch(error => {
logError('Failed to restore games from snapshots', error);
});
})
.catch((error) => {
const dbOptions = AppDataSource.options as any;
@@ -225,6 +236,16 @@ const server = httpServer.listen(PORT, () => {
const gracefulShutdown = async (signal: string) => {
logStartup(`Received ${signal}. Shutting down gracefully...`);
// Snapshot all active games before shutdown
if (gameWebSocketService) {
try {
const snapshotCount = await gameWebSocketService.snapshotAllActiveGames();
logStartup(`Created ${snapshotCount} game snapshot(s) before shutdown`);
} catch (error) {
logError('Failed to snapshot games before shutdown', error as Error);
}
}
server.close(() => {
logStartup('HTTP server closed');
@@ -273,10 +273,11 @@ router.delete('/users/:userId',
try {
const targetUserId = req.params.userId;
const adminUserId = (req as any).user.userId;
const softDelete = req.query.soft === 'true' || req.query.soft === undefined;
logRequest('Delete user endpoint accessed', req, res, { adminUserId, targetUserId });
logRequest('Delete user endpoint accessed', req, res, { adminUserId, targetUserId, softDelete });
const result = await container.deleteUserCommandHandler.execute({ id: targetUserId });
const result = await container.deleteUserCommandHandler.execute({ id: targetUserId, soft: softDelete });
if (!result) {
return res.status(404).json({ error: 'User not found' });
@@ -120,12 +120,14 @@ export class BoardGenerationService {
// Generate appropriate step value for field type
if (specialField.type === 'positive') {
// Positive fields: use positive step values (3-8 range for good gameplay)
const stepValue = Math.floor(Math.random() * 6) + 3; // 3-8
// Positive fields: use positive step values (1-3 range for balanced gameplay)
// Max movement: 3 × 6 (dice) = 18 steps
const stepValue = Math.floor(Math.random() * 3) + 1; // 1-3
fields[fieldIndex].stepValue = Math.min(stepValue, maxStepValue);
} else {
// Negative fields: use negative step values (-3 to -8 range)
const stepValue = -(Math.floor(Math.random() * 6) + 3); // -3 to -8
// Negative fields: use negative step values (-1 to -3 range)
// Max backward: -3 × 6 (dice) = -18 steps
const stepValue = -(Math.floor(Math.random() * 3) + 1); // -1 to -3
fields[fieldIndex].stepValue = Math.max(stepValue, minStepValue);
}
});
@@ -156,25 +158,33 @@ export class BoardGenerationService {
return finalPosition;
}
private getPatternModifier(position: number, positiveField: boolean): number {
// Pattern modifiers for strategic complexity:
public getPatternModifier(position: number, positiveField: boolean): number {
// Pattern modifiers STACK for strategic complexity:
// - Positions ending in 0 (10, 20, 30...): No modifier
// - Positions ending in 5 (15, 25, 35...): ±3 modifier
// - Positions divisible by 3 (9, 12, 21...): ±2 modifier
// - Odd positions (1, 7, 11...): ±1 modifier
// - Other even positions: No modifier
// Multiple conditions can apply and stack
if (position % 10 === 0) {
return 0; // Positions ending in 0
} else if (position % 10 === 5) {
return positiveField ? 3 : -3; // Positions ending in 5
} else if (position % 3 === 0) {
return positiveField ? 2 : -2; // Divisible by 3
} else if (position % 2 === 1) {
return positiveField ? 1 : -1; // Odd positions
} else {
return 0; // Other even positions
return 0; // Positions ending in 0 - no modifier
}
let modifier = 0;
const direction = positiveField ? 1 : -1;
// Check each condition and stack modifiers
if (position % 10 === 5) {
modifier += 3 * direction; // Positions ending in 5
}
if (position % 3 === 0) {
modifier += 2 * direction; // Divisible by 3
}
if (position % 2 === 1) {
modifier += 1 * direction; // Odd positions
}
return modifier;
}
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
@@ -49,7 +49,8 @@ export class JoinGameCommandHandler {
}
// Generate player ID for public games or use provided one
const actualPlayerId = command.playerId || uuidv4();
// For anonymous players (no playerId), use playerName as the identifier to allow rejoining
const actualPlayerId = command.playerId || `guest_${command.playerName}`;
// Validate game joinability (authentication/org checks done in router)
this.validateGameJoinability(game, actualPlayerId, command);
@@ -122,7 +123,7 @@ export class JoinGameCommandHandler {
private async updateGameInRedis(game: GameAggregate, command: JoinGameCommand & { playerId: string }): Promise<void> {
try {
const redisKey = `game:${game.id}`;
const redisKey = `game:${game.gamecode}`;
// Get existing game data from Redis or create new
let gameData: ActiveGameData;
@@ -189,9 +190,9 @@ export class JoinGameCommandHandler {
}
}
async getGameFromRedis(gameId: string): Promise<ActiveGameData | null> {
async getGameFromRedis(gameCode: string): Promise<ActiveGameData | null> {
try {
const redisKey = `game:${gameId}`;
const redisKey = `game:${gameCode}`;
const data = await this.redisService.get(redisKey);
return data ? JSON.parse(data) as ActiveGameData : null;
} catch (error) {
@@ -200,9 +201,9 @@ export class JoinGameCommandHandler {
}
}
async removePlayerFromRedis(gameId: string, playerId: string): Promise<void> {
async removePlayerFromRedis(gameCode: string, playerId: string): Promise<void> {
try {
const redisKey = `game:${gameId}`;
const redisKey = `game:${gameCode}`;
const existingData = await this.redisService.get(redisKey);
if (existingData) {
@@ -163,6 +163,7 @@ export class StartGameCommandHandler {
cardid: this.generateCardId(),
question: card.text,
answer: card.answer || undefined,
type: card.type, // Include card type for proper processing
consequence: card.consequence || null,
played: false,
playerid: undefined
@@ -25,6 +25,7 @@ export interface ActiveGamePlayData {
createdAt: Date;
startedAt: Date;
currentTurn: number; // Index of current player in turn order
currentPlayer: string; // ID of the player whose turn it is
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
websocketRoom: string;
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
@@ -131,10 +132,13 @@ export class StartGamePlayCommandHandler {
private async initializeGamePlayInRedis(game: GameAggregate, boardData: BoardData): Promise<void> {
try {
const redisKey = `gameplay:${game.id}`;
const redisKey = `gameplay:${game.gamecode}`;
// Get connected player names from Redis (stored by WebSocket)
const playerNamesMap = await this.getPlayerNames(game.gamecode);
// Generate random turn orders for all players
const playersWithPositions = this.initializePlayerPositions(game.players);
const playersWithPositions = this.initializePlayerPositions(game.players, playerNamesMap);
// Sort by turn order to create turn sequence
const turnSequence = [...playersWithPositions]
@@ -151,6 +155,7 @@ export class StartGamePlayCommandHandler {
createdAt: game.createdate,
startedAt: new Date(),
currentTurn: 0, // Start with first player in sequence
currentPlayer: turnSequence[0], // First player in turn sequence
turnSequence,
websocketRoom: `game_${game.gamecode}`,
gamePhase: 'starting',
@@ -160,13 +165,6 @@ export class StartGamePlayCommandHandler {
// Store game play data in Redis with TTL (24 hours)
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gamePlayData), 24 * 60 * 60);
// Create turn sequence mapping for quick lookups
await this.redisService.setWithExpiry(
`game_turns:${game.id}`,
JSON.stringify(turnSequence),
24 * 60 * 60
);
logOther('Game play initialized in Redis', {
gameId: game.id,
gameCode: game.gamecode,
@@ -182,7 +180,7 @@ export class StartGamePlayCommandHandler {
}
}
private initializePlayerPositions(playerIds: string[]): GamePlayerPosition[] {
private initializePlayerPositions(playerIds: string[], playerNamesMap: Map<string, string>): GamePlayerPosition[] {
const players: GamePlayerPosition[] = [];
// Generate random turn orders (1 to playerCount)
@@ -191,6 +189,7 @@ export class StartGamePlayCommandHandler {
playerIds.forEach((playerId, index) => {
players.push({
playerId,
playerName: playerNamesMap.get(playerId) || playerId, // Use mapped name or fallback to ID
position: 0, // All players start at position 0
turnOrder: turnOrders[index],
isOnline: true, // Assume online when game starts
@@ -203,6 +202,7 @@ export class StartGamePlayCommandHandler {
turnOrders: turnOrders,
playersData: players.map(p => ({
playerId: p.playerId,
playerName: p.playerName,
position: p.position,
turnOrder: p.turnOrder
}))
@@ -226,23 +226,18 @@ export class StartGamePlayCommandHandler {
private async notifyGameStart(game: GameAggregate): Promise<void> {
try {
// Get board data from Redis
const redisKey = `game_board_${game.id}`;
const boardDataStr = await this.redisService.get(redisKey);
if (!boardDataStr) {
logError('Board data not found in Redis during game start notification', new Error('Missing board data'));
return;
}
const boardData: BoardData = JSON.parse(boardDataStr);
// Get turn sequence from Redis
const gamePlayData = await this.getGamePlayFromRedis(game.id);
// Get game play data from Redis (contains board data)
const gamePlayData = await this.getGamePlayFromRedis(game.gamecode);
if (!gamePlayData) {
logError('Game play data not found in Redis', new Error('Missing game play data'));
return;
}
const boardData = gamePlayData.boardData;
if (!boardData) {
logError('Board data not found in game play data', new Error('Missing board data'));
return;
}
// Get WebSocket service from DIContainer and broadcast game start
const gameWebSocketService = DIContainer.getInstance().gameWebSocketService;
@@ -267,9 +262,9 @@ export class StartGamePlayCommandHandler {
}
}
async getGamePlayFromRedis(gameId: string): Promise<ActiveGamePlayData | null> {
async getGamePlayFromRedis(gameCode: string): Promise<ActiveGamePlayData | null> {
try {
const redisKey = `gameplay:${gameId}`;
const redisKey = `gameplay:${gameCode}`;
const data = await this.redisService.get(redisKey);
return data ? JSON.parse(data) as ActiveGamePlayData : null;
} catch (error) {
@@ -278,9 +273,9 @@ export class StartGamePlayCommandHandler {
}
}
async updatePlayerPosition(gameId: string, playerId: string, newPosition: number): Promise<void> {
async updatePlayerPosition(gameCode: string, playerId: string, newPosition: number): Promise<void> {
try {
const gameData = await this.getGamePlayFromRedis(gameId);
const gameData = await this.getGamePlayFromRedis(gameCode);
if (!gameData) {
throw new Error('Game session not found');
}
@@ -291,11 +286,11 @@ export class StartGamePlayCommandHandler {
player.position = newPosition;
// Save back to Redis
const redisKey = `gameplay:${gameId}`;
const redisKey = `gameplay:${gameCode}`;
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
logOther('Player position updated', {
gameId,
gameCode,
playerId,
newPosition
});
@@ -306,9 +301,9 @@ export class StartGamePlayCommandHandler {
}
}
async getNextPlayer(gameId: string): Promise<string | null> {
async getNextPlayer(gameCode: string): Promise<string | null> {
try {
const gameData = await this.getGamePlayFromRedis(gameId);
const gameData = await this.getGamePlayFromRedis(gameCode);
if (!gameData) {
return null;
}
@@ -321,6 +316,39 @@ export class StartGamePlayCommandHandler {
}
}
private async getPlayerNames(gameCode: string): Promise<Map<string, string>> {
try {
// Get active game data from Redis which contains player names
const activeGameKey = `game:${gameCode}`;
const activeGameStr = await this.redisService.get(activeGameKey);
const playerNamesMap = new Map<string, string>();
if (activeGameStr) {
const activeGame = JSON.parse(activeGameStr);
if (activeGame.currentPlayers && Array.isArray(activeGame.currentPlayers)) {
// Map playerIds to playerNames from active game data
activeGame.currentPlayers.forEach((player: any) => {
if (player.playerId && player.playerName) {
playerNamesMap.set(player.playerId, player.playerName);
}
});
}
}
logOther('Retrieved player names map', {
gameCode,
playerCount: playerNamesMap.size,
players: Array.from(playerNamesMap.entries()).map(([id, name]) => ({ id, name }))
});
return playerNamesMap;
} catch (error) {
logError('Failed to get player names', error instanceof Error ? error : new Error(String(error)));
return new Map();
}
}
async advanceTurn(gameId: string): Promise<string | null> {
try {
const gameData = await this.getGamePlayFromRedis(gameId);
@@ -81,15 +81,28 @@ export class CardDrawingService {
drawnCard.played = true;
drawnCard.playerid = playerId;
// Check if card has consequence field (joker/luck card) even without type
const hasConsequence = drawnCard.consequence !== undefined && drawnCard.consequence !== null;
// Prepare client data based on card type
// Only prepare for question cards (cards without consequence and with defined type)
let clientData: CardClientData | undefined;
try {
if (drawnCard.type !== undefined) {
if (!hasConsequence && drawnCard.type !== undefined) {
try {
clientData = this.cardProcessingService.prepareCardForClient(drawnCard);
} catch (error) {
// If client data preparation fails, still return the card but log the error
console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error);
}
} catch (error) {
// If client data preparation fails, still return the card but log the error
console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error);
} else if (!hasConsequence && drawnCard.type === undefined) {
// Card is missing type field - this shouldn't happen, log error
console.error(`Card ${drawnCard.cardid} is missing type field. Card data:`, {
cardId: drawnCard.cardid,
hasQuestion: !!drawnCard.question,
hasAnswer: !!drawnCard.answer,
hasConsequence,
cardKeys: Object.keys(drawnCard)
});
}
return {
@@ -413,7 +413,7 @@ export class CardProcessingService {
*/
private convertToBoolean(value: string): boolean {
const lowerValue = value.toLowerCase().trim();
return ['true', 'yes', '1', 'correct', 'right'].includes(lowerValue);
return ['true', 'yes', '1', 'correct', 'right', 'igaz'].includes(lowerValue);
}
/**
@@ -6,6 +6,8 @@ import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
import { IGameRepository } from '../../Domain/IRepository/IGameRepository';
import { ITurnHistoryRepository } from '../../Domain/IRepository/ITurnHistoryRepository';
import { IGameSnapshotRepository } from '../../Domain/IRepository/IGameSnapshotRepository';
// Repository Implementations
import { UserRepository } from '../../Infrastructure/Repository/UserRepository';
@@ -15,6 +17,8 @@ import { DeckRepository } from '../../Infrastructure/Repository/DeckRepository';
import { OrganizationRepository } from '../../Infrastructure/Repository/OrganizationRepository';
import { ContactRepository } from '../../Infrastructure/Repository/ContactRepository';
import { GameRepository } from '../../Infrastructure/Repository/GameRepository';
import { TurnHistoryRepository } from '../../Infrastructure/Repository/TurnHistoryRepository';
import { GameSnapshotRepository } from '../../Infrastructure/Repository/GameSnapshotRepository';
// Command Handlers
import { CreateUserCommandHandler } from '../User/commands/CreateUserCommandHandler';
@@ -86,6 +90,8 @@ export class DIContainer {
private _organizationRepository: IOrganizationRepository | null = null;
private _contactRepository: IContactRepository | null = null;
private _gameRepository: IGameRepository | null = null;
private _turnHistoryRepository: ITurnHistoryRepository | null = null;
private _gameSnapshotRepository: IGameSnapshotRepository | null = null;
// Services
private _jwtService: JWTService | null = null;
@@ -202,6 +208,20 @@ export class DIContainer {
return this._gameRepository;
}
public get turnHistoryRepository(): ITurnHistoryRepository {
if (!this._turnHistoryRepository) {
this._turnHistoryRepository = new TurnHistoryRepository();
}
return this._turnHistoryRepository;
}
public get gameSnapshotRepository(): IGameSnapshotRepository {
if (!this._gameSnapshotRepository) {
this._gameSnapshotRepository = new GameSnapshotRepository();
}
return this._gameSnapshotRepository;
}
// Services getters
public get jwtService(): JWTService {
if (!this._jwtService) {
@@ -294,7 +314,9 @@ export class DIContainer {
this._socketIOInstance,
this.gameRepository as any, // Cast to concrete type
this.userRepository as any, // Cast to concrete type
RedisService.getInstance()
RedisService.getInstance(),
this.turnHistoryRepository as any, // Cast to concrete type
this.gameSnapshotRepository as any // Cast to concrete type
);
}
return this._gameWebSocketService;
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
@@ -256,6 +256,15 @@ export class LoggingService {
}
private logToConsole(entry: LogEntry): void {
// In production, skip OTHER, CONNECTION, and REQUEST logs
if (process.env.NODE_ENV === 'production') {
if (entry.level === LogLevel.OTHER ||
entry.level === LogLevel.CONNECTION ||
entry.level === LogLevel.REQUEST) {
return;
}
}
const formattedEntry = this.formatLogEntry(entry);
switch (entry.level) {
@@ -287,6 +296,15 @@ export class LoggingService {
res?: Response,
responseTime?: number
): void {
// In production, skip OTHER, CONNECTION, and REQUEST logs entirely
if (process.env.NODE_ENV === 'production') {
if (level === LogLevel.OTHER ||
level === LogLevel.CONNECTION ||
level === LogLevel.REQUEST) {
return;
}
}
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
@@ -307,7 +307,12 @@ export class RedisService {
// Generic Redis methods for game data
public async get(key: string): Promise<string | null> {
try {
return await this.client.get(key);
const value = await this.client.get(key);
// Refresh TTL on access for game-related keys
if (value && this.isGameRelatedKey(key)) {
await this.client.expire(key, 1800); // Reset to 30 minutes
}
return value;
} catch (error) {
logError(`Failed to get key ${key}`, error as Error);
return null;
@@ -317,6 +322,10 @@ export class RedisService {
public async set(key: string, value: string): Promise<void> {
try {
await this.client.set(key, value);
// Auto-expire game-related keys after 30 minutes
if (this.isGameRelatedKey(key)) {
await this.client.expire(key, 1800); // 30 minutes
}
} catch (error) {
logError(`Failed to set key ${key}`, error as Error);
}
@@ -341,6 +350,10 @@ export class RedisService {
public async setAdd(key: string, member: string): Promise<void> {
try {
await this.client.sAdd(key, member);
// Refresh TTL for game-related keys
if (this.isGameRelatedKey(key)) {
await this.client.expire(key, 1800); // Reset to 30 minutes
}
} catch (error) {
logError(`Failed to add member to set ${key}`, error as Error);
}
@@ -349,6 +362,10 @@ export class RedisService {
public async setRemove(key: string, member: string): Promise<void> {
try {
await this.client.sRem(key, member);
// Refresh TTL for game-related keys
if (this.isGameRelatedKey(key)) {
await this.client.expire(key, 1800); // Reset to 30 minutes
}
} catch (error) {
logError(`Failed to remove member from set ${key}`, error as Error);
}
@@ -356,7 +373,12 @@ export class RedisService {
public async setMembers(key: string): Promise<string[]> {
try {
return await this.client.sMembers(key);
const members = await this.client.sMembers(key);
// Refresh TTL on access for game-related keys
if (members.length > 0 && this.isGameRelatedKey(key)) {
await this.client.expire(key, 1800); // Reset to 30 minutes
}
return members;
} catch (error) {
logError(`Failed to get members of set ${key}`, error as Error);
return [];
@@ -366,10 +388,36 @@ export class RedisService {
public async exists(key: string): Promise<boolean> {
try {
const result = await this.client.exists(key);
// Refresh TTL on access for game-related keys
if (result === 1 && this.isGameRelatedKey(key)) {
await this.client.expire(key, 1800); // Reset to 30 minutes
}
return result === 1;
} catch (error) {
logError(`Failed to check existence of key ${key}`, error as Error);
return false;
}
}
/**
* Check if a key is game-related and should have auto-expiration
* Game-related patterns: gameplay:*, game:*, game_*, board:*, game_pending_card:*, etc.
*/
private isGameRelatedKey(key: string): boolean {
const gamePatterns = [
'gameplay:',
'game:',
'game_',
'board:',
'game_pending_card:',
'game_pending_decision:',
'game_player_extra_turns:',
'game_player_turns_to_lose:',
'game_positions:',
'game_ready:',
'game_room:',
'active_game:'
];
return gamePatterns.some(pattern => key.startsWith(pattern));
}
}
@@ -0,0 +1,80 @@
import { ITurnHistoryRepository } from '../../Domain/IRepository/ITurnHistoryRepository';
import { TurnHistoryAggregate, TurnActionType, TurnActionData } from '../../Domain/Game/TurnHistoryAggregate';
import { logOther, logError } from './Logger';
export class TurnHistoryService {
constructor(private turnHistoryRepository: ITurnHistoryRepository) {}
/**
* Log a turn action
*/
async logTurnAction(
gameId: string,
playerId: string,
playerName: string,
turnNumber: number,
actionType: TurnActionType,
positionBefore: number,
positionAfter: number,
actionData?: TurnActionData
): Promise<void> {
try {
const turnHistory = new TurnHistoryAggregate();
turnHistory.gameid = gameId;
turnHistory.playerid = playerId;
turnHistory.playername = playerName;
turnHistory.turnNumber = turnNumber;
turnHistory.actionType = actionType;
turnHistory.positionBefore = positionBefore;
turnHistory.positionAfter = positionAfter;
turnHistory.actionData = actionData || null;
await this.turnHistoryRepository.save(turnHistory);
logOther(`Turn history logged: ${actionType}`, {
gameId,
playerId,
playerName,
turnNumber,
positionBefore,
positionAfter
});
} catch (error) {
logError('Failed to log turn history', error as Error);
// Don't throw - logging shouldn't break game flow
}
}
/**
* Get game replay data
*/
async getGameReplay(gameId: string): Promise<TurnHistoryAggregate[]> {
return await this.turnHistoryRepository.findByGameId(gameId);
}
/**
* Get player's turn history in a game
*/
async getPlayerHistory(gameId: string, playerId: string): Promise<TurnHistoryAggregate[]> {
return await this.turnHistoryRepository.findByGameAndPlayer(gameId, playerId);
}
/**
* Get recent turns for a game
*/
async getRecentTurns(gameId: string, limit: number = 10): Promise<TurnHistoryAggregate[]> {
return await this.turnHistoryRepository.findLastNTurns(gameId, limit);
}
/**
* Clean up turn history for a finished game
*/
async cleanupGameHistory(gameId: string): Promise<void> {
try {
await this.turnHistoryRepository.deleteByGameId(gameId);
logOther(`Turn history cleaned up for game ${gameId}`);
} catch (error) {
logError('Failed to cleanup turn history', error as Error);
}
}
}
@@ -0,0 +1,67 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { GameAggregate } from './GameAggregate';
export interface PlayerSnapshot {
playerId: string;
playerName: string;
boardPosition: number;
extraTurns: number;
turnsToLose: number;
isOnline: boolean;
}
export interface GameStateSnapshot {
currentPlayer: string;
currentPlayerName: string;
turnNumber: number;
turnOrder: string[];
playerPositions: PlayerSnapshot[];
boardFields?: any[];
deckStates?: any;
pendingActions?: any;
}
export enum SnapshotTrigger {
TURN_INTERVAL = 'turn_interval', // Every N turns
PLAYER_DISCONNECT = 'player_disconnect', // When player disconnects
CRITICAL_EVENT = 'critical_event', // Important game events
MANUAL = 'manual', // Manual checkpoint
SERVER_SHUTDOWN = 'server_shutdown' // Before server shutdown
}
@Entity('GameSnapshots')
@Index(['gameid', 'createdat'])
@Index(['gameid', 'trigger'])
export class GameSnapshotAggregate {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'gameid' })
gameid!: string;
@Column({ type: 'int', name: 'turn_number' })
turnNumber!: number;
@Column({
type: 'enum',
enum: SnapshotTrigger,
name: 'trigger'
})
trigger!: SnapshotTrigger;
@Column({ type: 'jsonb', name: 'game_state' })
gameState!: GameStateSnapshot;
@Column({ type: 'jsonb', name: 'redis_state', nullable: true })
redisState!: any | null;
@Column({ type: 'text', name: 'notes', nullable: true })
notes!: string | null;
@CreateDateColumn({ name: 'createdat' })
createdat!: Date;
@ManyToOne(() => GameAggregate, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'gameid' })
game?: GameAggregate;
}
@@ -0,0 +1,75 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { GameAggregate } from './GameAggregate';
export enum TurnActionType {
DICE_ROLL = 'dice_roll',
CARD_DRAWN = 'card_drawn',
ANSWER_SUBMITTED = 'answer_submitted',
POSITION_GUESS = 'position_guess',
GAMEMASTER_DECISION = 'gamemaster_decision',
LUCK_CONSEQUENCE = 'luck_consequence',
EXTRA_TURN = 'extra_turn',
TURN_LOST = 'turn_lost',
PLAYER_DISCONNECTED = 'player_disconnected',
TIMEOUT = 'timeout'
}
export interface TurnActionData {
diceValue?: number;
cardId?: string;
cardType?: string;
question?: string;
answer?: any;
isCorrect?: boolean;
guessedPosition?: number;
actualPosition?: number;
consequenceType?: string;
consequenceValue?: number;
decision?: string;
reason?: string;
[key: string]: any; // Allow additional properties
}
@Entity('TurnHistory')
@Index(['gameid', 'turnNumber'])
@Index(['gameid', 'playerid'])
@Index(['createdat'])
export class TurnHistoryAggregate {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'gameid' })
gameid!: string;
@Column({ type: 'uuid', name: 'playerid' })
playerid!: string;
@Column({ type: 'varchar', length: 255, name: 'playername' })
playername!: string;
@Column({ type: 'int', name: 'turn_number' })
turnNumber!: number;
@Column({
type: 'enum',
enum: TurnActionType,
name: 'action_type'
})
actionType!: TurnActionType;
@Column({ type: 'jsonb', name: 'action_data', nullable: true })
actionData!: TurnActionData | null;
@Column({ type: 'int', name: 'position_before' })
positionBefore!: number;
@Column({ type: 'int', name: 'position_after' })
positionAfter!: number;
@CreateDateColumn({ name: 'createdat' })
createdat!: Date;
@ManyToOne(() => GameAggregate, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'gameid' })
game?: GameAggregate;
}
@@ -0,0 +1,38 @@
import { GameSnapshotAggregate, SnapshotTrigger } from '../Game/GameSnapshotAggregate';
export interface IGameSnapshotRepository {
/**
* Save a game state snapshot
*/
save(snapshot: GameSnapshotAggregate): Promise<GameSnapshotAggregate>;
/**
* Get the most recent snapshot for a game
*/
findLatestByGameId(gameId: string): Promise<GameSnapshotAggregate | null>;
/**
* Get all snapshots for a game
*/
findByGameId(gameId: string): Promise<GameSnapshotAggregate[]>;
/**
* Get snapshots by trigger type
*/
findByGameAndTrigger(gameId: string, trigger: SnapshotTrigger): Promise<GameSnapshotAggregate[]>;
/**
* Get snapshot at specific turn
*/
findByGameAndTurn(gameId: string, turnNumber: number): Promise<GameSnapshotAggregate | null>;
/**
* Delete old snapshots (keep only last N)
*/
deleteOldSnapshots(gameId: string, keepCount: number): Promise<void>;
/**
* Delete all snapshots for a game
*/
deleteByGameId(gameId: string): Promise<void>;
}
@@ -0,0 +1,33 @@
import { TurnHistoryAggregate, TurnActionType, TurnActionData } from '../Game/TurnHistoryAggregate';
export interface ITurnHistoryRepository {
/**
* Save a turn history entry
*/
save(turnHistory: TurnHistoryAggregate): Promise<TurnHistoryAggregate>;
/**
* Get all turn history for a game
*/
findByGameId(gameId: string): Promise<TurnHistoryAggregate[]>;
/**
* Get turn history for a specific player in a game
*/
findByGameAndPlayer(gameId: string, playerId: string): Promise<TurnHistoryAggregate[]>;
/**
* Get the last N turns for a game
*/
findLastNTurns(gameId: string, limit: number): Promise<TurnHistoryAggregate[]>;
/**
* Get turn count for a game
*/
countTurnsByGame(gameId: string): Promise<number>;
/**
* Delete all turn history for a game
*/
deleteByGameId(gameId: string): Promise<void>;
}
@@ -0,0 +1,72 @@
import { Repository, LessThan } from 'typeorm';
import { AppDataSource } from '../ormconfig';
import { IGameSnapshotRepository } from '../../Domain/IRepository/IGameSnapshotRepository';
import { GameSnapshotAggregate, SnapshotTrigger } from '../../Domain/Game/GameSnapshotAggregate';
export class GameSnapshotRepository implements IGameSnapshotRepository {
private repository: Repository<GameSnapshotAggregate>;
constructor() {
this.repository = AppDataSource.getRepository(GameSnapshotAggregate);
}
async save(snapshot: GameSnapshotAggregate): Promise<GameSnapshotAggregate> {
return await this.repository.save(snapshot);
}
async findLatestByGameId(gameId: string): Promise<GameSnapshotAggregate | null> {
return await this.repository.findOne({
where: { gameid: gameId },
order: { createdat: 'DESC' }
});
}
async findByGameId(gameId: string): Promise<GameSnapshotAggregate[]> {
return await this.repository.find({
where: { gameid: gameId },
order: { turnNumber: 'ASC', createdat: 'ASC' }
});
}
async findByGameAndTrigger(gameId: string, trigger: SnapshotTrigger): Promise<GameSnapshotAggregate[]> {
return await this.repository.find({
where: {
gameid: gameId,
trigger: trigger
},
order: { createdat: 'DESC' }
});
}
async findByGameAndTurn(gameId: string, turnNumber: number): Promise<GameSnapshotAggregate | null> {
return await this.repository.findOne({
where: {
gameid: gameId,
turnNumber: turnNumber
},
order: { createdat: 'DESC' }
});
}
async deleteOldSnapshots(gameId: string, keepCount: number): Promise<void> {
const snapshots = await this.repository.find({
where: { gameid: gameId },
order: { createdat: 'DESC' },
select: ['id', 'createdat']
});
if (snapshots.length > keepCount) {
const idsToDelete = snapshots
.slice(keepCount)
.map(s => s.id);
if (idsToDelete.length > 0) {
await this.repository.delete(idsToDelete);
}
}
}
async deleteByGameId(gameId: string): Promise<void> {
await this.repository.delete({ gameid: gameId });
}
}
@@ -0,0 +1,51 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../ormconfig';
import { ITurnHistoryRepository } from '../../Domain/IRepository/ITurnHistoryRepository';
import { TurnHistoryAggregate } from '../../Domain/Game/TurnHistoryAggregate';
export class TurnHistoryRepository implements ITurnHistoryRepository {
private repository: Repository<TurnHistoryAggregate>;
constructor() {
this.repository = AppDataSource.getRepository(TurnHistoryAggregate);
}
async save(turnHistory: TurnHistoryAggregate): Promise<TurnHistoryAggregate> {
return await this.repository.save(turnHistory);
}
async findByGameId(gameId: string): Promise<TurnHistoryAggregate[]> {
return await this.repository.find({
where: { gameid: gameId },
order: { turnNumber: 'ASC', createdat: 'ASC' }
});
}
async findByGameAndPlayer(gameId: string, playerId: string): Promise<TurnHistoryAggregate[]> {
return await this.repository.find({
where: {
gameid: gameId,
playerid: playerId
},
order: { turnNumber: 'ASC', createdat: 'ASC' }
});
}
async findLastNTurns(gameId: string, limit: number): Promise<TurnHistoryAggregate[]> {
return await this.repository.find({
where: { gameid: gameId },
order: { turnNumber: 'DESC', createdat: 'DESC' },
take: limit
});
}
async countTurnsByGame(gameId: string): Promise<number> {
return await this.repository.count({
where: { gameid: gameId }
});
}
async deleteByGameId(gameId: string): Promise<void> {
await this.repository.delete({ gameid: gameId });
}
}