import { GameCard } from '../../Domain/Game/GameAggregate'; import { CardType } from '../../Domain/Deck/DeckAggregate'; // Type-specific answer structures export interface QuizOption { answer: string; // A, B, C, D text: string; correct: boolean; } export interface CloserAnswer { correct: number; percent: number; } /** * Sentence pair for matching left to right */ export interface SentencePair { id: string; // Unique identifier for this pair left: string; // Left part to match right: string; // Right part (scrambled position) } /** * Player's answer for sentence pairing (array of matches) */ export interface SentencePairingAnswer { pairId: string; // ID of the pair leftText: string; // Left part rightText: string; // Player's chosen right part } export interface CardClientData { cardid: string; question: string; type: CardType; timeLimit: number; // Type-specific client data answerOptions?: QuizOption[]; // For QUIZ words?: string[]; // For SENTENCE_PAIRING (legacy scrambled words) sentencePairs?: SentencePair[]; // For SENTENCE_PAIRING (left-right matching) acceptableAnswers?: string[]; // For OWN_ANSWER (not sent to client) // CLOSER and TRUE_FALSE send only question } export interface CardValidationResult { isCorrect: boolean; submittedAnswer: any; correctAnswer?: any; explanation?: string; } /** * Service responsible for handling type-specific card processing * Prepares cards for clients and validates answers based on CardType */ export class CardProcessingService { /** * Prepare card data for client based on card type * @param card The game card to prepare * @returns Client-safe card data with type-specific information */ prepareCardForClient(card: GameCard): CardClientData { if (!card.question || card.type === undefined) { throw new Error('Card must have question and type defined'); } const baseData: CardClientData = { cardid: card.cardid, question: card.question, type: card.type, timeLimit: 60 // Default 60 seconds for question cards }; switch (card.type) { case CardType.QUIZ: return this.prepareQuizCard(card, baseData); case CardType.SENTENCE_PAIRING: return this.prepareSentencePairingCard(card, baseData); case CardType.OWN_ANSWER: return this.prepareOwnAnswerCard(card, baseData); case CardType.TRUE_FALSE: return this.prepareTrueFalseCard(card, baseData); case CardType.CLOSER: return this.prepareCloserCard(card, baseData); default: throw new Error(`Unsupported card type: ${card.type}`); } } /** * Validate player's answer based on card type * @param card The game card * @param playerAnswer Player's submitted answer * @returns Validation result with correctness and explanation */ validateAnswer(card: GameCard, playerAnswer: any): CardValidationResult { if (card.type === undefined) { throw new Error('Card type is required for validation'); } switch (card.type) { case CardType.QUIZ: return this.validateQuizAnswer(card, playerAnswer); case CardType.SENTENCE_PAIRING: return this.validateSentencePairingAnswer(card, playerAnswer); case CardType.OWN_ANSWER: return this.validateOwnAnswerAnswer(card, playerAnswer); case CardType.TRUE_FALSE: return this.validateTrueFalseAnswer(card, playerAnswer); case CardType.CLOSER: return this.validateCloserAnswer(card, playerAnswer); default: throw new Error(`Unsupported card type for validation: ${card.type}`); } } /** * Prepare QUIZ card with multiple choice options */ private prepareQuizCard(card: GameCard, baseData: CardClientData): CardClientData { if (!Array.isArray(card.answer)) { throw new Error('Quiz card answer must be an array of options'); } return { ...baseData, answerOptions: card.answer as QuizOption[] }; } /** * Prepare SENTENCE_PAIRING card with scrambled left/right pairs * * Expected card.answer format: * [ * { left: "Apple", right: "Red" }, * { left: "Banana", right: "Yellow" }, * { left: "Orange", right: "Orange color" } * ] * * OR legacy string format: "word1 word2 word3" (will be split and scrambled) */ private prepareSentencePairingCard(card: GameCard, baseData: CardClientData): CardClientData { // NEW FORMAT: Array of pairs (left-right matching) if (Array.isArray(card.answer)) { // Validate structure const pairs = card.answer as Array<{ left: string; right: string }>; if (!pairs.every(p => p.left && p.right)) { throw new Error('Sentence pairing card answer must be array of {left, right} objects'); } // Create pairs with IDs and scramble the right parts const leftParts = pairs.map((p, idx) => ({ id: `pair_${idx}`, left: p.left, right: p.right })); const rightParts = this.scrambleArray([...pairs.map(p => p.right)]); // Send left parts in order, right parts scrambled const sentencePairs: SentencePair[] = leftParts.map((lp, idx) => ({ id: lp.id, left: lp.left, right: rightParts[idx] // Scrambled position })); return { ...baseData, sentencePairs }; } // LEGACY FORMAT: Single sentence to reconstruct (backward compatibility) if (typeof card.answer === 'string') { const words = card.answer.split(' ').filter(word => word.trim() !== ''); const scrambledWords = this.scrambleArray([...words]); return { ...baseData, words: scrambledWords }; } throw new Error('Sentence pairing card answer must be array of pairs or string'); } /** * Prepare OWN_ANSWER card (only question, acceptable answers hidden) */ private prepareOwnAnswerCard(card: GameCard, baseData: CardClientData): CardClientData { // Don't send acceptable answers to client return baseData; } /** * Prepare TRUE_FALSE card (only question) */ private prepareTrueFalseCard(card: GameCard, baseData: CardClientData): CardClientData { return baseData; } /** * Prepare CLOSER card (only question) */ private prepareCloserCard(card: GameCard, baseData: CardClientData): CardClientData { return baseData; } /** * Validate QUIZ answer (A, B, C, D) */ private validateQuizAnswer(card: GameCard, playerAnswer: string): CardValidationResult { if (!Array.isArray(card.answer)) { throw new Error('Quiz card answer must be an array'); } const options = card.answer as QuizOption[]; const correctOption = options.find(opt => opt.correct); if (!correctOption) { throw new Error('Quiz card must have one correct answer'); } const isCorrect = playerAnswer.toUpperCase() === correctOption.answer.toUpperCase(); return { isCorrect, submittedAnswer: playerAnswer, correctAnswer: correctOption.answer, explanation: isCorrect ? `✅ Correct! ${correctOption.text}` : `❌ Wrong! Correct answer was ${correctOption.answer}: ${correctOption.text}` }; } /** * Validate SENTENCE_PAIRING answer * * Supports two formats: * 1. NEW: Array of { pairId, leftText, rightText } matches * 2. LEGACY: Reconstructed sentence string or array of words */ private validateSentencePairingAnswer(card: GameCard, playerAnswer: any): CardValidationResult { // NEW FORMAT: Array of pairs (left-right matching) if (Array.isArray(card.answer) && card.answer.every((p: any) => p.left && p.right)) { const correctPairs = card.answer as Array<{ left: string; right: string }>; // Player answer should be array of SentencePairingAnswer objects if (!Array.isArray(playerAnswer)) { throw new Error('Player answer must be array of pair matches'); } const playerMatches = playerAnswer as SentencePairingAnswer[]; // Check if all pairs match correctly let correctCount = 0; const results: string[] = []; for (const correctPair of correctPairs) { const playerMatch = playerMatches.find(pm => pm.leftText.toLowerCase().trim() === correctPair.left.toLowerCase().trim() ); if (playerMatch) { const isMatch = playerMatch.rightText.toLowerCase().trim() === correctPair.right.toLowerCase().trim(); if (isMatch) { correctCount++; results.push(`✓ "${correctPair.left}" → "${correctPair.right}"`); } else { results.push(`✗ "${correctPair.left}" → "${playerMatch.rightText}" (should be "${correctPair.right}")`); } } else { results.push(`✗ "${correctPair.left}" → (not matched)`); } } const isCorrect = correctCount === correctPairs.length; return { isCorrect, submittedAnswer: playerMatches, correctAnswer: correctPairs, explanation: isCorrect ? `✅ Perfect! All ${correctCount} pairs matched correctly!\n${results.join('\n')}` : `❌ Only ${correctCount}/${correctPairs.length} pairs correct:\n${results.join('\n')}` }; } // LEGACY FORMAT: Single sentence to reconstruct (backward compatibility) if (typeof card.answer === 'string') { // Handle both array of words and joined string const reconstructed = Array.isArray(playerAnswer) ? playerAnswer.join(' ').toLowerCase().trim() : (typeof playerAnswer === 'string' ? playerAnswer.toLowerCase().trim() : ''); const correctSentence = card.answer.toLowerCase().trim(); const isCorrect = reconstructed === correctSentence; return { isCorrect, submittedAnswer: reconstructed, correctAnswer: card.answer, explanation: isCorrect ? '✅ Perfect! You arranged the sentence correctly!' : `❌ Wrong order! Correct sentence: "${card.answer}"` }; } throw new Error('Sentence pairing card answer must be array of pairs or string'); } /** * Validate OWN_ANSWER (check against acceptable answers array) */ private validateOwnAnswerAnswer(card: GameCard, playerAnswer: string): CardValidationResult { if (!Array.isArray(card.answer)) { throw new Error('Own answer card must have array of acceptable answers'); } const acceptableAnswers = card.answer as string[]; const cleanPlayerAnswer = playerAnswer.toLowerCase().trim(); const isCorrect = acceptableAnswers.some(acceptable => acceptable.toLowerCase().trim() === cleanPlayerAnswer ); return { isCorrect, submittedAnswer: playerAnswer, correctAnswer: acceptableAnswers, explanation: isCorrect ? '✅ Correct! Your answer is acceptable.' : `❌ Your answer doesn't match any acceptable responses.` }; } /** * Validate TRUE_FALSE answer */ private validateTrueFalseAnswer(card: GameCard, playerAnswer: string): CardValidationResult { if (typeof card.answer !== 'boolean' && typeof card.answer !== 'string') { throw new Error('True/false card answer must be boolean or string'); } // Convert player answer to boolean const playerBool = this.convertToBoolean(playerAnswer); const correctBool = typeof card.answer === 'boolean' ? card.answer : this.convertToBoolean(card.answer); const isCorrect = playerBool === correctBool; return { isCorrect, submittedAnswer: playerAnswer, correctAnswer: correctBool ? 'True' : 'False', explanation: isCorrect ? '✅ Correct!' : `❌ Wrong! The correct answer is ${correctBool ? 'True' : 'False'}.` }; } /** * Validate CLOSER answer (numerical proximity) */ private validateCloserAnswer(card: GameCard, playerAnswer: string | number): CardValidationResult { if (typeof card.answer !== 'object' || !card.answer.correct || !card.answer.percent) { throw new Error('Closer card answer must have correct and percent fields'); } const closerAnswer = card.answer as CloserAnswer; const playerNumber = typeof playerAnswer === 'number' ? playerAnswer : parseFloat(playerAnswer.toString()); if (isNaN(playerNumber)) { return { isCorrect: false, submittedAnswer: playerAnswer, correctAnswer: closerAnswer.correct, explanation: '❌ Invalid number! Please enter a valid numeric answer.' }; } const tolerance = closerAnswer.correct * (closerAnswer.percent / 100); const minValue = closerAnswer.correct - tolerance; const maxValue = closerAnswer.correct + tolerance; const isCorrect = playerNumber >= minValue && playerNumber <= maxValue; return { isCorrect, submittedAnswer: playerNumber, correctAnswer: closerAnswer.correct, explanation: isCorrect ? `✅ Close enough! Correct answer: ${closerAnswer.correct}` : `❌ Not close enough! Correct answer: ${closerAnswer.correct} (±${closerAnswer.percent}%)` }; } /** * Convert string to boolean for TRUE_FALSE validation */ private convertToBoolean(value: string): boolean { const lowerValue = value.toLowerCase().trim(); return ['true', 'yes', '1', 'correct', 'right', 'igaz'].includes(lowerValue); } /** * Scramble array elements randomly */ private scrambleArray(array: T[]): T[] { const scrambled = [...array]; for (let i = scrambled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [scrambled[i], scrambled[j]] = [scrambled[j], scrambled[i]]; } return scrambled; } }