fel kesz game backend
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export interface GenerateBoardCommand {
|
||||
gameId: string;
|
||||
positiveFieldCount: number;
|
||||
negativeFieldCount: number;
|
||||
luckFieldCount: number;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
||||
import { BoardGenerationService } from '../BoardGenerationService';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { BoardData } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export class GenerateBoardCommandHandler {
|
||||
constructor(
|
||||
private readonly boardGenerationService: BoardGenerationService,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async execute(cmd: GenerateBoardCommand): Promise<void> {
|
||||
try {
|
||||
logOther(`Starting board generation for game ${cmd.gameId}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate board with 20-30 rule validation
|
||||
const boardData = await this.boardGenerationService.generateBoard(
|
||||
cmd.positiveFieldCount,
|
||||
cmd.negativeFieldCount,
|
||||
cmd.luckFieldCount
|
||||
);
|
||||
|
||||
// Store in Redis
|
||||
const boardDataWithMetadata: BoardData = {
|
||||
...boardData,
|
||||
gameId: cmd.gameId,
|
||||
generatedAt: new Date(),
|
||||
generationComplete: true
|
||||
};
|
||||
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_board_${cmd.gameId}`,
|
||||
JSON.stringify(boardDataWithMetadata),
|
||||
24 * 60 * 60 // 24 hours
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
logOther(`Board generation completed for game ${cmd.gameId} in ${executionTime}ms. Error rate: ${boardData.totalErrorRate}%`);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Board generation failed for game ${cmd.gameId}:`, error as Error);
|
||||
|
||||
// Store error state in Redis
|
||||
const errorData: BoardData = {
|
||||
gameId: cmd.gameId,
|
||||
fields: [],
|
||||
border: [],
|
||||
validationResults: {},
|
||||
totalErrorRate: 100,
|
||||
generationComplete: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
generatedAt: new Date()
|
||||
};
|
||||
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_board_${cmd.gameId}`,
|
||||
JSON.stringify(errorData),
|
||||
24 * 60 * 60
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface JoinGameCommand {
|
||||
gameCode: string; // 6-character game code
|
||||
playerId?: string; // User ID of the player joining (optional for public games)
|
||||
playerName?: string; // Display name for the player (required for public games)
|
||||
orgId?: string | null; // Organization ID (for organization games)
|
||||
loginType: LoginType; // Type of join being attempted
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { JoinGameCommand } from './JoinGameCommand';
|
||||
import { GameAggregate, GameState, LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface GamePlayerData {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
joinedAt: Date;
|
||||
isOnline: boolean;
|
||||
position?: number; // For game board position (to be used later)
|
||||
}
|
||||
|
||||
export interface ActiveGameData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
currentPlayers: GamePlayerData[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
currentTurn?: string; // Player ID whose turn it is
|
||||
websocketRoom: string; // WebSocket room name for real-time updates
|
||||
}
|
||||
|
||||
export class JoinGameCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: JoinGameCommand): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Joining game', `gameCode: ${command.gameCode}, playerId: ${command.playerId || 'anonymous'}, loginType: ${command.loginType}`);
|
||||
|
||||
// Find the game by game code
|
||||
const game = await this.gameRepository.findByGameCode(command.gameCode);
|
||||
if (!game) {
|
||||
throw new Error(`Game with code ${command.gameCode} not found`);
|
||||
}
|
||||
|
||||
// Generate player ID for public games or use provided one
|
||||
const actualPlayerId = command.playerId || uuidv4();
|
||||
|
||||
// Validate game joinability (authentication/org checks done in router)
|
||||
this.validateGameJoinability(game, actualPlayerId, command);
|
||||
|
||||
// Add player to database
|
||||
const updatedGame = await this.gameRepository.addPlayerToGame(game.id, actualPlayerId);
|
||||
if (!updatedGame) {
|
||||
throw new Error('Failed to add player to game');
|
||||
}
|
||||
|
||||
// Update Redis with the new player
|
||||
await this.updateGameInRedis(updatedGame, { ...command, playerId: actualPlayerId });
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Player joined game successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerId: actualPlayerId,
|
||||
playerCount: updatedGame.players.length,
|
||||
maxPlayers: updatedGame.maxplayers,
|
||||
loginType: game.logintype,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return updatedGame;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to join game', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game join failed', {
|
||||
gameCode: command.gameCode,
|
||||
playerId: command.playerId || 'anonymous',
|
||||
loginType: command.loginType,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateGameJoinability(game: GameAggregate, playerId: string, command: JoinGameCommand): void {
|
||||
// Check if game is in waiting state
|
||||
if (game.state !== GameState.WAITING) {
|
||||
throw new Error('Game is not accepting new players');
|
||||
}
|
||||
|
||||
// Check if player is already in the game
|
||||
if (game.players.includes(playerId)) {
|
||||
throw new Error('Player is already in this game');
|
||||
}
|
||||
|
||||
// Check if game is full
|
||||
if (game.players.length >= game.maxplayers) {
|
||||
throw new Error('Game is full');
|
||||
}
|
||||
|
||||
// Note: Login type validation is now handled in the router before reaching this handler
|
||||
// This ensures proper authentication and organization membership checks are done first
|
||||
|
||||
logOther('Game join validation passed', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
currentPlayers: game.players.length,
|
||||
maxPlayers: game.maxplayers,
|
||||
gameState: game.state,
|
||||
loginType: game.logintype,
|
||||
playerId: playerId,
|
||||
isAuthenticated: !!command.playerId
|
||||
});
|
||||
}
|
||||
|
||||
private async updateGameInRedis(game: GameAggregate, command: JoinGameCommand & { playerId: string }): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
|
||||
// Get existing game data from Redis or create new
|
||||
let gameData: ActiveGameData;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
gameData = JSON.parse(existingData) as ActiveGameData;
|
||||
} else {
|
||||
// Create new game data structure
|
||||
gameData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
maxPlayers: game.maxplayers,
|
||||
currentPlayers: [],
|
||||
state: game.state,
|
||||
createdAt: game.createdate,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
};
|
||||
}
|
||||
|
||||
// Add the new player
|
||||
const newPlayer: GamePlayerData = {
|
||||
playerId: command.playerId,
|
||||
playerName: command.playerName,
|
||||
joinedAt: new Date(),
|
||||
isOnline: true
|
||||
};
|
||||
|
||||
// Update players list (remove if exists, then add)
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
|
||||
gameData.currentPlayers.push(newPlayer);
|
||||
|
||||
// Update game state and player count
|
||||
gameData.state = game.state;
|
||||
|
||||
// Store updated data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Add player to active players set
|
||||
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
|
||||
|
||||
logOther('Game data updated in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
redisKey,
|
||||
playerCount: gameData.currentPlayers.length,
|
||||
websocketRoom: gameData.websocketRoom,
|
||||
playerId: command.playerId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to update game in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - Redis failure shouldn't prevent game join
|
||||
logOther('Game join completed despite Redis error', {
|
||||
gameId: game.id,
|
||||
playerId: command.playerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getGameFromRedis(gameId: string): Promise<ActiveGameData | null> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGameData : null;
|
||||
} catch (error) {
|
||||
logError('Failed to get game from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removePlayerFromRedis(gameId: string, playerId: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
const gameData = JSON.parse(existingData) as ActiveGameData;
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
|
||||
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface StartGameCommand {
|
||||
deckids: string[]; // Array of deck IDs (3 types, multiple decks per type)
|
||||
maxplayers: number; // Maximum number of players
|
||||
logintype: LoginType; // How players can join the game
|
||||
userid?: string; // Optional user who created the game (becomes game master)
|
||||
orgid?: string | null; // Organization ID (for organization games)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { StartGameCommand } from './StartGameCommand';
|
||||
import { GameAggregate, GameDeck, GameCard, DeckType, GameState } from '../../../Domain/Game/GameAggregate';
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
||||
|
||||
export interface ActiveGameData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
currentPlayers: GamePlayerData[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
currentTurn?: string;
|
||||
websocketRoom: string;
|
||||
}
|
||||
|
||||
export interface GamePlayerData {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
joinedAt: Date;
|
||||
isOnline: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class StartGameCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private deckRepository: IDeckRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.deckRepository = DIContainer.getInstance().deckRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: StartGameCommand): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Starting game creation', `deckCount: ${command.deckids.length}, maxPlayers: ${command.maxplayers}, loginType: ${command.logintype}`);
|
||||
|
||||
// Generate unique game code
|
||||
const gamecode = this.generateGameCode();
|
||||
|
||||
// Fetch all decks by IDs
|
||||
const decks = await this.fetchDecks(command.deckids);
|
||||
|
||||
// Validate we have 3 deck types
|
||||
this.validateDeckTypes(decks);
|
||||
|
||||
// Group decks by type and shuffle cards within each type
|
||||
const gamedecks = await this.createShuffledGameDecks(decks);
|
||||
|
||||
// Create the game aggregate
|
||||
const gameData: Partial<GameAggregate> = {
|
||||
gamecode,
|
||||
maxplayers: command.maxplayers,
|
||||
logintype: command.logintype,
|
||||
createdby: command.userid || null,
|
||||
orgid: command.orgid || null,
|
||||
gamedecks,
|
||||
players: [],
|
||||
started: false,
|
||||
finished: false,
|
||||
winner: null,
|
||||
state: GameState.WAITING,
|
||||
startdate: null,
|
||||
enddate: null
|
||||
};
|
||||
|
||||
// Save the game to database
|
||||
const savedGame = await this.gameRepository.create(gameData);
|
||||
|
||||
// Create Redis object for real-time game management
|
||||
await this.createGameInRedis(savedGame, command.userid);
|
||||
|
||||
// Trigger async board generation (don't block game creation)
|
||||
this.triggerAsyncBoardGeneration(savedGame.id).catch((error: Error) => {
|
||||
logError('Async board generation failed', error);
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game created successfully', `gameId: ${savedGame.id}, gameCode: ${savedGame.gamecode}, executionTime: ${Math.round(endTime - startTime)}ms`);
|
||||
|
||||
return savedGame;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to create game', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game creation failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
|
||||
throw new Error('Failed to start game: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
private generateGameCode(): string {
|
||||
// Generate a 6-character alphanumeric game code
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
const randomBytesArray = randomBytes(6);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars[randomBytesArray[i] % chars.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchDecks(deckIds: string[]): Promise<DeckAggregate[]> {
|
||||
const decks: DeckAggregate[] = [];
|
||||
|
||||
for (const deckId of deckIds) {
|
||||
const deck = await this.deckRepository.findById(deckId);
|
||||
if (!deck) {
|
||||
throw new Error(`Deck with ID ${deckId} not found`);
|
||||
}
|
||||
decks.push(deck);
|
||||
}
|
||||
|
||||
return decks;
|
||||
}
|
||||
|
||||
private validateDeckTypes(decks: DeckAggregate[]): void {
|
||||
const deckTypes = new Set(decks.map(deck => deck.type));
|
||||
|
||||
// Check if we have all 3 required deck types (LUCK=0, JOKER=1, QUESTION=2)
|
||||
const requiredTypes = [0, 1, 2]; // Based on Type enum in DeckAggregate
|
||||
const missingTypes = requiredTypes.filter(type => !deckTypes.has(type));
|
||||
|
||||
if (missingTypes.length > 0) {
|
||||
throw new Error(`Missing required deck types: ${missingTypes.join(', ')}. Game requires LUCK, JOKER, and QUESTION deck types.`);
|
||||
}
|
||||
|
||||
logOther('Deck types validation passed', `foundTypes: [${Array.from(deckTypes).join(', ')}]`);
|
||||
}
|
||||
|
||||
private async createShuffledGameDecks(decks: DeckAggregate[]): Promise<GameDeck[]> {
|
||||
// Group decks by type
|
||||
const decksByType = new Map<number, DeckAggregate[]>();
|
||||
|
||||
decks.forEach(deck => {
|
||||
if (!decksByType.has(deck.type)) {
|
||||
decksByType.set(deck.type, []);
|
||||
}
|
||||
decksByType.get(deck.type)!.push(deck);
|
||||
});
|
||||
|
||||
const gamedecks: GameDeck[] = [];
|
||||
|
||||
// Process each deck type
|
||||
for (const [deckType, typeDecks] of decksByType) {
|
||||
// Collect all cards from decks of this type
|
||||
const allCards: GameCard[] = [];
|
||||
|
||||
typeDecks.forEach(deck => {
|
||||
deck.cards.forEach(card => {
|
||||
const gameCard: GameCard = {
|
||||
cardid: this.generateCardId(),
|
||||
question: card.text,
|
||||
answer: card.answer || undefined,
|
||||
consequence: card.consequence || null,
|
||||
played: false,
|
||||
playerid: undefined
|
||||
};
|
||||
allCards.push(gameCard);
|
||||
});
|
||||
});
|
||||
|
||||
// Shuffle all cards of this type
|
||||
const shuffledCards = this.shuffleArray(allCards);
|
||||
|
||||
// Create game deck for this type
|
||||
const gameDeck: GameDeck = {
|
||||
deckid: typeDecks[0].id, // Use first deck ID as representative
|
||||
decktype: this.mapDeckTypeToGameDeckType(deckType),
|
||||
cards: shuffledCards
|
||||
};
|
||||
|
||||
gamedecks.push(gameDeck);
|
||||
|
||||
logOther('Created shuffled game deck', `type: ${deckType}, cardCount: ${shuffledCards.length}, sourceDecks: ${typeDecks.length}`);
|
||||
}
|
||||
|
||||
return gamedecks;
|
||||
}
|
||||
|
||||
private mapDeckTypeToGameDeckType(deckType: number): DeckType {
|
||||
// Map DeckAggregate.Type to GameAggregate.DeckType
|
||||
switch (deckType) {
|
||||
case 0: return DeckType.LUCK; // LUCK = 0
|
||||
case 1: return DeckType.JOCKER; // JOKER = 1
|
||||
case 2: return DeckType.QUEST; // QUESTION = 2
|
||||
default: throw new Error(`Unknown deck type: ${deckType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
private generateCardId(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
private async createGameInRedis(game: GameAggregate, hostId?: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
|
||||
const gameData: ActiveGameData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: hostId,
|
||||
maxPlayers: game.maxplayers,
|
||||
currentPlayers: [],
|
||||
state: game.state,
|
||||
createdAt: game.createdate,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
};
|
||||
|
||||
// Store game data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Create game room for WebSocket connections
|
||||
await this.redisService.set(`game_room:${game.gamecode}`, game.id);
|
||||
|
||||
logOther('Game created in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: hostId,
|
||||
websocketRoom: gameData.websocketRoom,
|
||||
redisKey
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to create game in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - Redis failure shouldn't prevent game creation
|
||||
logOther('Game created successfully despite Redis error', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerAsyncBoardGeneration(gameId: string): Promise<void> {
|
||||
try {
|
||||
// Calculate default field counts based on game configuration
|
||||
// For now, use reasonable defaults - this should be configurable by host in the future
|
||||
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
|
||||
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
|
||||
|
||||
// Default distribution: 60% positive, 25% negative, 15% luck
|
||||
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
|
||||
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
|
||||
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
|
||||
|
||||
const command: GenerateBoardCommand = {
|
||||
gameId,
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
};
|
||||
|
||||
logOther(`Triggering async board generation for game ${gameId}`, {
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount,
|
||||
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
|
||||
});
|
||||
|
||||
// Execute board generation in background
|
||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Async board generation failed for game ${gameId}`, error as Error);
|
||||
// Don't propagate error - board generation failure shouldn't affect game creation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface StartGamePlayCommand {
|
||||
gameId: string; // Game ID to start
|
||||
userId?: string; // User who is starting the game (should be game master)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
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 and border
|
||||
}
|
||||
|
||||
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, {
|
||||
started: true,
|
||||
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 game is already started
|
||||
if (game.started) {
|
||||
throw new Error('Game has already been 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,
|
||||
borderLength: boardData.border ? boardData.border.length : 0,
|
||||
totalErrorRate: boardData.totalErrorRate
|
||||
});
|
||||
|
||||
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}`, {
|
||||
errorRate: boardData.totalErrorRate,
|
||||
fieldCount: boardData.fields.length,
|
||||
borderLength: boardData.border.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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user