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

3566 lines
147 KiB
TypeScript

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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<any> {
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<void> {
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<void> {
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<void> {
const key = `game_pending:${gameCode}`;
await this.redisService.setAdd(key, playerName);
}
private async removeFromPendingPlayers(gameCode: string, playerName: string): Promise<void> {
const key = `game_pending:${gameCode}`;
await this.redisService.setRemove(key, playerName);
}
private async getPendingPlayers(gameCode: string): Promise<string[]> {
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<void> {
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<PendingCardState | null> {
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<void> {
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<void> {
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<PendingDecisionState | null> {
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<void> {
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<string[]> {
// 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<any | null> {
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<PlayerPosition[]> {
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<void> {
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<void> {
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<number> {
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<void> {
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<void> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string[]> {
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<string[]> {
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<void> {
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<void> {
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<void> {
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<any> {
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<number> {
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<number> {
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;
}
}
}