430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import { StartGamePlayCommand } from './StartGamePlayCommand';
|
|
import { GameAggregate, GameState, BoardData, GameField } from '../../../Domain/Game/GameAggregate';
|
|
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
|
import { DIContainer } from '../../Services/DIContainer';
|
|
import { RedisService } from '../../Services/RedisService';
|
|
import { WebSocketService } from '../../Services/WebSocketService';
|
|
import { logOther, logError } from '../../Services/Logger';
|
|
|
|
export interface GamePlayerPosition {
|
|
playerId: string;
|
|
playerName?: string;
|
|
position: number; // Board position (starts at 0)
|
|
turnOrder: number; // Random number to determine turn sequence
|
|
isOnline: boolean;
|
|
joinedAt: Date;
|
|
}
|
|
|
|
export interface ActiveGamePlayData {
|
|
gameId: string;
|
|
gameCode: string;
|
|
hostId?: string;
|
|
maxPlayers: number;
|
|
players: GamePlayerPosition[];
|
|
state: GameState;
|
|
createdAt: Date;
|
|
startedAt: Date;
|
|
currentTurn: number; // Index of current player in turn order
|
|
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
|
|
websocketRoom: string;
|
|
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
|
|
boardData: BoardData; // Generated board with fields
|
|
}
|
|
|
|
export interface GameStartResult {
|
|
game: GameAggregate;
|
|
boardData: BoardData;
|
|
}
|
|
|
|
export class StartGamePlayCommandHandler {
|
|
private gameRepository: IGameRepository;
|
|
private redisService: RedisService;
|
|
|
|
constructor() {
|
|
this.gameRepository = DIContainer.getInstance().gameRepository;
|
|
this.redisService = RedisService.getInstance();
|
|
}
|
|
|
|
async handle(command: StartGamePlayCommand): Promise<GameStartResult> {
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
logOther('Starting game play', `gameId: ${command.gameId}, userId: ${command.userId || 'system'}`);
|
|
|
|
// Find the game
|
|
const game = await this.gameRepository.findById(command.gameId);
|
|
if (!game) {
|
|
throw new Error(`Game with ID ${command.gameId} not found`);
|
|
}
|
|
|
|
// Validate game can be started
|
|
this.validateGameCanStart(game, command.userId);
|
|
|
|
// Wait for board generation to complete (max 20 seconds)
|
|
const boardData = await this.waitForBoardGeneration(game.id);
|
|
|
|
// Update game state in database
|
|
const updatedGame = await this.gameRepository.update(game.id, {
|
|
state: GameState.ACTIVE,
|
|
startdate: new Date()
|
|
});
|
|
|
|
if (!updatedGame) {
|
|
throw new Error('Failed to update game state');
|
|
}
|
|
|
|
// Initialize game play in Redis with board data
|
|
await this.initializeGamePlayInRedis(updatedGame, boardData);
|
|
|
|
// Notify all players via WebSocket
|
|
await this.notifyGameStart(updatedGame);
|
|
|
|
const endTime = performance.now();
|
|
logOther('Game play started successfully', {
|
|
gameId: updatedGame.id,
|
|
gameCode: updatedGame.gamecode,
|
|
playerCount: updatedGame.players.length,
|
|
executionTime: Math.round(endTime - startTime)
|
|
});
|
|
|
|
return {
|
|
game: updatedGame,
|
|
boardData: boardData
|
|
};
|
|
|
|
} catch (error) {
|
|
const endTime = performance.now();
|
|
logError('Failed to start game play', error instanceof Error ? error : new Error(String(error)));
|
|
logOther('Game start failed', {
|
|
gameId: command.gameId,
|
|
userId: command.userId,
|
|
executionTime: Math.round(endTime - startTime)
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private validateGameCanStart(game: GameAggregate, userId?: string): void {
|
|
// Check if game is in waiting state
|
|
if (game.state !== GameState.WAITING) {
|
|
throw new Error('Game is not in waiting state and cannot be started');
|
|
}
|
|
|
|
// Check if there are enough players (at least 2)
|
|
if (game.players.length < 2) {
|
|
throw new Error('Game needs at least 2 players to start');
|
|
}
|
|
|
|
// For private and organization games, check if user is game master
|
|
if (game.createdby && userId && game.createdby !== userId) {
|
|
throw new Error('Only the game master can start this game');
|
|
}
|
|
|
|
logOther('Game start validation passed', {
|
|
gameId: game.id,
|
|
gameCode: game.gamecode,
|
|
playerCount: game.players.length,
|
|
gameState: game.state,
|
|
isGameMaster: !game.createdby || (userId && game.createdby === userId)
|
|
});
|
|
}
|
|
|
|
private async initializeGamePlayInRedis(game: GameAggregate, boardData: BoardData): Promise<void> {
|
|
try {
|
|
const redisKey = `gameplay:${game.id}`;
|
|
|
|
// Generate random turn orders for all players
|
|
const playersWithPositions = this.initializePlayerPositions(game.players);
|
|
|
|
// Sort by turn order to create turn sequence
|
|
const turnSequence = [...playersWithPositions]
|
|
.sort((a, b) => a.turnOrder - b.turnOrder)
|
|
.map(p => p.playerId);
|
|
|
|
const gamePlayData: ActiveGamePlayData = {
|
|
gameId: game.id,
|
|
gameCode: game.gamecode,
|
|
hostId: game.createdby || undefined,
|
|
maxPlayers: game.maxplayers,
|
|
players: playersWithPositions,
|
|
state: GameState.ACTIVE,
|
|
createdAt: game.createdate,
|
|
startedAt: new Date(),
|
|
currentTurn: 0, // Start with first player in sequence
|
|
turnSequence,
|
|
websocketRoom: `game_${game.gamecode}`,
|
|
gamePhase: 'starting',
|
|
boardData
|
|
};
|
|
|
|
// Store game play data in Redis with TTL (24 hours)
|
|
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gamePlayData), 24 * 60 * 60);
|
|
|
|
// Create turn sequence mapping for quick lookups
|
|
await this.redisService.setWithExpiry(
|
|
`game_turns:${game.id}`,
|
|
JSON.stringify(turnSequence),
|
|
24 * 60 * 60
|
|
);
|
|
|
|
logOther('Game play initialized in Redis', {
|
|
gameId: game.id,
|
|
gameCode: game.gamecode,
|
|
playerCount: playersWithPositions.length,
|
|
turnSequence,
|
|
currentPlayer: turnSequence[0],
|
|
redisKey
|
|
});
|
|
|
|
} catch (error) {
|
|
logError('Failed to initialize game play in Redis', error instanceof Error ? error : new Error(String(error)));
|
|
throw new Error('Failed to initialize game session');
|
|
}
|
|
}
|
|
|
|
private initializePlayerPositions(playerIds: string[]): GamePlayerPosition[] {
|
|
const players: GamePlayerPosition[] = [];
|
|
|
|
// Generate random turn orders (1 to playerCount)
|
|
const turnOrders = this.generateRandomTurnOrders(playerIds.length);
|
|
|
|
playerIds.forEach((playerId, index) => {
|
|
players.push({
|
|
playerId,
|
|
position: 0, // All players start at position 0
|
|
turnOrder: turnOrders[index],
|
|
isOnline: true, // Assume online when game starts
|
|
joinedAt: new Date()
|
|
});
|
|
});
|
|
|
|
logOther('Player positions initialized', {
|
|
playerCount: players.length,
|
|
turnOrders: turnOrders,
|
|
playersData: players.map(p => ({
|
|
playerId: p.playerId,
|
|
position: p.position,
|
|
turnOrder: p.turnOrder
|
|
}))
|
|
});
|
|
|
|
return players;
|
|
}
|
|
|
|
private generateRandomTurnOrders(playerCount: number): number[] {
|
|
// Create array [1, 2, 3, ..., playerCount]
|
|
const orders = Array.from({ length: playerCount }, (_, i) => i + 1);
|
|
|
|
// Fisher-Yates shuffle
|
|
for (let i = orders.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[orders[i], orders[j]] = [orders[j], orders[i]];
|
|
}
|
|
|
|
return orders;
|
|
}
|
|
|
|
private async notifyGameStart(game: GameAggregate): Promise<void> {
|
|
try {
|
|
// Note: WebSocket notifications will be handled when WebSocket service is available
|
|
// For now, just log the game start
|
|
logOther('Game start notifications prepared', {
|
|
gameId: game.id,
|
|
gameCode: game.gamecode,
|
|
playerCount: game.players.length,
|
|
websocketRoom: `game_${game.gamecode}`
|
|
});
|
|
|
|
// TODO: Implement WebSocket notifications when service is properly integrated
|
|
// wsService.notifyGameStart(game.gamecode, game.players);
|
|
// wsService.broadcastGameStateUpdate(game.gamecode, gameStateData);
|
|
|
|
} catch (error) {
|
|
logError('Failed to prepare game start notifications', error instanceof Error ? error : new Error(String(error)));
|
|
// Don't throw error here - notification failure shouldn't prevent game start
|
|
}
|
|
}
|
|
|
|
async getGamePlayFromRedis(gameId: string): Promise<ActiveGamePlayData | null> {
|
|
try {
|
|
const redisKey = `gameplay:${gameId}`;
|
|
const data = await this.redisService.get(redisKey);
|
|
return data ? JSON.parse(data) as ActiveGamePlayData : null;
|
|
} catch (error) {
|
|
logError('Failed to get game play from Redis', error instanceof Error ? error : new Error(String(error)));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async updatePlayerPosition(gameId: string, playerId: string, newPosition: number): Promise<void> {
|
|
try {
|
|
const gameData = await this.getGamePlayFromRedis(gameId);
|
|
if (!gameData) {
|
|
throw new Error('Game session not found');
|
|
}
|
|
|
|
// Update player position
|
|
const player = gameData.players.find(p => p.playerId === playerId);
|
|
if (player) {
|
|
player.position = newPosition;
|
|
|
|
// Save back to Redis
|
|
const redisKey = `gameplay:${gameId}`;
|
|
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
|
|
|
logOther('Player position updated', {
|
|
gameId,
|
|
playerId,
|
|
newPosition
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logError('Failed to update player position', error instanceof Error ? error : new Error(String(error)));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getNextPlayer(gameId: string): Promise<string | null> {
|
|
try {
|
|
const gameData = await this.getGamePlayFromRedis(gameId);
|
|
if (!gameData) {
|
|
return null;
|
|
}
|
|
|
|
const nextTurnIndex = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
|
return gameData.turnSequence[nextTurnIndex];
|
|
} catch (error) {
|
|
logError('Failed to get next player', error instanceof Error ? error : new Error(String(error)));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async advanceTurn(gameId: string): Promise<string | null> {
|
|
try {
|
|
const gameData = await this.getGamePlayFromRedis(gameId);
|
|
if (!gameData) {
|
|
return null;
|
|
}
|
|
|
|
// Advance to next player
|
|
gameData.currentTurn = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
|
const currentPlayer = gameData.turnSequence[gameData.currentTurn];
|
|
|
|
// Save back to Redis
|
|
const redisKey = `gameplay:${gameId}`;
|
|
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
|
|
|
logOther('Turn advanced', {
|
|
gameId,
|
|
currentTurn: gameData.currentTurn,
|
|
currentPlayer
|
|
});
|
|
|
|
return currentPlayer;
|
|
} catch (error) {
|
|
logError('Failed to advance turn', error instanceof Error ? error : new Error(String(error)));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async waitForBoardGeneration(gameId: string): Promise<BoardData> {
|
|
const maxWaitTime = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
|
|
const pollInterval = 500; // Check every 500ms
|
|
const startTime = Date.now();
|
|
|
|
logOther(`Waiting for board generation for game ${gameId}`, {
|
|
maxWaitTime: maxWaitTime / 1000,
|
|
pollInterval,
|
|
redisKey: `game_board_${gameId}`
|
|
});
|
|
|
|
while (Date.now() - startTime < maxWaitTime) {
|
|
try {
|
|
const redisKey = `game_board_${gameId}`;
|
|
const boardDataStr = await this.redisService.get(redisKey);
|
|
|
|
logOther(`Board generation check for game ${gameId}`, {
|
|
attempt: Math.floor((Date.now() - startTime) / pollInterval) + 1,
|
|
hasData: !!boardDataStr,
|
|
dataLength: boardDataStr ? boardDataStr.length : 0,
|
|
waitTime: Date.now() - startTime
|
|
});
|
|
|
|
if (boardDataStr) {
|
|
const boardData: BoardData = JSON.parse(boardDataStr);
|
|
|
|
logOther(`Board data found for game ${gameId}`, {
|
|
generationComplete: boardData.generationComplete,
|
|
hasError: !!boardData.error,
|
|
fieldsCount: boardData.fields ? boardData.fields.length : 0
|
|
});
|
|
|
|
if (boardData.generationComplete) {
|
|
if (boardData.error) {
|
|
logError(`Board generation failed for game ${gameId}`, new Error(boardData.error));
|
|
throw new Error(`Board generation failed: ${boardData.error}`);
|
|
}
|
|
|
|
logOther(`Board generation completed for game ${gameId}`, {
|
|
fieldCount: boardData.fields.length,
|
|
waitTime: Date.now() - startTime
|
|
});
|
|
|
|
return boardData;
|
|
}
|
|
} else {
|
|
// No board data found yet - check if we need to trigger generation
|
|
logOther(`No board data found yet for game ${gameId}, checking if generation was triggered...`, {
|
|
waitTime: Date.now() - startTime,
|
|
redisKey
|
|
});
|
|
|
|
// If we've waited for 2 seconds and still no data, try to trigger generation manually
|
|
if (Date.now() - startTime > 2000) {
|
|
await this.ensureBoardGenerationTriggered(gameId);
|
|
}
|
|
}
|
|
|
|
// Wait before next poll
|
|
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
|
|
} catch (error) {
|
|
logError(`Error checking board generation status for game ${gameId}`, error as Error);
|
|
throw new Error(`Failed to retrieve board data: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
// Timeout reached
|
|
logError(`Board generation timeout for game ${gameId}`, new Error(`Generation took longer than ${maxWaitTime / 1000} seconds`));
|
|
throw new Error(`Board generation timeout. Game ${gameId} is not ready to start. Please try again later.`);
|
|
}
|
|
|
|
private async ensureBoardGenerationTriggered(gameId: string): Promise<void> {
|
|
try {
|
|
logOther(`Ensuring board generation is triggered for game ${gameId}`);
|
|
|
|
// Check if generation was already triggered by looking for any board data
|
|
const redisKey = `game_board_${gameId}`;
|
|
const existingData = await this.redisService.get(redisKey);
|
|
|
|
if (!existingData) {
|
|
// No data at all - trigger generation manually
|
|
logOther(`No board generation found for game ${gameId}, triggering manually`);
|
|
|
|
// Use DIContainer to trigger board generation
|
|
const generateBoardCommand = {
|
|
gameId,
|
|
positiveFieldCount: Math.floor(67 * 0.6), // Default: 60% positive
|
|
negativeFieldCount: Math.floor(67 * 0.25), // Default: 25% negative
|
|
luckFieldCount: Math.floor(67 * 0.15) // Default: 15% luck
|
|
};
|
|
|
|
await DIContainer.getInstance().generateBoardCommandHandler.execute(generateBoardCommand);
|
|
logOther(`Board generation manually triggered for game ${gameId}`);
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to ensure board generation for game ${gameId}`, error as Error);
|
|
// Don't throw here - let the main wait loop handle the timeout
|
|
}
|
|
}
|
|
} |