lila/apps/api/src/services/gameService.ts
lila 0118798e36 feat: guest play — allow singleplayer quiz without auth
- 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
2026-05-31 21:28:08 +02:00

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,
};
};