feat(api): assemble full GameSession with shuffled answer options

Extend the game flow from raw term rows to a complete GameSession
matching the shared schema:

- Add getDistractors model query: fetches N same-POS, same-difficulty,
  same-target-language terms excluding a given termId. Returns bare
  strings since distractors only need their display text.
- Fix getGameTerms select clause to use the neutral field names
  (sourceText, targetText, sourceGloss) that the type already declared.
- Rename gameService entry point to createGameSession; signature now
  takes a GameRequest and returns a GameSession.
- Per question: fetch 3 distractors, combine with the correct answer,
  shuffle (Fisher-Yates), assign optionIds 0-3 by post-shuffle index,
  and assemble into a GameQuestion with a fresh UUID.
- Wrap the questions in a GameSession with its own UUID.
- Run per-question distractor fetches in parallel via Promise.all.

Known gap: the correct option is not yet remembered server-side, so
/game/answer cannot evaluate submissions. SessionStore lands next.
This commit is contained in:
lila 2026-04-10 21:44:36 +02:00
parent 0cf6a852b2
commit f53ac618bb
4 changed files with 95 additions and 20 deletions

View file

@ -1,6 +1,6 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { GameRequestSchema } from "@glossa/shared"; import { GameRequestSchema } from "@glossa/shared";
import { prepareGameQuestions } from "../services/gameService.js"; import { createGameSession } from "../services/gameService.js";
export const createGame = async (req: Request, res: Response) => { export const createGame = async (req: Request, res: Response) => {
const gameSettings = GameRequestSchema.safeParse(req.body); const gameSettings = GameRequestSchema.safeParse(req.body);
@ -11,7 +11,7 @@ export const createGame = async (req: Request, res: Response) => {
return; return;
} }
const gameQuestions = await prepareGameQuestions(gameSettings.data); const gameQuestions = await createGameSession(gameSettings.data);
res.json({ success: true, data: gameQuestions }); res.json({ success: true, data: gameQuestions });
}; };

View file

@ -1,16 +1,60 @@
import type { GameRequestType } from "@glossa/shared"; import { randomUUID } from "crypto";
import { getGameTerms } from "@glossa/db"; import { getGameTerms, getDistractors } from "@glossa/db";
import type {
GameRequest,
GameSession,
GameQuestion,
AnswerOption,
} from "@glossa/shared";
export const prepareGameQuestions = async (gameSettings: GameRequestType) => { export const createGameSession = async (
const { source_language, target_language, pos, difficulty, rounds } = request: GameRequest,
gameSettings; ): Promise<GameSession> => {
const correctAnswers = await getGameTerms(
const terms = await getGameTerms( request.source_language,
source_language, request.target_language,
target_language, request.pos,
pos, request.difficulty,
difficulty, Number(request.rounds),
Number(rounds),
); );
return terms;
const questions: GameQuestion[] = await Promise.all(
correctAnswers.map(async (correctAnswer) => {
const distractorTexts = await getDistractors(
correctAnswer.termId,
request.target_language,
request.pos,
request.difficulty,
3,
);
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
optionId: index,
text,
}));
return {
questionId: randomUUID(),
prompt: correctAnswer.sourceText,
gloss: correctAnswer.sourceGloss,
options,
};
}),
);
return { sessionId: randomUUID(), questions };
};
const shuffle = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = result[i]!;
result[i] = result[j]!;
result[j] = temp;
}
return result;
}; };

View file

@ -10,4 +10,4 @@ config({
export const db = drizzle(process.env["DATABASE_URL"]!); export const db = drizzle(process.env["DATABASE_URL"]!);
export { getGameTerms } from "./models/termModel.js"; export * from "./models/termModel.js";

View file

@ -1,5 +1,5 @@
import { db } from "@glossa/db"; import { db } from "@glossa/db";
import { eq, and, isNotNull, sql } from "drizzle-orm"; import { eq, and, isNotNull, sql, ne } from "drizzle-orm";
import { terms, translations, term_glosses } from "@glossa/db/schema"; import { terms, translations, term_glosses } from "@glossa/db/schema";
import { alias } from "drizzle-orm/pg-core"; import { alias } from "drizzle-orm/pg-core";
@ -33,9 +33,9 @@ export const getGameTerms = async (
const rows = await db const rows = await db
.select({ .select({
termId: terms.id, termId: terms.id,
prompt: sourceTranslations.text, sourceText: sourceTranslations.text,
answer: targetTranslations.text, targetText: targetTranslations.text,
gloss: term_glosses.text, sourceGloss: term_glosses.text,
}) })
.from(terms) .from(terms)
.innerJoin( .innerJoin(
@ -79,3 +79,34 @@ export const getGameTerms = async (
return rows; return rows;
}; };
export const getDistractors = async (
excludeTermId: string,
targetLanguage: SupportedLanguageCode,
pos: SupportedPos,
difficulty: DifficultyLevel,
count: number,
): Promise<string[]> => {
const rows = await db
.select({ text: translations.text })
.from(terms)
.innerJoin(
translations,
and(
eq(translations.term_id, terms.id),
eq(translations.language_code, targetLanguage),
),
)
.where(
and(
eq(terms.pos, pos),
eq(translations.difficulty, difficulty),
ne(terms.id, excludeTermId),
),
)
// TODO(post-mvp): same ORDER BY RANDOM() concern as getGameTerms — see comment there.
.orderBy(sql`RANDOM()`)
.limit(count);
return rows.map((row) => row.text);
};