430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
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'].includes(lowerValue);
|
|
}
|
|
|
|
/**
|
|
* Scramble array elements randomly
|
|
*/
|
|
private scrambleArray<T>(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;
|
|
}
|
|
} |