import { Server as SocketIOServer, Socket } from 'socket.io'; import { GameTokenService, GameTokenPayload } from './GameTokenService'; import { GameRepository } from '../../Infrastructure/Repository/GameRepository'; import { UserRepository } from '../../Infrastructure/Repository/UserRepository'; import { TurnHistoryRepository } from '../../Infrastructure/Repository/TurnHistoryRepository'; import { GameSnapshotRepository } from '../../Infrastructure/Repository/GameSnapshotRepository'; import { GameAggregate, GameState, LoginType, GameField } from '../../Domain/Game/GameAggregate'; import { logAuth, logError, logOther, logWarning } from './Logger'; import { RedisService } from './RedisService'; import { FieldEffectService, CardProcessingResult } from './FieldEffectService'; import { CardDrawingService } from './CardDrawingService'; import { BoardGenerationService } from '../Game/BoardGenerationService'; import { GamemasterService, GamemasterDecision } from './GamemasterService'; import { TurnHistoryService } from './TurnHistoryService'; import { GameSnapshotService } from './GameSnapshotService'; import { TurnActionType } from '../../Domain/Game/TurnHistoryAggregate'; import { SnapshotTrigger } from '../../Domain/Game/GameSnapshotAggregate'; import { GameActionData, PlayerPosition, GameStateUpdateData, FieldEffectRequest, JoinGameData, LeaveGameData } from './Interfaces/GameInterfaces'; import { json } from 'stream/consumers'; interface AuthenticatedSocket extends Socket { userId?: string; gameCode?: string; playerName?: string; isAuthenticated?: boolean; } interface DiceRollData { gameCode: string; diceValue: number; // Value from frontend (1-6) } interface GameChatData { gameCode: string; message: string; } interface CardAnswerData { gameCode: string; answer: any; cardId?: string; // Optional card ID sent from frontend } interface GamemasterDecisionData { gameCode: string; requestId: string; decision: 'approve' | 'reject'; } interface PendingCardState { playerId: string; playerName: string; card: any; field: GameField; // Field info dice: number; // Dice roll currentPosition: number; // Position before card landedPosition: number; // Position after dice roll (where they landed) drawnAt: number; answerGiven?: boolean; // Track if answer submitted answerCorrect?: boolean; // Track if answer was correct requiresGuess?: boolean; // Track if guess is required guessedPosition?: number; // Store player's guess } interface PendingDecisionState { playerId: string; playerName: string; card: any; field: GameField; // Field info dice: number; // Dice roll (always 6 for jokers) currentPosition: number; // Position before card drawnAt: number; recursionDepth: number; gamemasterDecided?: boolean; // Track if gamemaster decided gamemasterApproved?: boolean; // Track approval result guessedPosition?: number; // Store player's guess } export class GameWebSocketService { private io: SocketIOServer; private gameTokenService: GameTokenService; private gameRepository: GameRepository; private userRepository: UserRepository; private redisService: RedisService; private fieldEffectService: FieldEffectService; private cardDrawingService: CardDrawingService; private boardGenerationService: BoardGenerationService; private gamemasterService: GamemasterService; private turnHistoryService: TurnHistoryService; private gameSnapshotService: GameSnapshotService; constructor( io: SocketIOServer, gameRepository: GameRepository, userRepository: UserRepository, redisService: RedisService, turnHistoryRepository: TurnHistoryRepository, gameSnapshotRepository: GameSnapshotRepository ) { this.io = io; this.gameTokenService = new GameTokenService(); this.gameRepository = gameRepository; this.userRepository = userRepository; this.redisService = redisService; // Initialize services in proper dependency order this.boardGenerationService = new BoardGenerationService(); this.gamemasterService = new GamemasterService(); this.cardDrawingService = new CardDrawingService(); this.fieldEffectService = new FieldEffectService( this.boardGenerationService, this.gamemasterService ); this.turnHistoryService = new TurnHistoryService(turnHistoryRepository); this.gameSnapshotService = new GameSnapshotService(gameSnapshotRepository, redisService); this.setupGameNamespace(); } private setupGameNamespace(): void { // Create a namespace specifically for game events const gameNamespace = this.io.of('/game'); gameNamespace.on('connection', (socket: AuthenticatedSocket) => { logOther(`New game socket connection: ${socket.id}`); // For game sockets, authentication is optional (public games) // Players will authenticate when joining a specific game this.setupGameEventHandlers(socket); }); } private setupGameEventHandlers(socket: AuthenticatedSocket): void { // Join game room socket.on('game:join', async (data: any) => { await this.handleJoinGame(socket, data); }); // Leave game room socket.on('game:leave', async (data: LeaveGameData) => { await this.handleLeaveGame(socket, data); }); // Game actions (dice roll, move, etc.) socket.on('game:action', async (data: GameActionData) => { await this.handleGameAction(socket, data); }); // Game chat within a specific game socket.on('game:chat', async (data: GameChatData) => { await this.handleGameChat(socket, data); }); // Player ready status socket.on('game:ready', async (data: { gameCode: string; ready: boolean }) => { await this.handlePlayerReady(socket, data); }); // Gamemaster approve player (private games only) socket.on('game:approve-player', async (data: { gameCode: string; playerName: string }) => { await this.handleApprovePlayer(socket, data); }); // Gamemaster reject player (private games only) socket.on('game:reject-player', async (data: { gameCode: string; playerName: string; reason?: string }) => { await this.handleRejectPlayer(socket, data); }); // Player joining after approval (private games) socket.on('game:join-approved', async (data: JoinGameData) => { await this.handleJoinApproved(socket, data); }); // Dice roll from frontend socket.on('game:dice-roll', async (data: DiceRollData) => { await this.handleDiceRoll(socket, data); }); // Card answer from player socket.on('game:card-answer', async (data: CardAnswerData) => { await this.handleCardAnswer(socket, data); }); // Gamemaster decision on joker card socket.on('game:gamemaster-decision', async (data: GamemasterDecisionData) => { await this.handleGamemasterDecision(socket, data); }); // Position guess (for question cards) socket.on('game:position-guess', async (data: { gameCode: string; guessedPosition: number }) => { await this.handlePositionGuess(socket, data); }); // Joker position guess (for joker cards) socket.on('game:joker-position-guess', async (data: { gameCode: string; guessedPosition: number }) => { await this.handleJokerPositionGuess(socket, data); }); // Disconnect handling socket.on('disconnect', async () => { await this.handleDisconnect(socket); }); } private async handleJoinGame(socket: AuthenticatedSocket, data: any): Promise { try { // Socket.IO automatically deserializes JSON - data is already an object const gameToken = data?.gameToken; if (!gameToken) { logError('Game join failed: No game token provided'); socket.emit('game:error', { message: 'Game token is required' }); return; } // Verify the game token const gameTokenPayload = this.gameTokenService.verifyGameToken(gameToken); if (!gameTokenPayload) { logError('Game join failed: Invalid game token'); socket.emit('game:error', { message: 'Invalid or expired game token' }); return; } const { gameId, gameCode, playerName, isAuthenticated, userId } = gameTokenPayload; // Validate game still exists const game = await this.gameRepository.findByGameCode(gameCode); if (!game || game.id !== gameId) { logError(`Game join failed: Game not found - Code: ${gameCode}`); socket.emit('game:error', { message: 'Game not found or token invalid' }); return; } // Check if player name is already in use by checking connected players const connectedPlayers = await this.getConnectedPlayers(gameCode); if (connectedPlayers.includes(playerName)) { logOther(`Game join failed: Player name "${playerName}" already in use in game ${gameCode}`); socket.emit('game:error', { message: `Player name "${playerName}" is already in use in this game` }); return; } // Set socket properties from game token socket.gameCode = gameCode; socket.playerName = playerName; socket.isAuthenticated = isAuthenticated; socket.userId = userId; // Check if this is a private game and player needs gamemaster approval const isGamemaster = game.createdby === userId; const needsApproval = game.logintype === LoginType.PRIVATE && !isGamemaster; logOther(`Player joining game: ${playerName}`); logOther(` - userId: ${userId}`); logOther(` - game.createdby: ${game.createdby}`); logOther(` - isGamemaster: ${isGamemaster}`); logOther(` - needsApproval: ${needsApproval}`); // Generate dynamic room names (needed for both approval and direct join) const gameRoomName = `game_${gameCode}`; const playerRoomName = `game_${gameCode}:${playerName}`; if (needsApproval) { // For private games, non-gamemaster players need approval // Add to pending players list and notify gamemaster await this.addToPendingPlayers(gameCode, playerName); // Send pending status to the requesting player socket.emit('game:pending-approval', { gameCode, playerName, message: 'Waiting for gamemaster approval to join the game', timestamp: new Date().toISOString() }); // Notify gamemaster about the pending player socket.to(gameRoomName).emit('game:player-requesting-join', { playerName: playerName, isAuthenticated, message: `${playerName} is requesting to join the game`, timestamp: new Date().toISOString() }); return; // Don't join rooms yet - wait for approval } // Join both the general game room and player-specific room await socket.join(gameRoomName); await socket.join(playerRoomName); // Update Redis with active player connection FIRST (before getting state) await this.updatePlayerConnection(gameCode, playerName, true); // Send success response to the joining player socket.emit('game:joined', { gameCode, playerName, isAuthenticated, gameId, isGamemaster, timestamp: new Date().toISOString() }); // Notify other players in the game (broadcast) socket.to(gameRoomName).emit('game:player-joined', { playerName: playerName, isAuthenticated, isGamemaster, timestamp: new Date().toISOString() }); // Send current game state to the joining player (now includes this player) const gameState = await this.getGameState(gameCode); // Add isGamemaster flag for this specific player const gameStateWithMasterFlag = { ...gameState, isGamemaster }; socket.emit('game:state', gameStateWithMasterFlag); // Broadcast updated game state to all other players so they see the new player socket.to(gameRoomName).emit('game:state-update', gameState); } catch (error) { socket.emit('game:error', { message: 'Failed to join game', error: error instanceof Error ? error.message : 'Unknown error' }); } } private async handleLeaveGame(socket: AuthenticatedSocket, data: LeaveGameData): Promise { try { const { gameCode } = data; const playerName = socket.playerName; // Validate we have the required data if (!playerName) { logError('Cannot leave game: socket has no playerName'); socket.emit('game:error', { message: 'Player has no name' }); return; } const gameRoomName = `game_${gameCode}`; const playerRoomName = `game_${gameCode}:${playerName}`; // Leave both rooms await socket.leave(gameRoomName); await socket.leave(playerRoomName); logOther(`Player ${playerName} left game room: ${gameRoomName}`); // Notify other players socket.to(gameRoomName).emit('game:player-left', { playerName: playerName, timestamp: new Date().toISOString() }); // Update Redis before clearing socket properties await this.updatePlayerConnection(gameCode, playerName, false); // Clear socket properties socket.gameCode = undefined; socket.playerName = undefined; } catch (error) { logError('Error leaving game', error as Error); socket.emit('game:error', { message: 'Failed to leave game' }); } } private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData): Promise { try { const { gameCode, action, data: actionData } = data; if (!socket.gameCode || socket.gameCode !== gameCode) { socket.emit('game:error', { message: 'You must be in the game to perform actions' }); return; } // Validate it's the player's turn (this would need game state logic) const game = await this.gameRepository.findByGameCode(gameCode); if (!game) { socket.emit('game:error', { message: 'Game not found' }); return; } // Process the game action based on type const result = await this.processGameAction(game, socket.userId!, action, actionData); if (result.success) { // Broadcast action to all players in the game const gameRoomName = `game_${gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:action-result', { action, playerName: socket.playerName, result: result.data, timestamp: new Date().toISOString() }); // If the action resulted in a game state change, broadcast the new state if (result.stateChanged) { const updatedGameState = await this.getGameState(gameCode); this.io.of('/game').to(gameRoomName).emit('game:state-update', updatedGameState); } } else { socket.emit('game:error', { message: result.error }); } } catch (error) { logError('Error processing game action', error as Error); socket.emit('game:error', { message: 'Failed to process action' }); } } private async handleGameChat(socket: AuthenticatedSocket, data: GameChatData): Promise { try { const { gameCode, message } = data; if (!socket.gameCode || socket.gameCode !== gameCode) { socket.emit('game:error', { message: 'You must be in the game to chat' }); return; } const gameRoomName = `game_${gameCode}`; // Broadcast chat message to all players in the game this.io.of('/game').to(gameRoomName).emit('game:chat-message', { playerName: socket.playerName, message, timestamp: new Date().toISOString() }); logOther(`Game chat in ${gameCode}: ${socket.playerName || socket.userId}: ${message}`); } catch (error) { logError('Error handling game chat', error as Error); socket.emit('game:error', { message: 'Failed to send chat message' }); } } private async handlePlayerReady(socket: AuthenticatedSocket, data: { gameCode: string; ready: boolean }): Promise { try { const { gameCode, ready } = data; const gameRoomName = `game_${gameCode}`; // Update player ready status in Redis await this.updatePlayerReadyStatus(gameCode, socket.playerName!, ready); // Broadcast ready status to all players this.io.of('/game').to(gameRoomName).emit('game:player-ready', { playerName: socket.playerName, ready, timestamp: new Date().toISOString() }); // Check if all players are ready and start game if so const allReady = await this.checkAllPlayersReady(gameCode); if (allReady) { this.io.of('/game').to(gameRoomName).emit('game:all-ready', { message: 'All players are ready! Game can start.', timestamp: new Date().toISOString() }); } } catch (error) { logError('Error handling player ready status', error as Error); socket.emit('game:error', { message: 'Failed to update ready status' }); } } private async handleApprovePlayer(socket: AuthenticatedSocket, data: { gameCode: string; playerName: string }): Promise { try { const { gameCode, playerName } = data; // Verify that the requesting socket is the gamemaster const game = await this.gameRepository.findByGameCode(gameCode); if (!game) { socket.emit('game:error', { message: 'Game not found' }); return; } const isGamemaster = game.createdby === socket.userId; if (!isGamemaster) { socket.emit('game:error', { message: 'Only the gamemaster can approve players' }); return; } if (game.logintype !== LoginType.PRIVATE) { socket.emit('game:error', { message: 'Player approval is only for private games' }); return; } // Check if player is in pending list const pendingPlayers = await this.getPendingPlayers(gameCode); if (!pendingPlayers.includes(playerName)) { socket.emit('game:error', { message: 'Player not found in pending list' }); return; } // Remove from pending players await this.removeFromPendingPlayers(gameCode, playerName); // Notify the approved player to join the game rooms const gameRoomName = `game_${gameCode}`; const playerRoomName = `game_${gameCode}:${playerName}`; // Find the pending player's socket and move them to the game this.io.of('/game').emit('game:approval-granted', { gameCode, playerName, gameRoomName, playerRoomName, message: 'You have been approved to join the game!', timestamp: new Date().toISOString() }); // Notify all players about the approval this.io.of('/game').to(gameRoomName).emit('game:player-approved', { playerName, approvedBy: socket.playerName, timestamp: new Date().toISOString() }); // Send updated game state to gamemaster to preserve their status const gameState = await this.getGameState(gameCode); const gamemasterState = { ...gameState, isGamemaster: true }; socket.emit('game:state', gamemasterState); logOther(`Player ${playerName} approved by gamemaster in game ${gameCode}`); } catch (error) { logError('Error approving player', error as Error); socket.emit('game:error', { message: 'Failed to approve player' }); } } private async handleRejectPlayer(socket: AuthenticatedSocket, data: { gameCode: string; playerName: string; reason?: string }): Promise { try { const { gameCode, playerName, reason } = data; // Verify that the requesting socket is the gamemaster const game = await this.gameRepository.findByGameCode(gameCode); if (!game) { socket.emit('game:error', { message: 'Game not found' }); return; } const isGamemaster = game.createdby === socket.userId; if (!isGamemaster) { socket.emit('game:error', { message: 'Only the gamemaster can reject players' }); return; } if (game.logintype !== LoginType.PRIVATE) { socket.emit('game:error', { message: 'Player rejection is only for private games' }); return; } // Check if player is in pending list const pendingPlayers = await this.getPendingPlayers(gameCode); if (!pendingPlayers.includes(playerName)) { socket.emit('game:error', { message: 'Player not found in pending list' }); return; } // Remove from pending players await this.removeFromPendingPlayers(gameCode, playerName); // Notify the rejected player this.io.of('/game').emit('game:approval-denied', { gameCode, playerName, reason: reason || 'Your request to join the game was denied', timestamp: new Date().toISOString() }); // Send updated game state to gamemaster to preserve their status const gameState = await this.getGameState(gameCode); const gamemasterState = { ...gameState, isGamemaster: true }; socket.emit('game:state', gamemasterState); logOther(`Player ${playerName} rejected by gamemaster in game ${gameCode}${reason ? ': ' + reason : ''}`); } catch (error) { logError('Error rejecting player', error as Error); socket.emit('game:error', { message: 'Failed to reject player' }); } } private async handleJoinApproved(socket: AuthenticatedSocket, data: JoinGameData): Promise { try { const { gameToken } = data; if (!gameToken) { socket.emit('game:error', { message: 'Game token is required' }); return; } // Verify the game token const gameTokenPayload = this.gameTokenService.verifyGameToken(gameToken); if (!gameTokenPayload) { socket.emit('game:error', { message: 'Invalid or expired game token' }); return; } const { gameId, gameCode, playerName, isAuthenticated, userId } = gameTokenPayload; // Validate game still exists const game = await this.gameRepository.findByGameCode(gameCode); if (!game || game.id !== gameId) { socket.emit('game:error', { message: 'Game not found or token invalid' }); return; } // Check if player was actually approved (not in pending list anymore) const pendingPlayers = await this.getPendingPlayers(gameCode); if (pendingPlayers.includes(playerName)) { socket.emit('game:error', { message: 'Player still pending approval' }); return; } // Set socket properties from game token socket.gameCode = gameCode; socket.playerName = playerName; socket.isAuthenticated = isAuthenticated; socket.userId = userId; // Generate dynamic room names and join const gameRoomName = `game_${gameCode}`; const playerRoomName = `game_${gameCode}:${playerName}`; await socket.join(gameRoomName); await socket.join(playerRoomName); logOther(`Approved player ${playerName} joined game room: ${gameRoomName}`); // Update Redis with active player connection FIRST (before getting state) await this.updatePlayerConnection(gameCode, playerName, true); // Send success response to the joining player socket.emit('game:joined', { gameCode, playerName, isAuthenticated, gameId, isGamemaster: false, timestamp: new Date().toISOString() }); // Notify other players in the game (broadcast) socket.to(gameRoomName).emit('game:player-joined', { playerName: playerName, isAuthenticated, isGamemaster: false, timestamp: new Date().toISOString() }); // Send current game state to the joining player (after approval) const gameState = await this.getGameState(gameCode); // Check if this player is gamemaster (shouldn't be, since they were just approved) const gameForMasterCheck = await this.gameRepository.findByGameCode(gameCode); const playerIsGamemaster = gameForMasterCheck?.createdby === socket.userId; const gameStateWithMasterFlag = { ...gameState, isGamemaster: playerIsGamemaster }; socket.emit('game:state', gameStateWithMasterFlag); // Broadcast updated game state to all other players so they see the new player socket.to(gameRoomName).emit('game:state-update', gameState); } catch (error) { logError('Error handling approved join', error as Error); socket.emit('game:error', { message: 'Failed to join after approval' }); } } private async handleDiceRoll(socket: AuthenticatedSocket, data: DiceRollData): Promise { try { const { gameCode, diceValue } = data; // Validate input if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) { socket.emit('game:error', { message: 'You must be in the game to roll dice' }); return; } if (!diceValue || diceValue < 1 || diceValue > 6) { socket.emit('game:error', { message: 'Invalid dice value. Must be between 1 and 6' }); return; } // Get current game state const gameState = await this.getCurrentGameState(gameCode); if (!gameState) { socket.emit('game:error', { message: 'Game not found' }); return; } // Check if it's the player's turn // Use userId for authenticated players, playerName for guests const playerIdentifier = socket.userId || socket.playerName; if (!playerIdentifier) { socket.emit('game:error', { message: 'Player identification failed' }); return; } if (gameState.currentPlayer !== playerIdentifier) { socket.emit('game:error', { message: 'It is not your turn' }); return; } // Get player's current position const playerPositions = await this.getPlayerPositions(gameCode); const currentPlayer = playerPositions.find(p => p.playerId === playerIdentifier); if (!currentPlayer) { socket.emit('game:error', { message: 'Player not found in game' }); return; } // Calculate new position after dice roll let newPosition = Math.min(currentPlayer.boardPosition + diceValue, 100); // Win at 100 const gameRoomName = `game_${gameCode}`; // Emit dice-rolled event FIRST (for frontend animation) this.io.of('/game').to(gameRoomName).emit('game:dice-rolled', { playerId: playerIdentifier, playerName: socket.playerName, diceValue: diceValue, calculatedDestination: newPosition, timestamp: new Date().toISOString() }); // Emit player-moving event (token starts animation) this.io.of('/game').to(gameRoomName).emit('game:player-moving', { playerId: playerIdentifier, playerName: socket.playerName, fromPosition: currentPlayer.boardPosition, toPosition: newPosition, timestamp: new Date().toISOString() }); // Check if player won (reached position 100) if (newPosition >= 100) { // Update position BEFORE ending game await this.updatePlayerPosition(gameCode, playerIdentifier, newPosition); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId: playerIdentifier, playerName: socket.playerName, position: newPosition, fieldType: 'finish', timestamp: new Date().toISOString() }); await this.endGame(gameCode, playerIdentifier, socket.playerName!); return; } // Check if player landed on special field (positive, negative, or luck) const boardData = await this.getBoardData(gameCode); if (boardData && boardData.fields) { const landedField = boardData.fields.find((f: GameField) => f.position === newPosition); if (landedField && this.isSpecialField(landedField)) { // Wait 2 seconds for frontend animation to complete await new Promise(resolve => setTimeout(resolve, 2000)); // Process special field - draw card // Position will be updated AFTER card/consequence logic in handleSpecialFieldLanding await this.handleSpecialFieldLanding( gameCode, playerIdentifier, socket.playerName!, landedField, newPosition, diceValue, currentPlayer.boardPosition ); return; // Don't advance turn yet, waiting for card answer } } // No special field - update position now and advance turn await this.updatePlayerPosition(gameCode, playerIdentifier, newPosition); // Log turn history await this.turnHistoryService.logTurnAction( gameCode, playerIdentifier, socket.playerName!, gameState.turnNumber || 1, TurnActionType.DICE_ROLL, currentPlayer.boardPosition, newPosition, { diceValue } ); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId: playerIdentifier, playerName: socket.playerName, position: newPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); await this.advanceTurn(gameCode); logOther(`Player ${socket.playerName} rolled ${diceValue}, moved from ${currentPlayer.boardPosition} to ${newPosition}`, { gameCode, playerId: socket.userId }); } catch (error) { logError('Error handling dice roll', error as Error); socket.emit('game:error', { message: 'Failed to process dice roll' }); } } private async handleCardAnswer(socket: AuthenticatedSocket, data: CardAnswerData): Promise { try { const { gameCode, answer, cardId } = data; logOther(`Card answer received`, { gameCode, playerId: socket.userId, playerName: socket.playerName, cardId, answerType: typeof answer }); // Validate input if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) { socket.emit('game:error', { message: 'You must be in the game to answer cards' }); return; } if (!socket.userId) { socket.emit('game:error', { message: 'Player not authenticated' }); return; } // Get pending card from Redis const pendingCard = await this.getPendingCard(gameCode, socket.userId); if (!pendingCard) { logError(`No pending card found for player ${socket.playerName} (${socket.userId}) in game ${gameCode}`); socket.emit('game:error', { message: 'No pending card answer found' }); return; } const pendingState = pendingCard as PendingCardState; // Clear the timeout by clearing from CardDrawingService const answerKey = `${gameCode}:${socket.userId}`; this.cardDrawingService.clearAnswerTimeout(answerKey); const gameRoomName = `game_${gameCode}`; // Broadcast player's answer to all players BEFORE validation this.io.of('/game').to(gameRoomName).emit('game:answer-submitted', { playerName: socket.playerName, playerId: socket.userId, answer: answer, message: `${socket.playerName} answered: ${JSON.stringify(answer)}`, timestamp: new Date().toISOString() }); // Add dramatic pause before showing result await new Promise(resolve => setTimeout(resolve, 1000)); // Process the answer const result = this.cardDrawingService.processAnswer(pendingState.card, answer); // Update pending state pendingState.answerGiven = true; pendingState.answerCorrect = result.correct; // Broadcast validation result this.io.of('/game').to(gameRoomName).emit('game:answer-validated', { playerName: socket.playerName, playerId: socket.userId, isCorrect: result.correct, correctAnswer: pendingState.card.answer, message: result.correct ? `✅ ${socket.playerName} answered correctly!` : `❌ ${socket.playerName} answered incorrectly. Correct answer: ${JSON.stringify(pendingState.card.answer)}`, timestamp: new Date().toISOString() }); // Log answer submission const gameState = await this.getCurrentGameState(gameCode); if (gameState) { await this.turnHistoryService.logTurnAction( gameCode, socket.userId!, socket.playerName!, gameState.turnNumber || 1, TurnActionType.ANSWER_SUBMITTED, pendingState.currentPosition, pendingState.currentPosition, { cardId: pendingState.card.cardid, answer: answer, isCorrect: result.correct } ); } // ========================================== // NEW: Determine if position guess is required // ========================================== const requiresGuess = this.determineGuessRequirement( pendingState.field.type, result.correct ); if (requiresGuess) { // Request position guess try { await this.requestPositionGuess(gameCode, socket.userId, socket.playerName!, pendingState); } catch (error) { logError('Error requesting position guess, advancing turn', error as Error); await this.clearPendingCard(gameCode, socket.userId); await this.advanceTurn(gameCode); } } else { // No guess required, handle based on field type if (pendingState.field.type === 'positive' && !result.correct) { // Positive field + wrong answer = stay at landed position (no movement back) this.io.of('/game').to(gameRoomName).emit('game:no-movement', { playerId: socket.userId, playerName: socket.playerName, reason: 'Wrong answer on positive field', message: `${socket.playerName} stays at position ${pendingState.landedPosition}`, timestamp: new Date().toISOString() }); } else if (pendingState.field.type === 'negative' && result.correct) { // Negative field + correct answer = stay at landed position (avoided penalty) this.io.of('/game').to(gameRoomName).emit('game:penalty-avoided', { playerId: socket.userId, playerName: socket.playerName, message: `${socket.playerName} avoided the penalty! Stays at position ${pendingState.landedPosition}`, timestamp: new Date().toISOString() }); } // Emit player-arrived event (stay at landed position, not reverting to pre-dice position) this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId: socket.userId, playerName: socket.playerName, position: pendingState.landedPosition || pendingState.currentPosition, // Use landed position fieldType: 'normal', timestamp: new Date().toISOString() }); // Clean up and advance turn await this.clearPendingCard(gameCode, socket.userId); await this.advanceTurn(gameCode); } logOther(`Player ${socket.playerName} answered card: ${result.correct ? 'correct' : 'wrong'}`, { gameCode, playerId: socket.userId, requiresGuess }); } catch (error) { logError('Error handling card answer', error as Error); socket.emit('game:error', { message: 'Failed to process card answer' }); // Ensure turn advances even on error try { if (socket.userId && data.gameCode) { const pendingCard = await this.getPendingCard(data.gameCode, socket.userId); if (pendingCard) { await this.clearPendingCard(data.gameCode, socket.userId); } await this.advanceTurn(data.gameCode); } } catch (advanceError) { logError('Error advancing turn after card answer error', advanceError as Error); } } } private async handleGamemasterDecision(socket: AuthenticatedSocket, data: GamemasterDecisionData): Promise { try { const { gameCode, requestId, decision } = data; // Validate input if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) { socket.emit('game:error', { message: 'You must be in the game to make decisions' }); return; } if (!socket.userId) { socket.emit('game:error', { message: 'Gamemaster not authenticated' }); return; } // Verify this is the gamemaster const game = await this.gameRepository.findByGameCode(gameCode); if (!game || game.createdby !== socket.userId) { socket.emit('game:error', { message: 'Only the gamemaster can make this decision' }); return; } // Get pending decision from Redis const pendingDecision = await this.getPendingDecision(gameCode, requestId); if (!pendingDecision) { socket.emit('game:error', { message: 'Decision request not found or expired' }); return; } const pendingState = pendingDecision as PendingDecisionState; // Process decision through GamemasterService const result = this.gamemasterService.processGamemasterDecision( requestId, decision === 'approve' ? GamemasterDecision.APPROVE : GamemasterDecision.REJECT ); if (!result) { socket.emit('game:error', { message: 'Failed to process gamemaster decision' }); // Clean up await this.clearPendingDecision(gameCode, requestId); return; } const gameRoomName = `game_${gameCode}`; const approved = decision === 'approve'; // Update pending state with decision pendingState.gamemasterDecided = true; pendingState.gamemasterApproved = approved; // Broadcast decision result to all players this.io.of('/game').to(gameRoomName).emit('game:gamemaster-decision-result', { playerName: pendingState.playerName, playerId: pendingState.playerId, gamemasterName: socket.playerName, decision: decision, approved: approved, consequence: result.consequence, description: result.description, timestamp: new Date().toISOString() }); // ========================================== // NEW: Determine if position guess is required // ========================================== const requiresGuess = ( (pendingState.field.type === 'positive' && approved) || (pendingState.field.type === 'negative' && !approved) ); if (requiresGuess) { // Request position guess await this.requestJokerPositionGuess( gameCode, pendingState.playerId, pendingState.playerName, pendingState ); } else { // No guess required if (pendingState.field.type === 'positive' && !approved) { // Positive field + rejected = no movement (stay at currentPosition) this.io.of('/game').to(gameRoomName).emit('game:no-movement', { playerId: pendingState.playerId, playerName: pendingState.playerName, reason: 'Gamemaster rejected on positive field', message: `${pendingState.playerName} stays at position ${pendingState.currentPosition}`, timestamp: new Date().toISOString() }); } else if (pendingState.field.type === 'negative' && approved) { // Negative field + approved = no movement (avoided penalty, stay at currentPosition) this.io.of('/game').to(gameRoomName).emit('game:penalty-avoided', { playerId: pendingState.playerId, playerName: pendingState.playerName, message: `${pendingState.playerName} avoided the penalty! Stays at position ${pendingState.currentPosition}`, timestamp: new Date().toISOString() }); } // Emit player-arrived event (stayed at original position) this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId: pendingState.playerId, playerName: pendingState.playerName, position: pendingState.currentPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); // Clean up and advance turn await this.clearPendingDecision(gameCode, requestId); await this.advanceTurn(gameCode); } logOther(`Gamemaster ${socket.playerName} made decision: ${decision} for player ${pendingState.playerName}`, { gameCode, requestId, requiresGuess }); } catch (error) { logError('Error handling gamemaster decision', error as Error); socket.emit('game:error', { message: 'Failed to process gamemaster decision' }); } } /** * Handle player landing on special field (positive, negative, luck) * Draws card and initiates appropriate flow based on card type */ private async handleSpecialFieldLanding( gameCode: string, playerId: string, playerName: string, field: GameField, position: number, dice: number, currentPosition: number ): Promise { try { const gameRoomName = `game_${gameCode}`; // Get game data for card drawing const gameData = await this.gameRepository.findByGameCode(gameCode); if (!gameData) { logError('Game not found when handling special field landing'); await this.advanceTurn(gameCode); return; } // Draw a card based on field type const cardDrawResult = this.cardDrawingService.drawCard( gameData, field.type as 'positive' | 'negative' | 'luck', playerId ); if (!cardDrawResult.success || !cardDrawResult.card) { // No more cards available or error this.io.of('/game').to(gameRoomName).emit('game:card-error', { playerName, playerId, error: cardDrawResult.error || 'Failed to draw card', timestamp: new Date().toISOString() }); await this.advanceTurn(gameCode); return; } const card = cardDrawResult.card; // Check if card has consequence (joker/luck card) even without type field const hasConsequence = card.consequence !== undefined && card.consequence !== null; const isLuckType = this.isLuckCard(card.type); // Get game state for turn number const gameState = await this.getCurrentGameState(gameCode); // Broadcast card drawn to all players (everyone sees the question) this.io.of('/game').to(gameRoomName).emit('game:card-drawn', { playerName, playerId, cardType: hasConsequence ? 'joker' : this.getCardTypeName(card.type), question: card.question, fieldType: field.type, timestamp: new Date().toISOString() }); logOther('Card drawn event broadcasted', { playerName, playerId, cardType: hasConsequence ? 'joker' : this.getCardTypeName(card.type), hasConsequence, cardTypeNumber: card.type, question: card.question?.substring(0, 50), fieldType: field.type }); // Log card drawn if (gameState) { await this.turnHistoryService.logTurnAction( gameCode, playerId, playerName, gameState.turnNumber || 1, TurnActionType.CARD_DRAWN, currentPosition, currentPosition, // Position unchanged at this point { cardId: card.cardid, cardType: hasConsequence ? 'joker' : this.getCardTypeName(card.type), question: card.question, fieldType: field.type } ); } // Check card type and handle accordingly if (isLuckType || hasConsequence) { // Update position to destination FIRST (player has landed on the luck field) await this.updatePlayerPosition(gameCode, playerId, position); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: position, fieldType: field.type, timestamp: new Date().toISOString() }); // Luck card - process immediately (no answer required) const result = this.cardDrawingService.processLuckCard(card); // Broadcast luck result this.io.of('/game').to(gameRoomName).emit('game:card-result', { playerName, playerId, correct: true, consequence: result.consequence, description: result.description, timestamp: new Date().toISOString() }); // Process luck card with multi-turn support // Note: processLuckCard will update position again if consequence moves player if (card.consequence) { await this.processLuckCard(gameCode, playerId, playerName, card.consequence, position); } else { // Fallback to old method if no consequence object await this.applyCardConsequence(gameCode, playerId, playerName, result.consequence); } } else { // Question card - send to player for answer if (!cardDrawResult.clientData) { logError('Client data missing for question card'); logOther('Card details for missing clientData', { cardType: card.type, cardId: card.cardid, hasCard: !!card, cardKeys: card ? Object.keys(card) : [] }); await this.advanceTurn(gameCode); return; } // Update position to destination FIRST (player has landed on the field) await this.updatePlayerPosition(gameCode, playerId, position); // Emit player-arrived event so frontend shows the position this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: position, fieldType: field.type, timestamp: new Date().toISOString() }); // Send interactive card to the player who drew it const playerRoomName = `game_${gameCode}:${playerName}`; this.io.of('/game').to(playerRoomName).emit('game:card-drawn-self', { cardData: cardDrawResult.clientData, timeLimit: 60, // 60 seconds to answer timestamp: new Date().toISOString() }); logOther('Card sent to player for answer', { playerName, playerId, cardId: card.cardid, cardType: this.getCardTypeName(card.type), hasClientData: !!cardDrawResult.clientData, clientDataKeys: cardDrawResult.clientData ? Object.keys(cardDrawResult.clientData) : [] }); // Start answer timeout const answerKey = this.cardDrawingService.startAnswerTimeout( gameCode, playerId, card, this.handleCardAnswerTimeout.bind(this) ); // Store pending card in Redis await this.storePendingCard(gameCode, playerId, { playerId: playerId, playerName: playerName, card: card, field: field, dice: dice, currentPosition: currentPosition, landedPosition: position, // Store where they landed drawnAt: Date.now() }); logOther(`Stored pending card for player ${playerName}`, { gameCode, playerId, cardType: this.getCardTypeName(card.type), cardId: card.cardid, redisKey: `game_pending_card:${gameCode}:${playerId}` }); } } catch (error) { logError('Error handling special field landing', error as Error); await this.advanceTurn(gameCode); } } /** * Handle card answer timeout (player didn't answer in time) */ private async handleCardAnswerTimeout(gameCode: string, playerId: string, card: any): Promise { try { // Clear from Redis await this.clearPendingCard(gameCode, playerId); const gameRoomName = `game_${gameCode}`; const pendingCard = await this.getPendingCard(gameCode, playerId); const playerName = pendingCard?.playerName || 'Player'; // Broadcast timeout to all players this.io.of('/game').to(gameRoomName).emit('game:card-timeout', { playerName, playerId, message: '⏰ Time\'s up!', timestamp: new Date().toISOString() }); // Process as timeout (automatic wrong answer) const result = this.cardDrawingService.processTimeoutAnswer(card); // Broadcast result this.io.of('/game').to(gameRoomName).emit('game:card-result', { playerName, playerId, correct: false, consequence: result.consequence, description: result.description, timestamp: new Date().toISOString() }); // Apply penalty await this.applyCardConsequence(gameCode, playerId, playerName, result.consequence); } catch (error) { logError('Error handling card answer timeout', error as Error); } } /** * Get human-readable card type name */ private getCardTypeName(cardType?: number): string { if (cardType === undefined) return 'unknown'; const typeNames = ['quiz', 'sentence_pairing', 'own_answer', 'true_false', 'closer', 'joker', 'luck']; return typeNames[cardType] || 'unknown'; } private async handleDisconnect(socket: AuthenticatedSocket): Promise { logOther(`Game socket disconnected: ${socket.id} (player: ${socket.playerName})`); // If the socket was in a game, handle cleanup if (socket.gameCode && socket.playerName) { try { // Check if this player is the gamemaster const game = await this.gameRepository.findByGameCode(socket.gameCode); const isGamemaster = game && socket.userId && game.createdby === socket.userId; // If gamemaster leaves, end the game immediately if (isGamemaster && game) { logOther(`Gamemaster ${socket.playerName} left game ${socket.gameCode}, ending game`); const gameRoomName = `game_${socket.gameCode}`; // Notify all players this.io.of('/game').to(gameRoomName).emit('game:ended', { reason: 'gamemaster_left', gamemasterName: socket.playerName, message: `🎭 Gamemaster ${socket.playerName} left. Game has ended.`, timestamp: new Date().toISOString() }); // Update database await this.gameRepository.update(game.id, { state: GameState.CANCELLED, enddate: new Date() }); // Clean up all game data await this.cleanupGameData(socket.gameCode, game.id); return; // Exit early, no need for further cleanup } // Clean up any pending card answer if (socket.userId) { const pendingCard = await this.getPendingCard(socket.gameCode, socket.userId); if (pendingCard) { // Clear timeout const answerKey = `${socket.gameCode}:${socket.userId}`; this.cardDrawingService.clearAnswerTimeout(answerKey); await this.clearPendingCard(socket.gameCode, socket.userId); // Notify others const gameRoomName = `game_${socket.gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:player-disconnected-during-card', { playerName: socket.playerName, playerId: socket.userId, timestamp: new Date().toISOString() }); } } // Update player connection status await this.updatePlayerConnection(socket.gameCode, socket.playerName, false); // Create snapshot on player disconnect during active game const gameState = await this.getCurrentGameState(socket.gameCode); if (gameState) { await this.gameSnapshotService.createSnapshot( socket.gameCode, (gameState.currentTurn || 0) + 1, SnapshotTrigger.PLAYER_DISCONNECT, `Player ${socket.playerName} disconnected` ).catch(err => { logError('Failed to create disconnect snapshot', err as Error); logOther('Disconnect snapshot context', { gameCode: socket.gameCode, playerName: socket.playerName }); }); } // Check if disconnected player was current player const playerIdentifier = socket.userId || socket.playerName; if (gameState && gameState.currentPlayer === playerIdentifier) { logOther(`Current player ${socket.playerName} disconnected, advancing turn`, { gameCode: socket.gameCode }); // Broadcast disconnect during turn const gameRoomName = `game_${socket.gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:player-disconnected-during-turn', { playerName: socket.playerName, playerId: playerIdentifier, message: `${socket.playerName} disconnected during their turn`, timestamp: new Date().toISOString() }); // Advance to next player await this.advanceTurn(socket.gameCode); } // Clean up player-specific Redis data await this.cleanupPlayerData(socket.gameCode, socket.playerName, socket.userId); // Notify other players about disconnection const gameRoomName = `game_${socket.gameCode}`; socket.to(gameRoomName).emit('game:player-disconnected', { playerName: socket.playerName, playerId: socket.userId, timestamp: new Date().toISOString() }); // Check if this was the last player - if so, consider ending/cleaning the game const connectedPlayers = await this.getConnectedPlayers(socket.gameCode); if (connectedPlayers.length === 0) { logOther(`All players disconnected from game ${socket.gameCode}, scheduling cleanup`); // Schedule cleanup after a delay to allow for reconnections setTimeout(async () => { const stillConnected = await this.getConnectedPlayers(socket.gameCode!); if (stillConnected.length === 0) { await this.handleAbandonedGame(socket.gameCode!); } }, 60000); // 1 minute delay } } catch (error) { logError('Error updating player connection on disconnect', error as Error); logOther('Disconnect error context', { gameCode: socket.gameCode, playerName: socket.playerName }); } } } /** * Clean up player-specific data when they disconnect * @param gameCode Game code * @param playerName Player name * @param playerId Player ID */ private async cleanupPlayerData(gameCode: string, playerName: string, playerId?: string): Promise { try { // Remove from ready players await this.redisService.setRemove(`game_ready:${gameCode}`, playerName); // Remove from pending players if they were pending await this.redisService.setRemove(`game_pending:${gameCode}`, playerName); logOther(`Cleaned up player data for ${playerName} in game ${gameCode}`); } catch (error) { logError('Error cleaning up player data', error as Error); } } /** * Handle games that have been abandoned by all players * @param gameCode Game code */ private async handleAbandonedGame(gameCode: string): Promise { try { const game = await this.gameRepository.findByGameCode(gameCode); if (!game) return; // Only clean up games that haven't finished yet if (game.state !== GameState.FINISHED && game.state !== GameState.CANCELLED) { logOther(`Handling abandoned game ${gameCode}`, { gameId: game.id }); // Mark game as cancelled in database await this.gameRepository.update(game.id, { state: GameState.CANCELLED, enddate: new Date(), }); // Clean up all Redis data for this abandoned game await this.cleanupGameData(gameCode, game.id); logOther(`Abandoned game ${gameCode} has been cleaned up`); } } catch (error) { logError('Error handling abandoned game', error as Error); } } // Helper methods for game state management private async getGameState(gameCode: string): Promise { try { // Try gameplay first (game started/in-progress) const gameplayState = await this.getCurrentGameState(gameCode); if (gameplayState) { return gameplayState; } // Fallback to game: key for pre-game lobby const gameKey = `game:${gameCode}`; const gameStr = await this.redisService.get(gameKey); if (gameStr) { const gameData = JSON.parse(gameStr); // Add pending players for private games if (gameData.logintype === LoginType.PRIVATE) { gameData.pendingPlayers = await this.getPendingPlayers(gameCode); } return gameData; } return null; } catch (error) { logError('Error getting game state', error as Error); return null; } } private async processGameAction(game: GameAggregate, playerId: string, action: string, actionData: any): Promise<{ success: boolean; data?: any; error?: string; stateChanged?: boolean }> { // This would contain the actual game logic // For now, returning a placeholder switch (action) { case 'roll-dice': // Handle dice rolling logic const diceResult = Math.floor(Math.random() * 6) + 1; return { success: true, data: { dice: diceResult }, stateChanged: true }; case 'move': // Handle player movement logic return { success: true, data: { newPosition: actionData.position }, stateChanged: true }; case 'use-field': // Handle special field usage return { success: true, data: { fieldUsed: actionData.fieldType }, stateChanged: true }; case 'end-turn': // Handle turn ending logic return { success: true, data: { nextPlayer: 'next-player-id' }, stateChanged: true }; default: return { success: false, error: 'Unknown action type' }; } } private async updatePlayerConnection(gameCode: string, playerName: string, connected: boolean): Promise { const key = `game_connections:${gameCode}`; if (connected) { await this.redisService.setAdd(key, playerName); } else { await this.redisService.setRemove(key, playerName); } // Note: RedisService doesn't have expire method, we'll handle expiration differently } private async updatePlayerReadyStatus(gameCode: string, playerName: string, ready: boolean): Promise { const key = `game_ready:${gameCode}`; if (ready) { await this.redisService.setAdd(key, playerName); } else { await this.redisService.setRemove(key, playerName); } // Note: RedisService doesn't have expire method, we'll handle expiration differently } private async addToPendingPlayers(gameCode: string, playerName: string): Promise { const key = `game_pending:${gameCode}`; await this.redisService.setAdd(key, playerName); } private async removeFromPendingPlayers(gameCode: string, playerName: string): Promise { const key = `game_pending:${gameCode}`; await this.redisService.setRemove(key, playerName); } private async getPendingPlayers(gameCode: string): Promise { const key = `game_pending:${gameCode}`; return await this.redisService.setMembers(key); } // Redis methods for pending card answers private async storePendingCard(gameCode: string, playerId: string, cardState: PendingCardState): Promise { const key = `game_pending_card:${gameCode}:${playerId}`; await this.redisService.setWithExpiry(key, JSON.stringify(cardState), 90); // 90 seconds (30 seconds buffer after timeout) } private async getPendingCard(gameCode: string, playerId: string): Promise { const key = `game_pending_card:${gameCode}:${playerId}`; const dataStr = await this.redisService.get(key); return dataStr ? JSON.parse(dataStr) : null; } private async clearPendingCard(gameCode: string, playerId: string): Promise { const key = `game_pending_card:${gameCode}:${playerId}`; await this.redisService.del(key); } // Redis methods for pending gamemaster decisions private async storePendingDecision(gameCode: string, requestId: string, decisionState: PendingDecisionState): Promise { const key = `game_pending_decision:${gameCode}:${requestId}`; await this.redisService.setWithExpiry(key, JSON.stringify(decisionState), 150); // 150 seconds (30 seconds buffer after timeout) } private async getPendingDecision(gameCode: string, requestId: string): Promise { const key = `game_pending_decision:${gameCode}:${requestId}`; const dataStr = await this.redisService.get(key); return dataStr ? JSON.parse(dataStr) : null; } private async clearPendingDecision(gameCode: string, requestId: string): Promise { const key = `game_pending_decision:${gameCode}:${requestId}`; await this.redisService.del(key); } // Helper to get all pending decision keys for a game private async getAllPendingDecisionKeys(gameCode: string): Promise { // Note: This is a simplified version. In production, you might want to maintain a set of request IDs // For now, we'll rely on the GamemasterService's in-memory tracking const pendingDecisions = this.gamemasterService.getPendingDecisionsForGame(gameCode); return pendingDecisions.map(d => d.requestId); } private async getCurrentGameState(gameCode: string): Promise { try { const gamePlayKey = `gameplay:${gameCode}`; const gameStateStr = await this.redisService.get(gamePlayKey); if (gameStateStr) { return JSON.parse(gameStateStr); } return null; } catch (error) { logError('Error getting current game state', error as Error); return null; } } private async getPlayerPositions(gameCode: string): Promise { try { // Get positions from gameplay (single source of truth) const gameState = await this.getCurrentGameState(gameCode); if (gameState && gameState.players) { return gameState.players.map((player: any) => ({ playerId: player.playerId, playerName: player.playerName || player.playerId, boardPosition: player.position || 0, turnOrder: player.turnOrder })); } return []; } catch (error) { logError('Error getting player positions', error as Error); return []; } } private async updatePlayerPosition(gameCode: string, playerId: string, newPosition: number): Promise { try { // Update position in gameplay (single source of truth) const gameState = await this.getCurrentGameState(gameCode); if (gameState && gameState.players) { const player = gameState.players.find((p: any) => p.playerId === playerId); if (player) { player.position = newPosition; // Save updated gameplay state const gamePlayKey = `gameplay:${gameCode}`; await this.redisService.set(gamePlayKey, JSON.stringify(gameState)); } } } catch (error) { logError('Error updating player position', error as Error); } } // ============================================ // TURN TRACKING REDIS METHODS // ============================================ /** * Set the number of extra turns for a player */ private async setPlayerExtraTurns( gameCode: string, playerId: string, count: number ): Promise { const key = `player_extra_turns:${gameCode}:${playerId}`; await this.redisService.set(key, count.toString()); logOther(`Set extra turns for player ${playerId}`, { gameCode, count }); } /** * Get the number of extra turns for a player */ private async getPlayerExtraTurns( gameCode: string, playerId: string ): Promise { const key = `player_extra_turns:${gameCode}:${playerId}`; const value = await this.redisService.get(key); return value ? parseInt(value, 10) : 0; } /** * Decrement extra turns by 1, delete key if reaches 0 */ private async decrementPlayerExtraTurns( gameCode: string, playerId: string ): Promise { const current = await this.getPlayerExtraTurns(gameCode, playerId); if (current > 1) { await this.setPlayerExtraTurns(gameCode, playerId, current - 1); } else { const key = `player_extra_turns:${gameCode}:${playerId}`; await this.redisService.del(key); } } /** * Set the number of turns to lose for a player */ private async setPlayerTurnsToLose( gameCode: string, playerId: string, count: number ): Promise { const key = `player_turns_to_lose:${gameCode}:${playerId}`; await this.redisService.set(key, count.toString()); logOther(`Set turns to lose for player ${playerId}`, { gameCode, count }); } /** * Get the number of turns to lose for a player */ private async getPlayerTurnsToLose( gameCode: string, playerId: string ): Promise { const key = `player_turns_to_lose:${gameCode}:${playerId}`; const value = await this.redisService.get(key); return value ? parseInt(value, 10) : 0; } /** * Decrement turns to lose by 1, delete key if reaches 0 */ private async decrementPlayerTurnsToLose( gameCode: string, playerId: string ): Promise { const current = await this.getPlayerTurnsToLose(gameCode, playerId); if (current > 1) { await this.setPlayerTurnsToLose(gameCode, playerId, current - 1); } else { const key = `player_turns_to_lose:${gameCode}:${playerId}`; await this.redisService.del(key); } } /** * Clear all turn tracking data for a player */ private async clearPlayerTurnData( gameCode: string, playerId: string ): Promise { await this.redisService.del(`player_extra_turns:${gameCode}:${playerId}`); await this.redisService.del(`player_turns_to_lose:${gameCode}:${playerId}`); } // ============================================ // POSITION GUESSING MECHANICS // ============================================ /** * Determine if position guess is required based on field type and answer correctness * * Logic: * - Positive field + correct answer = GUESS (reward scenario) * - Positive field + wrong answer = NO GUESS (no movement) * - Negative field + correct answer = NO GUESS (avoid penalty) * - Negative field + wrong answer = GUESS (penalty scenario) * - Regular field = NO GUESS (never guess on regular fields) */ private determineGuessRequirement( fieldType: 'regular' | 'positive' | 'negative' | 'luck', answerCorrect: boolean ): boolean { if (fieldType === 'positive') { return answerCorrect; // Correct = guess for reward } else if (fieldType === 'negative') { return !answerCorrect; // Wrong = guess for penalty } return false; // Regular and luck fields never require guess } /** * Request position guess from player with stepping calculation info */ private async requestPositionGuess( gameCode: string, playerId: string, playerName: string, pendingState: PendingCardState ): Promise { const gameRoomName = `game_${gameCode}`; const playerRoomName = `game_${gameCode}:${playerName}`; // Calculate what the actual position would be (without showing to player yet) // Use the LANDED field position for pattern modifier calculation const landedFieldPosition = pendingState.field.position; const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( landedFieldPosition, pendingState.field.stepValue || 0, pendingState.dice ); // Calculate the ACTUAL pattern modifier based on the LANDED field position const stepValue = pendingState.field.stepValue || 0; const positiveField = stepValue > 0; const actualPatternModifier = this.boardGenerationService.getPatternModifier(landedFieldPosition, positiveField); // Store stepping info for later validation pendingState.requiresGuess = true; const cardKey = `game_pending_card:${gameCode}:${playerId}`; await this.redisService.setWithExpiry(cardKey, JSON.stringify(pendingState), 30); // Start timeout for position guess (30 seconds) setTimeout(() => { this.handlePositionGuessTimeout(gameCode, playerId, playerName, pendingState); }, 30000); // Notify player to guess - send the ACTUAL pattern modifier and LANDED field position this.io.of('/game').to(playerRoomName).emit('game:position-guess-request', { message: 'Guess your final position!', currentPosition: landedFieldPosition, diceRoll: pendingState.dice, fieldStepValue: pendingState.field.stepValue || 0, patternModifier: actualPatternModifier, timeLimit: 30, timestamp: new Date().toISOString() }); // Notify others that player is guessing this.io.of('/game').to(gameRoomName).emit('game:player-guessing', { playerId, playerName, message: `${playerName} is guessing their final position...`, timestamp: new Date().toISOString() }); logOther(`Position guess requested from ${playerName}`, { gameCode }); } /** * Handle position guess timeout (player didn't guess in time) */ private async handlePositionGuessTimeout( gameCode: string, playerId: string, playerName: string, pendingState: PendingCardState ): Promise { try { // Check if pending state still exists (player might have already guessed) const cardKey = `game_pending_card:${gameCode}:${playerId}`; const stateJson = await this.redisService.get(cardKey); if (!stateJson) { // Already processed, nothing to do return; } // Clear from Redis await this.clearPendingCard(gameCode, playerId); const gameRoomName = `game_${gameCode}`; // Broadcast timeout to all players this.io.of('/game').to(gameRoomName).emit('game:guess-timeout', { playerId, playerName, message: `⏰ ${playerName} didn't guess in time!`, timestamp: new Date().toISOString() }); // Calculate actual position using LANDED field position const landedFieldPosition = pendingState.field.position; const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( landedFieldPosition, pendingState.field.stepValue || 0, pendingState.dice ); // Apply -2 penalty for timeout (treated as wrong guess) const finalPosition = Math.max(1, actualPosition - 2); // Update player position await this.updatePlayerPosition(gameCode, playerId, finalPosition); // Emit player-arrived event FIRST (before guess-result) this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: finalPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); // Calculate the actual pattern modifier used (based on LANDED field) const stepValue = pendingState.field.stepValue || 0; const positiveField = stepValue > 0; const actualPatternModifier = this.boardGenerationService.getPatternModifier(landedFieldPosition, positiveField); // Broadcast result this.io.of('/game').to(gameRoomName).emit('game:guess-result', { playerId, playerName, guessedPosition: null, actualPosition: actualPosition, finalPosition: finalPosition, guessCorrect: false, penaltyApplied: true, calculation: { startPosition: landedFieldPosition, diceRoll: pendingState.dice, stepValue: pendingState.field.stepValue || 0, patternModifier: actualPatternModifier, calculatedPosition: actualPosition, penalty: -2 }, message: `❌ ${playerName} timed out! Penalty applied. Final position: ${finalPosition}`, timestamp: new Date().toISOString() }); // Check for win condition if (finalPosition >= 100) { await this.endGame(gameCode, playerId, playerName); return; } // Check if landed on special field (secondary landing) // For positive/negative fields, this will draw joker card // For luck fields, this will return false and we'll handle them below const secondaryLandingHandled = await this.checkSecondaryLanding( gameCode, playerId, playerName, finalPosition, 0 // recursion depth ); if (secondaryLandingHandled) { // Joker card flow initiated, don't advance turn return; } // Check if landed on luck field (non-joker secondary landing) const boardData = await this.getBoardData(gameCode); if (boardData && boardData.fields) { const landedField = boardData.fields.find((f: GameField) => f.position === finalPosition); if (landedField && landedField.type === 'luck') { // Handle luck field normally await this.handleSpecialFieldLanding( gameCode, playerId, playerName, landedField, finalPosition, 6, // Secondary landing uses dice = 6 finalPosition // Use finalPosition as currentPosition for next card draw ); return; } } // No special field, advance turn await this.advanceTurn(gameCode); } catch (error) { logError('Error handling position guess timeout', error as Error); // Don't call advanceTurn here - already called above if successful } } /** * Handle position guess submission from player */ private async handlePositionGuess(socket: AuthenticatedSocket, data: { gameCode: string; guessedPosition: number; }): Promise { try { const { gameCode, guessedPosition } = data; const playerId = socket.userId || socket.playerName; const playerName = socket.playerName; if (!playerId || !playerName) { socket.emit('error', { message: 'Player identification failed' }); return; } // Get pending card state const cardKey = `game_pending_card:${gameCode}:${playerId}`; const stateJson = await this.redisService.get(cardKey); if (!stateJson) { socket.emit('error', { message: 'No pending guess found' }); return; } // Clear from Redis immediately to prevent timeout handler from processing await this.clearPendingCard(gameCode, playerId); const pendingState: PendingCardState = JSON.parse(stateJson); pendingState.guessedPosition = guessedPosition; // Broadcast the guess to everyone const gameRoomName = `game_${gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:position-guess-broadcast', { playerId, playerName, guessedPosition, message: `${playerName} guessed position ${guessedPosition}`, timestamp: new Date().toISOString() }); // Process the guess with FieldEffectService await this.processQuestionCardWithGuess(gameCode, pendingState); // Clean up pending state await this.redisService.del(cardKey); } catch (error) { logError('Error handling position guess', error as Error); socket.emit('error', { message: 'Failed to process guess' }); } } /** * Process question card with guess using FieldEffectService */ private async processQuestionCardWithGuess( gameCode: string, pendingState: PendingCardState ): Promise { // Calculate actual position using BoardGenerationService // Use the LANDED field position for calculation const landedFieldPosition = pendingState.field.position; const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( landedFieldPosition, pendingState.field.stepValue || 0, pendingState.dice ); let finalPosition = actualPosition; let guessCorrect = false; let penaltyApplied = false; // Check if guess was correct if (pendingState.guessedPosition === actualPosition) { guessCorrect = true; } else { // Wrong guess: apply -2 penalty finalPosition = Math.max(1, actualPosition - 2); penaltyApplied = true; } // Update player position await this.updatePlayerPosition(gameCode, pendingState.playerId, finalPosition); // Calculate the actual pattern modifier used (based on LANDED field) const stepValue = pendingState.field.stepValue || 0; const positiveField = stepValue > 0; const actualPatternModifier = this.boardGenerationService.getPatternModifier(landedFieldPosition, positiveField); // Emit player-arrived event FIRST (before guess-result) const gameRoomName = `game_${gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId: pendingState.playerId, playerName: pendingState.playerName, position: finalPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); // Broadcast result this.io.of('/game').to(gameRoomName).emit('game:guess-result', { playerId: pendingState.playerId, playerName: pendingState.playerName, guessedPosition: pendingState.guessedPosition, actualPosition: actualPosition, finalPosition: finalPosition, guessCorrect, penaltyApplied, calculation: { startPosition: pendingState.field.position, diceRoll: pendingState.dice, stepValue: pendingState.field.stepValue || 0, patternModifier: actualPatternModifier, calculatedPosition: actualPosition, penalty: penaltyApplied ? -2 : 0 }, message: guessCorrect ? `✅ ${pendingState.playerName} guessed correctly! Moved to ${finalPosition}` : `❌ ${pendingState.playerName} guessed wrong! Penalty applied. Final position: ${finalPosition}`, timestamp: new Date().toISOString() }); // Check for win condition (position 100) if (finalPosition >= 100) { await this.endGame(gameCode, pendingState.playerId, pendingState.playerName); return; } // Check if landed on special field (secondary landing) // For positive/negative fields, this will draw joker card // For luck fields, this will return false and we'll handle them below const secondaryLandingHandled = await this.checkSecondaryLanding( gameCode, pendingState.playerId, pendingState.playerName, finalPosition, 0 // recursion depth ); if (secondaryLandingHandled) { // Joker card flow initiated, don't advance turn return; } // Check if landed on luck field (non-joker secondary landing) const boardData = await this.getBoardData(gameCode); if (boardData && boardData.fields) { const landedField = boardData.fields.find((f: GameField) => f.position === finalPosition); if (landedField && landedField.type === 'luck') { // Handle luck field normally await this.handleSpecialFieldLanding( gameCode, pendingState.playerId, pendingState.playerName, landedField, finalPosition, 6, // Secondary landing uses dice = 6 finalPosition // Use finalPosition as currentPosition for next card draw ); return; } } // No special field, advance turn await this.advanceTurn(gameCode); } /** * Process luck card consequence with multi-turn support */ private async processLuckCard( gameCode: string, playerId: string, playerName: string, consequence: { type: number; value?: number }, currentPosition: number ): Promise { const gameRoomName = `game_${gameCode}`; let newPosition = currentPosition; let shouldAdvanceTurn = true; const consequenceValue = consequence.value || 1; // ConsequenceType enum: 0=MOVE_FORWARD, 1=MOVE_BACKWARD, 2=LOSE_TURN, 3=EXTRA_TURN, 5=GO_TO_START switch (consequence.type) { case 0: // MOVE_FORWARD newPosition = Math.min(currentPosition + consequenceValue, 100); await this.updatePlayerPosition(gameCode, playerId, newPosition); this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { playerId, playerName, consequenceType: 'MOVE_FORWARD', value: consequenceValue, newPosition, message: `${playerName} moves forward ${consequenceValue} steps to position ${newPosition}!`, timestamp: new Date().toISOString() }); // Emit player-arrived so frontend visualizes the movement this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: newPosition, fieldType: 'luck', timestamp: new Date().toISOString() }); break; case 1: // MOVE_BACKWARD newPosition = Math.max(1, currentPosition - consequenceValue); await this.updatePlayerPosition(gameCode, playerId, newPosition); this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { playerId, playerName, consequenceType: 'MOVE_BACKWARD', value: consequenceValue, newPosition, message: `${playerName} moves backward ${consequenceValue} steps to position ${newPosition}!`, timestamp: new Date().toISOString() }); // Emit player-arrived so frontend visualizes the movement this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: newPosition, fieldType: 'luck', timestamp: new Date().toISOString() }); break; case 2: // LOSE_TURN // Store turns to lose in Redis await this.setPlayerTurnsToLose(gameCode, playerId, consequenceValue); // Emit turn-lost event this.io.of('/game').to(gameRoomName).emit('game:turn-lost', { playerId, playerName, turnsToLose: consequenceValue, message: `${playerName} will lose ${consequenceValue} turn(s)!`, timestamp: new Date().toISOString() }); this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { playerId, playerName, consequenceType: 'LOSE_TURN', value: consequenceValue, turnsToLose: consequenceValue, message: `${playerName} will lose ${consequenceValue} turn(s)!`, timestamp: new Date().toISOString() }); shouldAdvanceTurn = true; // Skip to next player immediately break; case 3: // EXTRA_TURN // Store extra turns in Redis await this.setPlayerExtraTurns(gameCode, playerId, consequenceValue); this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { playerId, playerName, consequenceType: 'EXTRA_TURN', value: consequenceValue, extraTurns: consequenceValue, message: `${playerName} gets ${consequenceValue} extra turn(s)!`, timestamp: new Date().toISOString() }); shouldAdvanceTurn = true; // Let advanceTurn() handle extra turns break; case 5: // GO_TO_START newPosition = 1; await this.updatePlayerPosition(gameCode, playerId, newPosition); this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { playerId, playerName, consequenceType: 'GO_TO_START', value: consequenceValue, newPosition: 1, message: `${playerName} goes back to START!`, timestamp: new Date().toISOString() }); break; } // Check for win condition (position 100) if (newPosition >= 100) { await this.endGame(gameCode, playerId, playerName); return; } // Advance turn if needed if (shouldAdvanceTurn) { await this.advanceTurn(gameCode); } } // ============================================ // JOKER CARD POSITION GUESSING // ============================================ /** * Request position guess from player AFTER gamemaster decision (for jokers) */ private async requestJokerPositionGuess( gameCode: string, playerId: string, playerName: string, pendingState: PendingDecisionState ): Promise { const gameRoomName = `game_${gameCode}`; const playerRoomName = `game_${gameCode}:${playerName}`; // Calculate stepping info with dice = 6, using LANDED field position const landedFieldPosition = pendingState.field.position; const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( landedFieldPosition, pendingState.field.stepValue || 0, 6 // Joker cards always use dice value of 6 ); // Calculate the ACTUAL pattern modifier based on LANDED field position const stepValue = pendingState.field.stepValue || 0; const positiveField = stepValue > 0; const actualPatternModifier = this.boardGenerationService.getPatternModifier(landedFieldPosition, positiveField); // Update pending state const decisionKey = `pending_decision:${gameCode}:${pendingState.playerId}`; await this.redisService.setWithExpiry(decisionKey, JSON.stringify(pendingState), 30); // Start timeout for joker position guess (30 seconds) setTimeout(() => { this.handleJokerPositionGuessTimeout(gameCode, playerId, playerName, pendingState); }, 30000); // Notify player to guess - send the ACTUAL pattern modifier and LANDED field position this.io.of('/game').to(playerRoomName).emit('game:joker-position-guess-request', { message: 'Guess your final position after joker!', currentPosition: landedFieldPosition, diceRoll: 6, fieldStepValue: pendingState.field.stepValue || 0, patternModifier: actualPatternModifier, timeLimit: 30, timestamp: new Date().toISOString() }); // Notify others this.io.of('/game').to(gameRoomName).emit('game:player-guessing', { playerId, playerName, message: `${playerName} is guessing their position after joker...`, timestamp: new Date().toISOString() }); logOther(`Joker position guess requested from ${playerName}`, { gameCode }); } /** * Handle joker position guess timeout (player didn't guess in time) */ private async handleJokerPositionGuessTimeout( gameCode: string, playerId: string, playerName: string, pendingState: PendingDecisionState ): Promise { try { // Check if pending state still exists (player might have already guessed) const decisionKey = `pending_decision:${gameCode}:${playerId}`; const stateJson = await this.redisService.get(decisionKey); if (!stateJson) { // Already processed, nothing to do return; } // Clear from Redis await this.clearPendingDecision(gameCode, playerId); const gameRoomName = `game_${gameCode}`; // Broadcast timeout to all players this.io.of('/game').to(gameRoomName).emit('game:guess-timeout', { playerId, playerName, message: `⏰ ${playerName} didn't guess in time after joker!`, timestamp: new Date().toISOString() }); // Default behavior on timeout: no movement (player stays at current position) this.io.of('/game').to(gameRoomName).emit('game:joker-complete', { playerId, playerName, guessedPosition: null, actualPosition: pendingState.currentPosition, finalPosition: pendingState.currentPosition, guessCorrect: false, penaltyApplied: false, moved: false, calculation: { startPosition: pendingState.field.position, diceRoll: 6, stepValue: pendingState.field.stepValue || 0, patternModifier: this.boardGenerationService.getPatternModifier(pendingState.field.position, (pendingState.field.stepValue || 0) > 0), calculatedPosition: pendingState.currentPosition, penalty: 0 }, message: `${playerName} timed out on joker guess (no movement)`, timestamp: new Date().toISOString() }); // Advance turn await this.advanceTurn(gameCode); } catch (error) { logError('Error handling joker position guess timeout', error as Error); // Ensure turn advances even on error await this.advanceTurn(gameCode); } } /** * Handle joker position guess submission */ private async handleJokerPositionGuess(socket: AuthenticatedSocket, data: { gameCode: string; guessedPosition: number; }): Promise { try { const { gameCode, guessedPosition } = data; const playerId = socket.userId || socket.playerName; const playerName = socket.playerName; if (!playerId || !playerName) { socket.emit('error', { message: 'Player identification failed' }); return; } // Get pending decision state - try with playerId as key let decisionKey = `pending_decision:${gameCode}:${playerId}`; let stateJson = await this.redisService.get(decisionKey); if (!stateJson) { socket.emit('error', { message: 'No pending joker guess found' }); return; } // Clear from Redis immediately to prevent timeout handler from processing await this.clearPendingDecision(gameCode, playerId); const pendingState: PendingDecisionState = JSON.parse(stateJson); pendingState.guessedPosition = guessedPosition; // Broadcast the guess const gameRoomName = `game_${gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:position-guess-broadcast', { playerId, playerName, guessedPosition, message: `${playerName} guessed position ${guessedPosition}`, timestamp: new Date().toISOString() }); // Calculate actual position using BoardGenerationService const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( pendingState.currentPosition, pendingState.field.stepValue || 0, 6 // Joker always uses dice = 6 ); let finalPosition = actualPosition; let guessCorrect = false; let penaltyApplied = false; // Check guess if (guessedPosition === actualPosition) { guessCorrect = true; } else { finalPosition = Math.max(1, actualPosition - 2); penaltyApplied = true; } // Apply movement based on field type and gamemaster decision const shouldMove = (pendingState.field.type === 'positive' && pendingState.gamemasterApproved) || (pendingState.field.type === 'negative' && !pendingState.gamemasterApproved); if (shouldMove) { await this.updatePlayerPosition(gameCode, playerId, finalPosition); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: finalPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); } // Calculate the actual pattern modifier used const stepValue = pendingState.field.stepValue || 0; const positiveField = stepValue > 0; const actualPatternModifier = this.boardGenerationService.getPatternModifier(pendingState.currentPosition, positiveField); // Broadcast joker complete this.io.of('/game').to(gameRoomName).emit('game:joker-complete', { playerId, playerName, guessedPosition, actualPosition: actualPosition, finalPosition: shouldMove ? finalPosition : pendingState.currentPosition, guessCorrect, penaltyApplied, moved: shouldMove, calculation: { startPosition: pendingState.field.position, diceRoll: 6, stepValue: pendingState.field.stepValue || 0, patternModifier: actualPatternModifier, calculatedPosition: actualPosition, penalty: penaltyApplied ? -2 : 0 }, message: shouldMove ? (guessCorrect ? `✅ ${playerName} guessed correctly! Moved to ${finalPosition}` : `❌ ${playerName} guessed wrong! Penalty applied. Final position: ${finalPosition}`) : `${playerName} did not move (gamemaster decision)`, timestamp: new Date().toISOString() }); // Clean up await this.redisService.del(decisionKey); // Check for win condition const movedPosition = shouldMove ? finalPosition : pendingState.currentPosition; if (movedPosition >= 100) { await this.endGame(gameCode, playerId, playerName); return; } // Check if landed on special field (secondary landing) if moved if (shouldMove) { // For positive/negative fields, this will draw joker card // For luck fields, this will return false and we'll handle them below const secondaryLandingHandled = await this.checkSecondaryLanding( gameCode, playerId, playerName, finalPosition, 0 // recursion depth ); if (secondaryLandingHandled) { // Joker card flow initiated, don't advance turn return; } // Check if landed on luck field (non-joker secondary landing) const boardData = await this.getBoardData(gameCode); if (boardData && boardData.fields) { const landedField = boardData.fields.find((f: GameField) => f.position === finalPosition); if (landedField && landedField.type === 'luck') { // Handle luck field normally await this.handleSpecialFieldLanding( gameCode, playerId, playerName, landedField, finalPosition, 6, // Secondary landing uses dice = 6 finalPosition // Use finalPosition as currentPosition for next card draw ); return; } } } // No special field or didn't move, advance turn await this.advanceTurn(gameCode); } catch (error) { logError('Error handling joker position guess', error as Error); socket.emit('error', { message: 'Failed to process joker guess' }); } } private async advanceTurn(gameCode: string): Promise { try { const gameState = await this.getCurrentGameState(gameCode); if (!gameState) return; const currentTurnIndex = gameState.currentTurn || 0; const currentPlayerId = gameState.turnSequence[currentTurnIndex]; // ========================================== // PHASE 1: Check if current player has extra turns // ========================================== const extraTurns = await this.getPlayerExtraTurns(gameCode, currentPlayerId); if (extraTurns > 0) { // Current player gets another turn await this.decrementPlayerExtraTurns(gameCode, currentPlayerId); const playerPositions = await this.getPlayerPositions(gameCode); const currentPlayer = playerPositions.find(p => p.playerId === currentPlayerId); const currentPlayerName = currentPlayer?.playerName || currentPlayerId; // Notify about extra turn const gameRoomName = `game_${gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:extra-turn-remaining', { playerId: currentPlayerId, playerName: currentPlayerName, remainingExtraTurns: extraTurns - 1, message: `${currentPlayerName} has ${extraTurns - 1} extra turn(s) remaining!`, timestamp: new Date().toISOString() }); // Notify player they can roll again const playerRoomName = `game_${gameCode}:${currentPlayerName}`; this.io.of('/game').to(playerRoomName).emit('game:your-turn', { message: 'Extra turn! Roll the dice again!', canRoll: true, isExtraTurn: true, timestamp: new Date().toISOString() }); logOther(`Player ${currentPlayerName} using extra turn`, { gameCode, remainingExtraTurns: extraTurns - 1 }); return; // Same player continues, don't advance } // ========================================== // PHASE 2: Find next player, skipping those with lost turns // ========================================== let nextTurnIndex = (currentTurnIndex + 1) % gameState.turnSequence.length; const skippedPlayers: Array<{ playerId: string; playerName: string; remainingTurnsToLose: number; }> = []; let loopGuard = 0; const maxLoops = gameState.turnSequence.length; while (loopGuard < maxLoops) { const candidatePlayerId = gameState.turnSequence[nextTurnIndex]; const turnsToLose = await this.getPlayerTurnsToLose(gameCode, candidatePlayerId); if (turnsToLose > 0) { // This player loses their turn await this.decrementPlayerTurnsToLose(gameCode, candidatePlayerId); const playerPositions = await this.getPlayerPositions(gameCode); const skippedPlayer = playerPositions.find(p => p.playerId === candidatePlayerId); const skippedPlayerName = skippedPlayer?.playerName || candidatePlayerId; skippedPlayers.push({ playerId: candidatePlayerId, playerName: skippedPlayerName, remainingTurnsToLose: turnsToLose - 1 }); logOther(`Player ${skippedPlayerName} turn skipped`, { gameCode, remainingTurnsToLose: turnsToLose - 1 }); // Move to next player in sequence nextTurnIndex = (nextTurnIndex + 1) % gameState.turnSequence.length; loopGuard++; } else { // Found a player who can play break; } } // ========================================== // PHASE 3: Update game state with valid next player // ========================================== const nextPlayerId = gameState.turnSequence[nextTurnIndex]; gameState.currentTurn = nextTurnIndex; gameState.currentPlayer = nextPlayerId; // Save updated state const gamePlayKey = `gameplay:${gameCode}`; await this.redisService.set(gamePlayKey, JSON.stringify(gameState)); // Create snapshot every 5 turns const newTurnNumber = nextTurnIndex + 1; if (this.gameSnapshotService.shouldCreateSnapshot(newTurnNumber)) { await this.gameSnapshotService.createSnapshot( gameCode, newTurnNumber, SnapshotTrigger.TURN_INTERVAL, `Automatic snapshot at turn ${newTurnNumber}` ).catch(err => { logError('Failed to create turn snapshot', err as Error); logOther('Turn snapshot context', { gameCode, turnNumber: newTurnNumber }); }); } // Get next player info const playerPositions = await this.getPlayerPositions(gameCode); const nextPlayer = playerPositions.find(p => p.playerId === nextPlayerId); const nextPlayerName = nextPlayer?.playerName || nextPlayerId; // ========================================== // PHASE 4: Notify about skipped players (if any) // ========================================== const gameRoomName = `game_${gameCode}`; if (skippedPlayers.length > 0) { this.io.of('/game').to(gameRoomName).emit('game:players-skipped', { skippedPlayers, message: `${skippedPlayers.map(p => p.playerName).join(', ')} skipped due to lost turn(s)`, timestamp: new Date().toISOString() }); } // ========================================== // PHASE 5: Notify about turn change // ========================================== this.io.of('/game').to(gameRoomName).emit('game:turn-changed', { currentPlayer: nextPlayerId, currentPlayerName: nextPlayerName, turnNumber: nextTurnIndex + 1, message: `It's ${nextPlayerName}'s turn!`, timestamp: new Date().toISOString() }); // Send special notification to the current player const playerRoomName = `game_${gameCode}:${nextPlayerName}`; this.io.of('/game').to(playerRoomName).emit('game:your-turn', { message: 'It\'s your turn! Roll the dice!', canRoll: true, timestamp: new Date().toISOString() }); logOther(`Turn advanced in game ${gameCode}`, { previousTurn: currentTurnIndex, newTurn: nextTurnIndex, nextPlayer: nextPlayerName, skippedCount: skippedPlayers.length }); } catch (error) { logError('Error advancing turn', error as Error); } } private async endGame(gameCode: string, winnerId: string, winnerName: string): Promise { try { // Update game state to finished const gameState = await this.getCurrentGameState(gameCode); if (gameState) { gameState.gamePhase = 'finished'; gameState.winner = winnerId; gameState.winnerName = winnerName; gameState.endedAt = new Date().toISOString(); const gamePlayKey = `gameplay:${gameCode}`; await this.redisService.set(gamePlayKey, JSON.stringify(gameState)); } // Update database game record const game = await this.gameRepository.findByGameCode(gameCode); if (game) { await this.gameRepository.update(game.id, { state: GameState.FINISHED, winnerId: winnerId, enddate: new Date() }); } // Broadcast game end to all players const gameRoomName = `game_${gameCode}`; this.io.of('/game').to(gameRoomName).emit('game:ended', { winner: winnerId, winnerName: winnerName, message: `🎉 ${winnerName} won the game! Congratulations!`, finalPositions: await this.getPlayerPositions(gameCode), timestamp: new Date().toISOString() }); // Clean up all game-related Redis data and socket connections await this.cleanupGameData(gameCode, game?.id); logOther(`Game ${gameCode} ended and cleaned up`, { winner: winnerName, winnerId, gameId: game?.id }); } catch (error) { logError('Error ending game', error as Error); } } private async checkAllPlayersReady(gameCode: string): Promise { try { // Get connected players from Redis const connectedPlayers = await this.getConnectedPlayers(gameCode); const readyPlayers = await this.getReadyPlayers(gameCode); // All connected players must be ready for the game to start return readyPlayers.length === connectedPlayers.length && connectedPlayers.length > 1; } catch (error) { logError('Error checking if all players are ready', error as Error); return false; } } /** * Apply card consequence (movement, turn effects) to a player */ private async applyCardConsequence(gameCode: string, playerId: string, playerName: string, consequence: number, recursionDepth: number = 0): Promise { try { // Safety check: prevent infinite loops const MAX_RECURSION_DEPTH = 5; if (recursionDepth >= MAX_RECURSION_DEPTH) { logWarning(`Max recursion depth reached for consequence application in game ${gameCode}`); await this.advanceTurn(gameCode); return; } // ConsequenceType enum: // 0: MOVE_FORWARD, 1: MOVE_BACKWARD, 2: LOSE_TURN, 3: EXTRA_TURN, 5: GO_TO_START const positions = await this.getPlayerPositions(gameCode); const currentPlayer = positions.find(p => p.playerId === playerId); if (!currentPlayer) { logWarning(`Player ${playerId} not found when applying consequence`); return; } const gameRoomName = `game_${gameCode}`; let newPosition = currentPlayer.boardPosition; let positionChanged = false; switch (consequence) { case 0: // MOVE_FORWARD newPosition = Math.min(currentPlayer.boardPosition + 3, 101); // Move forward 3 steps positionChanged = newPosition !== currentPlayer.boardPosition; await this.updatePlayerPosition(gameCode, playerId, newPosition); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: newPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { playerName, playerId, type: 'move_forward', oldPosition: currentPlayer.boardPosition, newPosition, timestamp: new Date().toISOString() }); break; case 1: // MOVE_BACKWARD newPosition = Math.max(currentPlayer.boardPosition - 3, 0); // Move backward 3 steps positionChanged = newPosition !== currentPlayer.boardPosition; await this.updatePlayerPosition(gameCode, playerId, newPosition); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: newPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { playerName, playerId, type: 'move_backward', oldPosition: currentPlayer.boardPosition, newPosition, timestamp: new Date().toISOString() }); break; case 2: // LOSE_TURN // Immediately advance to next player await this.advanceTurn(gameCode); this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { playerName, playerId, type: 'lose_turn', timestamp: new Date().toISOString() }); return; // Early return, turn already advanced break; case 3: // EXTRA_TURN // Don't advance turn, player gets to go again const playerRoomName = `game_${gameCode}:${playerName}`; this.io.of('/game').to(playerRoomName).emit('game:extra-turn', { message: 'You get an extra turn!', canRoll: true, timestamp: new Date().toISOString() }); this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { playerName, playerId, type: 'extra_turn', timestamp: new Date().toISOString() }); return; // Early return, no turn advance needed break; case 5: // GO_TO_START newPosition = 0; positionChanged = newPosition !== currentPlayer.boardPosition; await this.updatePlayerPosition(gameCode, playerId, newPosition); // Emit player-arrived event this.io.of('/game').to(gameRoomName).emit('game:player-arrived', { playerId, playerName, position: newPosition, fieldType: 'normal', timestamp: new Date().toISOString() }); this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { playerName, playerId, type: 'go_to_start', oldPosition: currentPlayer.boardPosition, newPosition, timestamp: new Date().toISOString() }); break; default: logWarning(`Unknown consequence type: ${consequence}`); } // Check for secondary special field landing (only if position changed) if (positionChanged && newPosition > 0 && newPosition < 101) { const secondaryLanding = await this.checkSecondaryLanding(gameCode, playerId, playerName, newPosition, recursionDepth); if (secondaryLanding) { // Secondary landing detected, joker flow initiated, don't advance turn yet return; } } // If no secondary landing, advance to next player await this.advanceTurn(gameCode); } catch (error) { logError('Error applying card consequence', error as Error); } } /** * Check if player landed on special field as result of consequence * Returns true if joker card flow was initiated, false otherwise */ private async checkSecondaryLanding(gameCode: string, playerId: string, playerName: string, position: number, recursionDepth: number): Promise { try { logOther(`🔍 Checking secondary landing for ${playerName} at position ${position}`, { gameCode, playerId, playerName, position, recursionDepth }); const boardData = await this.getBoardData(gameCode); if (!boardData || !boardData.fields) { logOther('❌ No board data found for secondary landing check'); return false; } const landedField = boardData.fields.find((f: GameField) => f.position === position); // Check if field is special (positive or negative only for joker) if (!landedField || !this.isSpecialField(landedField)) { logOther(`❌ Position ${position} is not a special field`, { hasField: !!landedField, fieldType: landedField?.type }); return false; } // Only positive and negative fields trigger joker on secondary landing if (landedField.type !== 'positive' && landedField.type !== 'negative') { logOther(`❌ Field type ${landedField.type} does not trigger joker (only positive/negative do)`); return false; } logOther(`✅ Secondary landing detected on ${landedField.type} field at position ${position} - Drawing joker card!`, { fieldType: landedField.type, position }); const gameRoomName = `game_${gameCode}`; // Notify players about secondary landing this.io.of('/game').to(gameRoomName).emit('game:secondary-landing', { playerName, playerId, position, fieldType: landedField.type, message: `${playerName} landed on a ${landedField.type} field! Drawing joker card...`, timestamp: new Date().toISOString() }); // Wait 2 seconds for animation await new Promise(resolve => setTimeout(resolve, 2000)); // Draw joker card await this.handleJokerCardDrawing(gameCode, playerId, playerName, landedField, position, recursionDepth); return true; // Joker flow initiated } catch (error) { logError('Error checking secondary landing', error as Error); return false; } } /** * Handle joker card drawing and gamemaster decision flow */ private async handleJokerCardDrawing(gameCode: string, playerId: string, playerName: string, field: GameField, position: number, recursionDepth: number): Promise { try { const gameRoomName = `game_${gameCode}`; // Get game data const gameData = await this.gameRepository.findByGameCode(gameCode); if (!gameData) { logError('Game not found when drawing joker card'); await this.advanceTurn(gameCode); return; } // Draw joker card const jokerResult = this.cardDrawingService.drawJokerCard(gameData, playerId); if (!jokerResult.success || !jokerResult.card) { // No more joker cards available this.io.of('/game').to(gameRoomName).emit('game:joker-error', { playerName, playerId, error: jokerResult.error || 'No joker cards available', timestamp: new Date().toISOString() }); await this.advanceTurn(gameCode); return; } const jokerCard = jokerResult.card; // Emit joker-activated event this.io.of('/game').to(gameRoomName).emit('game:joker-activated', { playerName, playerId, message: `${playerName} activated a Joker card!`, timestamp: new Date().toISOString() }); // Broadcast joker drawn to all players this.io.of('/game').to(gameRoomName).emit('game:joker-drawn', { playerName, playerId, jokerCard: { question: jokerCard.question, consequence: jokerCard.consequence }, waitingForGamemaster: true, timestamp: new Date().toISOString() }); // Request gamemaster decision const requestId = this.gamemasterService.requestGamemasterDecision( gameCode, playerId, playerName, jokerCard, (reqId: string) => this.handleGamemasterDecisionTimeout(gameCode, reqId, playerId, playerName, recursionDepth) ); // Store pending decision in Redis await this.storePendingDecision(gameCode, requestId, { playerId, playerName, card: jokerCard, field: field, dice: 6, // Joker cards always use dice value of 6 currentPosition: position, // Current position is where they landed drawnAt: Date.now(), recursionDepth }); // Find gamemaster const gamemaster = gameData.createdby; const gamemasterUser = await this.userRepository.findById(gamemaster); const gamemasterName = gamemasterUser?.username || 'Gamemaster'; // Send decision request to gamemaster only const gamemasterRoomName = `game_${gameCode}:${gamemasterName}`; this.io.of('/game').to(gamemasterRoomName).emit('game:gamemaster-decision-request', { requestId, playerName, playerId, jokerCard: { question: jokerCard.question, consequence: jokerCard.consequence }, timeLimit: 120, // 120 seconds recursionDepth, // Send to frontend for context timestamp: new Date().toISOString() }); logOther(`Joker card drawn for ${playerName}, waiting for gamemaster decision`, { gameCode, requestId, recursionDepth }); } catch (error) { logError('Error handling joker card drawing', error as Error); await this.advanceTurn(gameCode); } } /** * Handle gamemaster decision timeout (120 seconds elapsed) */ private async handleGamemasterDecisionTimeout(gameCode: string, requestId: string, playerId: string, playerName: string, recursionDepth: number): Promise { try { // Clear from Redis await this.clearPendingDecision(gameCode, requestId); const gameRoomName = `game_${gameCode}`; // Broadcast timeout to all players this.io.of('/game').to(gameRoomName).emit('game:gamemaster-timeout', { playerName, playerId, message: '🎭 Gamemaster didn\'t respond in time. No effect applied.', timestamp: new Date().toISOString() }); // Process timeout through GamemasterService const result = this.gamemasterService.processTimeoutDecision(requestId); if (result) { // Broadcast final result this.io.of('/game').to(gameRoomName).emit('game:gamemaster-decision-result', { playerName, playerId, gamemasterName: 'System (timeout)', decision: 'timeout', consequence: result.consequence, description: result.description, timestamp: new Date().toISOString() }); } // Advance turn (no consequence applied on timeout) await this.advanceTurn(gameCode); } catch (error) { logError('Error handling gamemaster decision timeout', error as Error); await this.advanceTurn(gameCode); } } // Public method to broadcast game state updates from external services public async broadcastGameStateUpdate(gameCode: string, gameState: any): Promise { const roomName = `game_${gameCode}`; this.io.of('/game').to(roomName).emit('game:state-update', gameState); } // Public method to broadcast game events from external services public async broadcastGameEvent(gameCode: string, event: string, data: any): Promise { const roomName = `game_${gameCode}`; this.io.of('/game').to(roomName).emit(event, data); } // Public method to send events to a specific player public async sendToPlayer(gameCode: string, playerName: string, event: string, data: any): Promise { const playerRoomName = `game_${gameCode}:${playerName}`; this.io.of('/game').to(playerRoomName).emit(event, data); logOther(`Sent event '${event}' to player ${playerName} in game ${gameCode}`); } // Public method to send events to multiple specific players public async sendToPlayers(gameCode: string, playerNames: string[], event: string, data: any): Promise { for (const playerName of playerNames) { await this.sendToPlayer(gameCode, playerName, event, data); } } // Public method to get connected players in a game public async getConnectedPlayers(gameCode: string): Promise { const key = `game_connections:${gameCode}`; return await this.redisService.setMembers(key); } // Public method to get ready players in a game public async getReadyPlayers(gameCode: string): Promise { const key = `game_ready:${gameCode}`; return await this.redisService.setMembers(key); } // Public method to broadcast game start with board data and player order public async broadcastGameStart(gameCode: string, boardData: any, playerOrder: string[], gameData: any): Promise { try { const roomName = `game_${gameCode}`; // Create comprehensive game start data const gameStartData = { gameCode, gameId: gameData.id, status: 'started', boardData, playerOrder, currentPlayer: playerOrder[0], // First player starts currentTurn: 0, maxPlayers: gameData.maxplayers, players: gameData.players, startedAt: new Date().toISOString(), message: 'Game has started! Good luck to all players!' }; // Broadcast to all players in the game this.io.of('/game').to(roomName).emit('game:start', gameStartData); // Note: Game state is already stored in gameplay:{gameCode} - no need for duplicate game_state: entry // Initialize player positions (all start at 0) const playerPositions = await this.getPlayerPositions(gameCode); // Notify the first player that it's their turn const firstPlayerName = playerPositions.find(p => p.playerId === playerOrder[0])?.playerName || playerOrder[0]; const firstPlayerRoomName = `game_${gameCode}:${firstPlayerName}`; // Send turn-changed event for initial turn with player name this.io.of('/game').to(roomName).emit('game:turn-changed', { currentPlayer: playerOrder[0], currentPlayerName: firstPlayerName, turnNumber: 1, message: `It's ${firstPlayerName}'s turn!`, timestamp: new Date().toISOString() }); this.io.of('/game').to(firstPlayerRoomName).emit('game:your-turn', { message: 'You go first! Roll the dice to start the game!', canRoll: true, timestamp: new Date().toISOString() }); logOther(`Game start broadcasted to all players in room: ${roomName}`, { gameCode, gameId: gameData.id, playerCount: gameData.players.length, boardFields: boardData?.fields?.length || 0, firstPlayer: playerOrder[0], firstPlayerName }); } catch (error) { logError('Error broadcasting game start', error as Error); throw error; // Re-throw so the caller knows the broadcast failed } } /** * Comprehensive cleanup of all game-related data when game ends * @param gameCode Game code * @param gameId Game ID from database */ private async cleanupGameData(gameCode: string, gameId?: string): Promise { try { logOther(`Starting cleanup for game ${gameCode}`, { gameId }); // 1. Force disconnect all players from game rooms const gameRoomName = `game_${gameCode}`; const gameRoom = this.io.of('/game').adapter.rooms.get(gameRoomName); if (gameRoom) { // Get all socket IDs in the room const socketIds = Array.from(gameRoom); for (const socketId of socketIds) { const socket = this.io.of('/game').sockets.get(socketId); if (socket) { // Leave game rooms await socket.leave(gameRoomName); await socket.leave(`game_${gameCode}:${(socket as any).playerName}`); // Clear game-related socket data (socket as any).gameCode = undefined; (socket as any).playerName = undefined; // Notify player that game has ended socket.emit('game:cleanup-complete', { gameCode, message: 'Game session has ended and been cleaned up', timestamp: new Date().toISOString() }); } } } // 2. Clean up all Redis game data const keysToClean = [ `gameplay:${gameCode}`, // Game play state (contains everything) `game_connections:${gameCode}`, // Connected players `game_ready:${gameCode}`, // Ready players `game_pending:${gameCode}`, // Pending players (for private games) `game_room:${gameCode}` // Game room mapping ]; // Clean up legacy keys if they exist if (gameId) { keysToClean.push(`game_board_${gameId}`); // Legacy board storage keysToClean.push(`game_state:${gameCode}`); // Legacy game state } // Clean up game-specific keys for (const key of keysToClean) { await this.redisService.del(key); } // Clean up all pending card answers for this game const connectedPlayers = await this.getConnectedPlayers(gameCode); for (const playerId of connectedPlayers) { await this.clearPendingCard(gameCode, playerId); } // Clean up all pending gamemaster decisions const pendingDecisionIds = await this.getAllPendingDecisionKeys(gameCode); for (const requestId of pendingDecisionIds) { await this.clearPendingDecision(gameCode, requestId); // Also cancel in GamemasterService to clear timeouts this.gamemasterService.cancelDecision(requestId); } // Clean up turn tracking for all players const gameState = await this.getCurrentGameState(gameCode); if (gameState?.turnSequence) { for (const playerId of gameState.turnSequence) { await this.clearPlayerTurnData(gameCode, playerId); } } // Clean up additional game keys const additionalKeys = [ `game:${gameCode}` // Pre-game lobby data (uses gameCode not gameId) ]; for (const key of additionalKeys) { await this.redisService.del(key); } logOther(`Game cleanup completed for ${gameCode}`, { gameId, keysCleanedCount: keysToClean.length + (gameId ? 3 : 0) }); } catch (error) { logError('Error during game cleanup', error as Error); logOther('Game cleanup failed', { gameCode, gameId, errorMessage: error instanceof Error ? error.message : String(error) }); } } /** * Public method to manually trigger game cleanup (for external services) * @param gameCode Game code to clean up * @param gameId Optional game ID */ public async triggerGameCleanup(gameCode: string, gameId?: string): Promise { logOther(`Manual cleanup triggered for game ${gameCode}`, { gameId }); await this.cleanupGameData(gameCode, gameId); } /** * Get board data for a game from Redis * Board data is stored in gameplay:{gameCode} */ private async getBoardData(gameCode: string): Promise { try { // Get from gameplay (single source of truth) const gameState = await this.getCurrentGameState(gameCode); return gameState?.boardData || null; } catch (error) { logError('Error getting board data', error as Error); return null; } } /** * Check if field is special (requires card drawing) * @param field Game field to check * @returns True if field is special */ private isSpecialField(field: GameField): boolean { return field.type === 'positive' || field.type === 'negative' || field.type === 'luck'; } /** * Check if card is a luck card * @param cardType Card type * @returns True if luck card */ private isLuckCard(cardType?: number): boolean { return cardType === 6; // Luck cards have type 6 } /** * Create snapshots for all active games (called on server shutdown) * @returns Number of snapshots created */ public async snapshotAllActiveGames(): Promise { try { logOther('Creating snapshots for all active games before shutdown'); // Find all active games from database const activeGames = await this.gameRepository.findActiveGames(); let snapshotCount = 0; for (const game of activeGames) { try { // Get current game state from Redis const gameState = await this.getCurrentGameState(game.gamecode); if (!gameState) { logOther(`Skipping game ${game.gamecode} - no Redis state found`); continue; } const turnNumber = (gameState.currentTurn || 0) + 1; await this.gameSnapshotService.createSnapshot( game.gamecode, turnNumber, SnapshotTrigger.SERVER_SHUTDOWN, `Server shutdown snapshot` ); snapshotCount++; logOther(`Created shutdown snapshot for game ${game.gamecode}`, { turnNumber }); } catch (gameError) { logError(`Failed to snapshot game ${game.gamecode}`, gameError as Error); } } logOther(`Completed shutdown snapshots`, { totalGames: activeGames.length, successfulSnapshots: snapshotCount }); return snapshotCount; } catch (error) { logError('Error creating shutdown snapshots', error as Error); return 0; } } /** * Restore all active games from latest snapshots (called on server startup) * @returns Number of games restored */ public async restoreAllActiveGames(): Promise { try { logOther('Attempting to restore active games from snapshots'); // Find all active games from database const activeGames = await this.gameRepository.findActiveGames(); let restoredCount = 0; for (const game of activeGames) { try { // Try to restore from latest snapshot const restored = await this.gameSnapshotService.restoreFromSnapshot(game.gamecode); if (restored) { restoredCount++; logOther(`Restored game ${game.gamecode} from snapshot`); } else { logOther(`No snapshot found for game ${game.gamecode}, skipping`); } } catch (gameError) { logError(`Failed to restore game ${game.gamecode}`, gameError as Error); } } logOther(`Completed game restoration`, { totalGames: activeGames.length, restoredGames: restoredCount }); return restoredCount; } catch (error) { logError('Error restoring games from snapshots', error as Error); return 0; } } }