Files
SerpentRace/SerpentRace_Backend/src/Application/Services/CardDrawingService.ts
T
2025-11-24 23:28:57 +01:00

399 lines
14 KiB
TypeScript

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<string, PendingCardAnswer> = 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}".`
};
}
}