399 lines
14 KiB
TypeScript
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}".`
|
|
};
|
|
}
|
|
} |