import { GameAggregate, GameCard, DeckType, GameDeck } from '../../Domain/Game/GameAggregate'; import { ConsequenceType } from '../../Domain/Deck/DeckAggregate'; import { CardProcessingService, CardClientData, CardValidationResult } from './CardProcessingService'; export interface CardDrawResult { success: boolean; card?: GameCard; clientData?: CardClientData; // Prepared data for client error?: string; } export interface CardAnswerResult { correct: boolean; consequence: ConsequenceType; description: string; validationDetails?: CardValidationResult; // Detailed validation info } export interface PendingCardAnswer { gameId: string; playerId: string; card: GameCard; timeoutId: NodeJS.Timeout; startTime: Date; } /** * Service responsible for handling card drawing mechanics during special field landings * Integrates with existing GameCard interface and DeckType enum */ export class CardDrawingService { private pendingAnswers: Map = new Map(); private readonly ANSWER_TIMEOUT_MS = 60000; // 1 minute private cardProcessingService: CardProcessingService; constructor() { this.cardProcessingService = new CardProcessingService(); } /** * Draw a card from the appropriate deck based on field type * @param game Game aggregate containing the deck information * @param fieldType Type of field the player landed on * @param playerId ID of the player who needs to draw the card * @returns Card draw result with the drawn card or error */ drawCard(game: GameAggregate, fieldType: 'positive' | 'negative' | 'luck', playerId: string): CardDrawResult { try { // Determine which deck type to use based on field type const deckType = this.getRequiredDeckType(fieldType); // Find the appropriate deck in the game const gameDecks: GameDeck[] = typeof game.gamedecks === 'string' ? JSON.parse(game.gamedecks) : game.gamedecks; const targetDeck = gameDecks.find((deck: GameDeck) => deck.decktype === deckType); if (!targetDeck) { return { success: false, error: `No ${this.getDeckTypeName(deckType)} deck found in game` }; } // Filter available cards (not played by this player yet) const availableCards = targetDeck.cards.filter((card: GameCard) => !card.played || card.playerid !== playerId); if (availableCards.length === 0) { return { success: false, error: `No more cards available in ${this.getDeckTypeName(deckType)} deck` }; } // Randomly select a card const randomIndex = Math.floor(Math.random() * availableCards.length); const drawnCard = availableCards[randomIndex]; // Mark card as drawn by this player drawnCard.played = true; drawnCard.playerid = playerId; // Check if card has consequence field (joker/luck card) even without type const hasConsequence = drawnCard.consequence !== undefined && drawnCard.consequence !== null; // Prepare client data based on card type // Only prepare for question cards (cards without consequence and with defined type) let clientData: CardClientData | undefined; if (!hasConsequence && drawnCard.type !== undefined) { try { clientData = this.cardProcessingService.prepareCardForClient(drawnCard); } catch (error) { // If client data preparation fails, still return the card but log the error console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error); } } else if (!hasConsequence && drawnCard.type === undefined) { // Card is missing type field - this shouldn't happen, log error console.error(`Card ${drawnCard.cardid} is missing type field. Card data:`, { cardId: drawnCard.cardid, hasQuestion: !!drawnCard.question, hasAnswer: !!drawnCard.answer, hasConsequence, cardKeys: Object.keys(drawnCard) }); } return { success: true, card: drawnCard, clientData: clientData }; } catch (error) { return { success: false, error: `Failed to draw card: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Draw a joker card for secondary landings on special fields * @param game Game aggregate containing the deck information * @param playerId ID of the player who needs to draw the joker card * @returns Card draw result with the joker card or error */ drawJokerCard(game: GameAggregate, playerId: string): CardDrawResult { try { const gameDecks: GameDeck[] = typeof game.gamedecks === 'string' ? JSON.parse(game.gamedecks) : game.gamedecks; const jokerDeck = gameDecks.find((deck: GameDeck) => deck.decktype === DeckType.JOCKER); if (!jokerDeck) { return { success: false, error: 'No joker deck found in game' }; } // Filter available joker cards const availableCards = jokerDeck.cards.filter((card: GameCard) => !card.played || card.playerid !== playerId); if (availableCards.length === 0) { return { success: false, error: 'No more joker cards available' }; } // Randomly select a joker card const randomIndex = Math.floor(Math.random() * availableCards.length); const drawnCard = availableCards[randomIndex]; // Mark card as drawn by this player drawnCard.played = true; drawnCard.playerid = playerId; return { success: true, card: drawnCard }; } catch (error) { return { success: false, error: `Failed to draw joker card: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Start the answer timeout for a question card * @param gameId Game ID * @param playerId Player ID who needs to answer * @param card The card with the question * @param onTimeout Callback function when timeout occurs * @returns Unique key for tracking this pending answer */ startAnswerTimeout( gameId: string, playerId: string, card: GameCard, onTimeout: (gameId: string, playerId: string, card: GameCard) => void ): string { const key = `${gameId}:${playerId}`; // Clear any existing timeout for this player this.clearAnswerTimeout(key); // Set new timeout const timeoutId = setTimeout(() => { onTimeout(gameId, playerId, card); this.pendingAnswers.delete(key); }, this.ANSWER_TIMEOUT_MS); // Store pending answer this.pendingAnswers.set(key, { gameId, playerId, card, timeoutId, startTime: new Date() }); return key; } /** * Clear an answer timeout * @param key The key returned from startAnswerTimeout */ clearAnswerTimeout(key: string): void { const pending = this.pendingAnswers.get(key); if (pending) { clearTimeout(pending.timeoutId); this.pendingAnswers.delete(key); } } /** * Process player's answer to a question card * @param card The question card * @param playerAnswer Player's submitted answer * @returns Result indicating if answer was correct and consequence to apply */ processAnswer(card: GameCard, playerAnswer: any): CardAnswerResult { if (!card.answer) { throw new Error('Card has no answer to compare against'); } let validationResult: CardValidationResult; try { // Use CardProcessingService for type-specific validation validationResult = this.cardProcessingService.validateAnswer(card, playerAnswer); } catch (error) { // Fallback to simple string comparison if type-specific validation fails console.warn(`Card validation failed, using fallback: ${error}`); validationResult = this.fallbackValidation(card, playerAnswer); } // For question cards, the consequence is applied only if the answer is correct // If wrong, we apply a default negative consequence const consequence = validationResult.isCorrect ? (card.consequence?.type || ConsequenceType.EXTRA_TURN) : ConsequenceType.LOSE_TURN; // Default penalty for wrong answer return { correct: validationResult.isCorrect, consequence: consequence, description: validationResult.explanation || (validationResult.isCorrect ? '✅ Correct!' : '❌ Wrong answer!'), validationDetails: validationResult }; } /** * Process automatic wrong answer (timeout occurred) * @param card The question card that timed out * @returns Result with wrong consequence applied */ processTimeoutAnswer(card: GameCard): CardAnswerResult { if (!card.answer) { throw new Error('Card has no answer to compare against'); } const consequence = ConsequenceType.LOSE_TURN; // Default penalty for timeout return { correct: false, consequence: consequence, description: `⏰ Time's up! The correct answer was "${card.answer}". ${this.getConsequenceDescription(consequence, false)}` }; } /** * Process luck card effect (no answer required) * @param card The luck card * @returns Result with the luck consequence to apply */ processLuckCard(card: GameCard): CardAnswerResult { const consequence = card.consequence?.type || ConsequenceType.EXTRA_TURN; return { correct: true, // Luck cards are always "correct" since no answer is needed consequence: consequence, description: `🍀 ${this.getConsequenceDescription(consequence, true)}` }; } /** * Get the required deck type based on field type */ private getRequiredDeckType(fieldType: 'positive' | 'negative' | 'luck'): DeckType { switch (fieldType) { case 'positive': case 'negative': return DeckType.QUEST; // Question cards for positive/negative fields case 'luck': return DeckType.LUCK; // Luck cards for luck fields default: throw new Error(`Unsupported field type: ${fieldType}`); } } /** * Get human-readable deck type name */ private getDeckTypeName(deckType: DeckType): string { switch (deckType) { case DeckType.QUEST: return 'question'; case DeckType.LUCK: return 'luck'; case DeckType.JOCKER: return 'joker'; default: return 'unknown'; } } /** * Get human-readable consequence description */ private getConsequenceDescription(consequence: ConsequenceType, isPositive: boolean): string { switch (consequence) { case ConsequenceType.MOVE_FORWARD: return isPositive ? 'Move forward!' : 'Move forward anyway!'; case ConsequenceType.MOVE_BACKWARD: return 'Move backward!'; case ConsequenceType.LOSE_TURN: return 'Lose your next turn!'; case ConsequenceType.EXTRA_TURN: return 'Get an extra turn!'; case ConsequenceType.GO_TO_START: return 'Go back to start!'; default: return 'Unknown effect!'; } } /** * Get remaining time for a pending answer * @param key The key for the pending answer * @returns Remaining time in seconds, or -1 if not found */ getRemainingTime(key: string): number { const pending = this.pendingAnswers.get(key); if (!pending) { return -1; } const elapsed = Date.now() - pending.startTime.getTime(); const remaining = Math.max(0, this.ANSWER_TIMEOUT_MS - elapsed); return Math.ceil(remaining / 1000); // Return in seconds } /** * Check if a player has a pending answer * @param gameId Game ID * @param playerId Player ID * @returns True if player has a pending answer */ hasPendingAnswer(gameId: string, playerId: string): boolean { const key = `${gameId}:${playerId}`; return this.pendingAnswers.has(key); } /** * Fallback validation for cards without proper type information * @param card The card to validate * @param playerAnswer Player's answer * @returns Basic validation result */ private fallbackValidation(card: GameCard, playerAnswer: any): CardValidationResult { if (typeof card.answer !== 'string' || typeof playerAnswer !== 'string') { return { isCorrect: false, submittedAnswer: playerAnswer, explanation: 'Cannot validate non-string answers without card type information' }; } const cleanPlayerAnswer = playerAnswer.toLowerCase().trim(); const cleanCorrectAnswer = card.answer.toLowerCase().trim(); const isCorrect = cleanPlayerAnswer === cleanCorrectAnswer; return { isCorrect, submittedAnswer: playerAnswer, correctAnswer: card.answer, explanation: isCorrect ? '✅ Correct!' : `❌ Wrong! The correct answer was "${card.answer}".` }; } }