From f53ac618bbc3a3c3e49e4efd42c23fe7af4514d5 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 10 Apr 2026 21:44:36 +0200 Subject: [PATCH] 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. --- apps/api/src/controllers/gameController.ts | 4 +- apps/api/src/services/gameService.ts | 70 ++++++++++++++++++---- packages/db/src/index.ts | 2 +- packages/db/src/models/termModel.ts | 39 ++++++++++-- 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/apps/api/src/controllers/gameController.ts b/apps/api/src/controllers/gameController.ts index eec4558..0266067 100644 --- a/apps/api/src/controllers/gameController.ts +++ b/apps/api/src/controllers/gameController.ts @@ -1,6 +1,6 @@ import type { Request, Response } from "express"; 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) => { const gameSettings = GameRequestSchema.safeParse(req.body); @@ -11,7 +11,7 @@ export const createGame = async (req: Request, res: Response) => { return; } - const gameQuestions = await prepareGameQuestions(gameSettings.data); + const gameQuestions = await createGameSession(gameSettings.data); res.json({ success: true, data: gameQuestions }); }; diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index 9478d3f..7ecbdbe 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -1,16 +1,60 @@ -import type { GameRequestType } from "@glossa/shared"; -import { getGameTerms } from "@glossa/db"; +import { randomUUID } from "crypto"; +import { getGameTerms, getDistractors } from "@glossa/db"; +import type { + GameRequest, + GameSession, + GameQuestion, + AnswerOption, +} from "@glossa/shared"; -export const prepareGameQuestions = async (gameSettings: GameRequestType) => { - const { source_language, target_language, pos, difficulty, rounds } = - gameSettings; - - const terms = await getGameTerms( - source_language, - target_language, - pos, - difficulty, - Number(rounds), +export const createGameSession = async ( + request: GameRequest, +): Promise => { + const correctAnswers = await getGameTerms( + request.source_language, + request.target_language, + request.pos, + request.difficulty, + Number(request.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 = (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; }; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 06232cb..cd261de 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -10,4 +10,4 @@ config({ export const db = drizzle(process.env["DATABASE_URL"]!); -export { getGameTerms } from "./models/termModel.js"; +export * from "./models/termModel.js"; diff --git a/packages/db/src/models/termModel.ts b/packages/db/src/models/termModel.ts index 1c01b53..40fe706 100644 --- a/packages/db/src/models/termModel.ts +++ b/packages/db/src/models/termModel.ts @@ -1,5 +1,5 @@ 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 { alias } from "drizzle-orm/pg-core"; @@ -33,9 +33,9 @@ export const getGameTerms = async ( const rows = await db .select({ termId: terms.id, - prompt: sourceTranslations.text, - answer: targetTranslations.text, - gloss: term_glosses.text, + sourceText: sourceTranslations.text, + targetText: targetTranslations.text, + sourceGloss: term_glosses.text, }) .from(terms) .innerJoin( @@ -79,3 +79,34 @@ export const getGameTerms = async ( return rows; }; + +export const getDistractors = async ( + excludeTermId: string, + targetLanguage: SupportedLanguageCode, + pos: SupportedPos, + difficulty: DifficultyLevel, + count: number, +): Promise => { + 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); +};