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:
parent
0cf6a852b2
commit
f53ac618bb
4 changed files with 95 additions and 20 deletions
|
|
@ -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 });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue