fel kesz game backend

This commit is contained in:
2025-09-15 19:00:35 +02:00
parent 7963f28021
commit 3af8de2797
267 changed files with 15655 additions and 347 deletions
@@ -0,0 +1,494 @@
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
import { logOther, logError } from '../Services/Logger';
interface TargetField {
fieldNumber: number;
distance: number;
}
interface SpecialFieldInfo {
position: number;
type: 'positive' | 'negative' | 'luck';
}
export class BoardGenerationService {
private readonly MAX_GENERATION_TIME = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
private readonly ERROR_TOLERANCE = parseInt(process.env.GENERATION_ERROR_TOLERANCE || '15');
async generateBoard(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): Promise<BoardData> {
const startTime = Date.now();
let bestAttempt: BoardData | null = null;
let attemptCount = 0;
while (Date.now() - startTime < this.MAX_GENERATION_TIME) {
attemptCount++;
try {
const attempt = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
if (attempt.totalErrorRate <= this.ERROR_TOLERANCE) {
logOther(`Board generation successful on attempt ${attemptCount}. Error rate: ${attempt.totalErrorRate}%`);
return attempt;
}
if (!bestAttempt || attempt.totalErrorRate < bestAttempt.totalErrorRate) {
bestAttempt = attempt;
}
logOther(`Attempt ${attemptCount}: Error rate ${attempt.totalErrorRate}% (target: ${this.ERROR_TOLERANCE}%)`);
} catch (error) {
logError(`Board generation attempt ${attemptCount} failed:`, error as Error);
}
}
logOther(`Using best attempt with error rate: ${bestAttempt?.totalErrorRate || 100}%`);
return bestAttempt || this.generateFallbackBoard(positiveFieldCount, negativeFieldCount, luckFieldCount);
}
private generateSingleAttempt(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): BoardData {
// Step 1: Choose special field positions
const specialFieldPositions = this.chooseSpecialFieldPositions(
positiveFieldCount,
negativeFieldCount,
luckFieldCount
);
// Step 2: Select target fields for each special field (6 targets per field for dice 1-6)
const targetFieldsMap = this.selectTargetFields(specialFieldPositions);
// Step 3: Create border with strategic placement
const border = this.createStrategicBorder(targetFieldsMap);
// Step 4: Calculate step values based on border positions
const fields = this.calculateStepValues(specialFieldPositions, targetFieldsMap, border);
// Step 5: Validate against 20-30 rule and calculate error rate
const validationResults = this.validateBoardGeneration(fields, border);
// Log generation statistics
logOther('Board generation attempt completed', {
totalFields: fields.length,
specialFields: fields.filter(f => f.type !== 'regular').length,
positiveFields: fields.filter(f => f.type === 'positive').length,
negativeFields: fields.filter(f => f.type === 'negative').length,
luckFields: fields.filter(f => f.type === 'luck').length,
errorRate: validationResults.errorRate,
targetCount: Array.from(targetFieldsMap.values()).reduce((sum, targets) => sum + targets.length, 0)
});
return {
fields,
border,
validationResults: validationResults.validationResults,
totalErrorRate: validationResults.errorRate
};
}
private chooseSpecialFieldPositions(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): SpecialFieldInfo[] {
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
const positions: number[] = [];
const specialFields: SpecialFieldInfo[] = [];
// Random placement with retry for good distribution
let attempts = 0;
while (positions.length < totalSpecial && attempts < 100) {
const position = Math.floor(Math.random() * 100) + 1; // 1-100
if (!positions.includes(position)) {
// Check minimum distance from existing positions
const tooClose = positions.some(existingPos => Math.abs(existingPos - position) < 3);
if (!tooClose || attempts > 50) { // Relax distance requirement after many attempts
positions.push(position);
}
}
attempts++;
}
// Sort positions and assign types
positions.sort((a, b) => a - b);
// Distribute types randomly
const types: ('positive' | 'negative' | 'luck')[] = [
...Array(positiveFieldCount).fill('positive'),
...Array(negativeFieldCount).fill('negative'),
...Array(luckFieldCount).fill('luck')
];
// Shuffle types
for (let i = types.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[types[i], types[j]] = [types[j], types[i]];
}
positions.forEach((position, index) => {
specialFields.push({
position,
type: types[index] || 'positive'
});
});
return specialFields;
}
private selectTargetFields(specialFields: SpecialFieldInfo[]): Map<number, TargetField[]> {
const targetFieldsMap = new Map<number, TargetField[]>();
specialFields.forEach(field => {
if (field.type === 'luck') {
// Luck fields don't need target calculations
targetFieldsMap.set(field.position, []);
return;
}
const targets: TargetField[] = [];
const usedTargets = new Set<number>();
// Generate 6 different target fields (for dice 1-6) with 20-30 rule compliance
for (let i = 0; i < 6; i++) {
let targetField: number;
let distance: number;
let attempts = 0;
do {
// Determine max distance based on field position (20-30 rule)
let maxDistance: number;
let maxBackward: number;
if (field.position <= 85) {
maxDistance = 20;
maxBackward = 20;
} else {
maxDistance = 20; // forward
maxBackward = 30; // backward
}
// Create variety in distances within the allowed range
const distanceType = Math.random();
if (distanceType < 0.5) {
// Close distance (50% chance) - 1 to 1/3 of max
distance = Math.floor(Math.random() * Math.floor(maxDistance / 3)) + 1;
} else {
// Far distance (50% chance) - 1/3 to max
distance = Math.floor(Math.random() * (maxDistance - Math.floor(maxDistance / 3))) + Math.floor(maxDistance / 3);
}
// Randomly choose forward or backward
if (Math.random() < 0.5) {
distance = -Math.min(distance, maxBackward);
} else {
distance = Math.min(distance, maxDistance);
}
targetField = field.position + distance;
// Ensure target is within valid range
if (targetField < 1) targetField = 1;
if (targetField > 100) targetField = 100;
// Recalculate actual distance after clamping
distance = Math.abs(targetField - field.position);
attempts++;
} while (usedTargets.has(targetField) && attempts < 30);
if (!usedTargets.has(targetField)) {
usedTargets.add(targetField);
targets.push({
fieldNumber: targetField,
distance: Math.abs(targetField - field.position)
});
} else {
// Fallback: use a nearby valid target
let fallbackTarget = field.position + (i - 3); // Create some variety around current position
if (fallbackTarget < 1) fallbackTarget = 1;
if (fallbackTarget > 100) fallbackTarget = 100;
targets.push({
fieldNumber: fallbackTarget,
distance: Math.abs(fallbackTarget - field.position)
});
}
}
targetFieldsMap.set(field.position, targets);
});
return targetFieldsMap;
}
private createStrategicBorder(targetFieldsMap: Map<number, TargetField[]>): number[] {
// Collect all target field numbers
const targetNumbers = new Set<number>();
targetFieldsMap.forEach(targets => {
targets.forEach(target => targetNumbers.add(target.fieldNumber));
});
// Create array of all numbers 1-100
const allNumbers = Array.from({ length: 100 }, (_, i) => i + 1);
// Separate target numbers from remaining numbers
const remainingNumbers = allNumbers.filter(num => !targetNumbers.has(num));
// Shuffle remaining numbers
for (let i = remainingNumbers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[remainingNumbers[i], remainingNumbers[j]] = [remainingNumbers[j], remainingNumbers[i]];
}
// Create border with strategic placement
const border: number[] = [];
const targetArray = Array.from(targetNumbers);
// Encourage overlap by placing target numbers first, then fill with random
let targetIndex = 0;
let remainingIndex = 0;
for (let i = 0; i < 100; i++) {
// Alternate between target numbers and remaining numbers, but favor targets when available
if (targetIndex < targetArray.length && (remainingIndex >= remainingNumbers.length || Math.random() < 0.6)) {
border.push(targetArray[targetIndex]);
targetIndex++;
} else if (remainingIndex < remainingNumbers.length) {
border.push(remainingNumbers[remainingIndex]);
remainingIndex++;
} else {
// Fallback - should not happen if logic is correct
border.push((i % 100) + 1);
}
}
return border;
}
private calculateStepValues(
specialFields: SpecialFieldInfo[],
targetFieldsMap: Map<number, TargetField[]>,
border: number[]
): GameField[] {
// Initialize all fields as regular
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
position: i + 1,
type: 'regular' as const
}));
// Update special fields with calculated step values
specialFields.forEach(specialField => {
const fieldIndex = specialField.position - 1; // Convert to 0-based index
fields[fieldIndex].type = specialField.type;
if (specialField.type === 'luck') {
// Luck fields don't need step values
return;
}
const targets = targetFieldsMap.get(specialField.position) || [];
if (targets.length === 0) return;
// NEW APPROACH: Calculate step value that will land on first target with dice=1
// This ensures we have a baseline that works, then dice 2-6 will hit other targets
const firstTarget = targets[0];
const targetIndexInBorder = border.indexOf(firstTarget.fieldNumber);
if (targetIndexInBorder !== -1) {
// Start from field position in border (field position = border index + 1, but we want 0-based)
const startBorderIndex = (specialField.position - 1) % border.length;
// Calculate step value needed to reach target with dice=1
let stepValue: number;
if (specialField.type === 'positive') {
// For positive: move right to target, then +1 more for dice=1
stepValue = targetIndexInBorder - startBorderIndex - 1; // -1 for dice offset
// Handle wrap-around
if (stepValue < 0) {
stepValue += border.length;
}
} else {
// For negative: move left to target, then -1 more for dice=1
stepValue = startBorderIndex - targetIndexInBorder + 1; // +1 for dice offset
// Handle wrap-around
if (stepValue > border.length) {
stepValue -= border.length;
}
// Make negative for negative fields
stepValue = -stepValue;
}
fields[fieldIndex].stepValue = stepValue;
// Debug logging for step value calculation
logOther(`Calculated step value for ${specialField.type} field at position ${specialField.position}`, {
targetField: firstTarget.fieldNumber,
targetIndexInBorder,
startBorderIndex,
calculatedStepValue: stepValue,
fieldType: specialField.type
});
} else {
// Fallback if target not found in border (shouldn't happen)
fields[fieldIndex].stepValue = specialField.type === 'positive' ? 1 : -1;
}
});
return fields;
}
private validateBoardGeneration(fields: GameField[], border: number[]): {
validationResults: { [fieldIndex: number]: number[] };
errorRate: number;
} {
const validationResults: { [fieldIndex: number]: number[] } = {};
let totalCombinations = 0;
let invalidCombinations = 0;
fields.forEach((field, fieldIndex) => {
if (field.type !== 'positive' && field.type !== 'negative') {
return; // Skip non-special fields
}
const diceOutcomes: number[] = [];
for (let diceValue = 1; diceValue <= 6; diceValue++) {
totalCombinations++;
try {
const result = this.calculateBorderMovement(
field.position,
field.stepValue || 0,
diceValue,
border,
field.type === 'positive'
);
// Validate 20-30 rule
const distance = Math.abs(result - field.position);
const isValid = this.validate20_30Rule(field.position, result, distance);
if (isValid) {
diceOutcomes.push(result);
} else {
diceOutcomes.push(-1); // Mark as invalid
invalidCombinations++;
}
} catch (error) {
diceOutcomes.push(-1); // Mark as invalid
invalidCombinations++;
}
}
validationResults[fieldIndex] = diceOutcomes;
});
const errorRate = totalCombinations > 0 ? (invalidCombinations / totalCombinations) * 100 : 0;
return {
validationResults,
errorRate: Math.round(errorRate * 100) / 100 // Round to 2 decimal places
};
}
private calculateBorderMovement(
currentPosition: number,
stepValue: number,
diceValue: number,
border: number[],
isPositive: boolean
): number {
// Step 1: Find border index for current field (field position corresponds to border index)
let borderIndex = (currentPosition - 1) % border.length; // Convert to 0-based, handle wraparound
// Step 2: Apply field step value (handle negative step values for negative fields)
if (isPositive) {
borderIndex = (borderIndex + Math.abs(stepValue)) % border.length;
} else {
// For negative fields, stepValue is already negative, so we subtract it (which adds its absolute value)
borderIndex = (borderIndex - stepValue + border.length) % border.length;
}
// Step 3: Apply dice value
if (isPositive) {
borderIndex = (borderIndex + diceValue) % border.length;
} else {
borderIndex = (borderIndex - diceValue + border.length) % border.length;
}
// Step 4: Return the field number at final border position
return border[borderIndex];
}
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
// Fields 1-85: max 20 fields in any direction
if (currentPosition <= 85) {
return distance <= 20;
}
// Fields 86-100: max 30 fields backward, max 20 fields forward
if (currentPosition > 85) {
if (targetPosition > currentPosition) {
// Moving forward: max 20 fields
return distance <= 20;
} else {
// Moving backward: max 30 fields
return distance <= 30;
}
}
return false;
}
private generateFallbackBoard(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): BoardData {
// Simple fallback: create basic board with minimal special fields
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
position: i + 1,
type: 'regular' as const
}));
// Add a few special fields with safe step values
let specialCount = 0;
for (let i = 10; i < 90 && specialCount < positiveFieldCount + negativeFieldCount; i += 10) {
if (specialCount < positiveFieldCount) {
fields[i].type = 'positive';
fields[i].stepValue = 1;
} else {
fields[i].type = 'negative';
fields[i].stepValue = -1;
}
specialCount++;
}
// Simple border: shuffled 1-100
const border = Array.from({ length: 100 }, (_, i) => i + 1);
for (let i = border.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[border[i], border[j]] = [border[j], border[i]];
}
return {
fields,
border,
validationResults: {},
totalErrorRate: 100 // Mark as fallback
};
}
}
@@ -0,0 +1,303 @@
import { StartGameCommand } from './commands/StartGameCommand';
import { StartGameCommandHandler } from './commands/StartGameCommandHandler';
import { JoinGameCommand } from './commands/JoinGameCommand';
import { JoinGameCommandHandler } from './commands/JoinGameCommandHandler';
import { StartGamePlayCommand } from './commands/StartGamePlayCommand';
import { StartGamePlayCommandHandler, GameStartResult } from './commands/StartGamePlayCommandHandler';
import { GameAggregate, LoginType } from '../../Domain/Game/GameAggregate';
import { logOther, logError } from '../Services/Logger';
export class GameService {
private startGameHandler: StartGameCommandHandler;
private joinGameHandler: JoinGameCommandHandler;
private startGamePlayHandler: StartGamePlayCommandHandler;
constructor() {
this.startGameHandler = new StartGameCommandHandler();
this.joinGameHandler = new JoinGameCommandHandler();
this.startGamePlayHandler = new StartGamePlayCommandHandler();
}
/**
* Starts a new game with the provided deck IDs
* @param deckids Array of deck IDs (should contain 3 types: LUCK, JOKER, QUESTION)
* @param maxplayers Maximum number of players allowed in the game
* @param logintype How players can join the game (PUBLIC, PRIVATE, ORGANIZATION)
* @param userid Optional ID of the user creating the game
* @returns Promise<GameAggregate> The created game
*/
async startGame(
deckids: string[],
maxplayers: number,
logintype: LoginType,
userid?: string,
orgid?: string | null
): Promise<GameAggregate> {
const startTime = performance.now();
try {
logOther('GameService.startGame called', {
deckCount: deckids.length,
maxplayers,
logintype,
userid,
orgid
});
// Validate input parameters
this.validateStartGameInput(deckids, maxplayers, logintype);
// Create and execute the command
const command: StartGameCommand = {
deckids,
maxplayers,
logintype,
userid,
orgid
};
const game = await this.startGameHandler.handle(command);
const endTime = performance.now();
logOther('Game started successfully', {
gameId: game.id,
gameCode: game.gamecode,
deckCount: game.gamedecks.length,
totalCards: game.gamedecks.reduce((sum, deck) => sum + deck.cards.length, 0),
executionTime: Math.round(endTime - startTime)
});
return game;
} catch (error) {
const endTime = performance.now();
logError('GameService.startGame failed', error instanceof Error ? error : new Error(String(error)));
logOther('Game start failed', {
executionTime: Math.round(endTime - startTime),
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Join an existing game using game code
* @param gameCode 6-character game code
* @param playerId ID of the player joining (optional for public games)
* @param playerName Display name for the player
* @param orgId Organization ID (for organization games)
* @param loginType Type of join being attempted
* @returns Promise<GameAggregate> The updated game with new player
*/
async joinGame(
gameCode: string,
playerId?: string,
playerName?: string,
orgId?: string | null,
loginType?: LoginType
): Promise<GameAggregate> {
const startTime = performance.now();
try {
logOther('GameService.joinGame called', {
gameCode,
playerId: playerId || 'anonymous',
playerName,
orgId,
loginType
});
// Validate input parameters
this.validateJoinGameInput(gameCode, playerId, loginType);
// Create and execute the command
const command: JoinGameCommand = {
gameCode,
playerId,
playerName,
orgId,
loginType: loginType || LoginType.PUBLIC
};
const game = await this.joinGameHandler.handle(command);
const endTime = performance.now();
logOther('Player joined game successfully', {
gameId: game.id,
gameCode: game.gamecode,
playerId,
playerCount: game.players.length,
maxPlayers: game.maxplayers,
executionTime: Math.round(endTime - startTime)
});
return game;
} catch (error) {
const endTime = performance.now();
logError('GameService.joinGame failed', error instanceof Error ? error : new Error(String(error)));
logOther('Game join failed', {
gameCode,
playerId,
executionTime: Math.round(endTime - startTime),
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Start an existing game (move from WAITING to ACTIVE)
* Initializes all player positions to 0 and assigns random turn order
* @param gameId Game ID to start
* @param userId User ID of the game master (optional for public games)
* @returns Promise<GameAggregate> The updated game
*/
async startGamePlay(
gameId: string,
userId?: string
): Promise<GameStartResult> {
const startTime = performance.now();
try {
logOther('GameService.startGamePlay called', {
gameId,
userId: userId || 'system'
});
// Validate input parameters
this.validateStartGamePlayInput(gameId);
// Create and execute the command
const command: StartGamePlayCommand = {
gameId,
userId
};
const result = await this.startGamePlayHandler.handle(command);
const endTime = performance.now();
logOther('Game play started successfully', {
gameId: result.game.id,
gameCode: result.game.gamecode,
playerCount: result.game.players.length,
gameState: result.game.state,
executionTime: Math.round(endTime - startTime)
});
return result;
} catch (error) {
const endTime = performance.now();
logError('GameService.startGamePlay failed', error instanceof Error ? error : new Error(String(error)));
logOther('Game play start failed', {
gameId,
userId,
executionTime: Math.round(endTime - startTime),
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
private validateStartGamePlayInput(gameId: string): void {
// Validate game ID
if (!gameId || typeof gameId !== 'string') {
throw new Error('Game ID is required and must be a string');
}
logOther('Start game play input validation passed', {
gameId
});
}
private validateJoinGameInput(gameCode: string, playerId?: string, loginType?: LoginType): void {
// Validate game code
if (!gameCode || typeof gameCode !== 'string') {
throw new Error('Game code is required and must be a string');
}
if (gameCode.length !== 6) {
throw new Error('Game code must be exactly 6 characters long');
}
// Validate login type specific requirements
if (loginType === LoginType.PRIVATE || loginType === LoginType.ORGANIZATION) {
if (!playerId || typeof playerId !== 'string') {
throw new Error(`Player ID is required for ${LoginType[loginType]} games`);
}
}
logOther('Join game input validation passed', {
gameCode,
playerId: playerId || 'anonymous',
loginType
});
}
private validateStartGameInput(deckids: string[], maxplayers: number, logintype: LoginType): void {
// Validate deck IDs
if (!deckids || deckids.length === 0) {
throw new Error('At least one deck ID must be provided');
}
if (deckids.length < 3) {
throw new Error('At least 3 decks are required to start a game (one for each type: LUCK, JOKER, QUESTION)');
}
// Validate max players
if (!maxplayers || maxplayers < 2) {
throw new Error('Maximum players must be at least 2');
}
if (maxplayers > 8) {
throw new Error('Maximum players cannot exceed 8');
}
// Validate login type
if (logintype < 0 || logintype > 2) {
throw new Error('Invalid login type. Must be PUBLIC (0), PRIVATE (1), or ORGANIZATION (2)');
}
// Check for duplicate deck IDs
const uniqueIds = new Set(deckids);
if (uniqueIds.size !== deckids.length) {
throw new Error('Duplicate deck IDs are not allowed');
}
logOther('Start game input validation passed', {
deckCount: deckids.length,
maxplayers,
logintype
});
}
/**
* Game flow explanation (to be implemented later):
*
* 1. START GAME (implemented above):
* - Input: deckids, maxplayers, logintype, gamecode
* - Process: Fetch decks, validate types, shuffle cards, create game
* - Output: Game with shuffled deck objects
*
* 2. JOIN GAME (to be implemented):
* - Input: gamecode, playerid
* - Process: Find game, validate capacity, add player
* - Output: Updated game with new player
*
* 3. GAME ROUNDS (to be implemented):
* - Input: gameid, current player
* - Process: Manage turn order, track game state
* - Output: Current player information
*
* 4. PICK CARD (to be implemented):
* - Input: gameid, playerid, deck type
* - Process: Draw card from specific deck, apply consequence
* - Output: Card details and consequence effects
*
* 5. END GAME (to be implemented):
* - Input: gameid, winner
* - Process: Set game as finished, record winner
* - Output: Final game state
*/
}
@@ -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
}
}
}