3566 lines
147 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
} |