- Add optionalAuth middleware: attaches session when present, never blocks (guests pass through) - Make game endpoints (start/answer) accept optional auth - GameSessionStore.userId: string → string | null - Rate limiter falls back to IP for unauthenticated users - Frontend: remove /play route guard, show 'Create account' CTA on score screen for guests - Add tests for guest session creation, answer submission, and cross-user session isolation
128 lines
3.3 KiB
TypeScript
128 lines
3.3 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import { getGameTerms, getDistractors } from "@lila/db";
|
|
import type {
|
|
GameRequest,
|
|
GameSession,
|
|
GameQuestion,
|
|
AnswerOption,
|
|
AnswerSubmission,
|
|
AnswerResult,
|
|
} from "@lila/shared";
|
|
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
|
import {
|
|
NotFoundError,
|
|
ConflictError,
|
|
UnprocessableEntityError,
|
|
} from "../errors/AppError.js";
|
|
import { shuffleArray } from "../lib/utils.js";
|
|
|
|
export const createGameSession = async (
|
|
request: GameRequest,
|
|
store: GameSessionStore,
|
|
userId: string | null,
|
|
): Promise<GameSession> => {
|
|
const terms = await getGameTerms(
|
|
request.source_language,
|
|
request.target_language,
|
|
request.pos,
|
|
request.difficulty,
|
|
request.rounds,
|
|
);
|
|
|
|
if (terms.length === 0) {
|
|
throw new UnprocessableEntityError("No terms found for the given filters");
|
|
}
|
|
|
|
const answerKey = new Map<string, { correctOptionId: number }>();
|
|
|
|
const questions: GameQuestion[] = await Promise.all(
|
|
terms.map(async (term) => {
|
|
const distractorTexts = await getDistractors(
|
|
term.entryId,
|
|
term.targetText,
|
|
request.source_language,
|
|
request.target_language,
|
|
request.pos,
|
|
request.difficulty,
|
|
6,
|
|
);
|
|
|
|
const uniqueDistractors = [
|
|
...new Set(distractorTexts.filter((t) => t !== term.targetText)),
|
|
];
|
|
|
|
if (uniqueDistractors.length < 3) {
|
|
throw new Error(
|
|
`Not enough unique distractors for term: ${term.targetText}`,
|
|
);
|
|
}
|
|
|
|
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
|
const shuffledTexts = shuffleArray(optionTexts);
|
|
const correctOptionId = shuffledTexts.indexOf(term.targetText);
|
|
|
|
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
|
|
optionId: index,
|
|
text,
|
|
}));
|
|
|
|
const questionId = randomUUID();
|
|
answerKey.set(questionId, { correctOptionId });
|
|
|
|
return {
|
|
questionId,
|
|
prompt: term.sourceText,
|
|
gloss: term.sourceGloss,
|
|
options,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const sessionId = randomUUID();
|
|
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
|
|
|
|
return { sessionId, questions };
|
|
};
|
|
|
|
export const evaluateAnswer = async (
|
|
submission: AnswerSubmission,
|
|
store: GameSessionStore,
|
|
userId: string | null,
|
|
): Promise<AnswerResult> => {
|
|
const session = await store.get(submission.sessionId);
|
|
|
|
if (!session) {
|
|
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
|
}
|
|
|
|
if (session.userId !== userId) {
|
|
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
|
}
|
|
|
|
const answer = session.answers.get(submission.questionId);
|
|
|
|
if (answer === undefined) {
|
|
throw new ConflictError(
|
|
`Question already answered: ${submission.questionId}`,
|
|
);
|
|
}
|
|
|
|
const updatedAnswers = new Map(session.answers);
|
|
updatedAnswers.delete(submission.questionId);
|
|
|
|
if (updatedAnswers.size === 0) {
|
|
await store.delete(submission.sessionId);
|
|
} else {
|
|
await store.update(submission.sessionId, {
|
|
answers: updatedAnswers,
|
|
userId: session.userId,
|
|
});
|
|
}
|
|
|
|
return {
|
|
questionId: submission.questionId,
|
|
isCorrect: submission.selectedOptionId === answer.correctOptionId,
|
|
correctOptionId: answer.correctOptionId,
|
|
selectedOptionId: submission.selectedOptionId,
|
|
};
|
|
};
|