lila/apps/api/src/services/multiplayerGameService.ts
lila 745c5c4e3a feat(api): add WebSocket foundation and multiplayer game store
- Add ws/ directory: server setup, auth, router, connections map
- WebSocket auth rejects upgrade with 401 if no Better Auth session
- Router parses WsClientMessageSchema, dispatches to handlers,
  two-layer error handling (AppError -> WsErrorSchema, unknown -> 500)
- connections.ts: in-memory Map<lobbyId, Map<userId, WebSocket>>
  with addConnection, removeConnection, broadcastToLobby
- LobbyGameStore interface + InMemoryLobbyGameStore implementation
  following existing GameSessionStore pattern
- multiplayerGameService: generateMultiplayerQuestions() decoupled
  from single-player flow, hardcoded defaults en->it nouns easy 3 rounds
- handleLobbyJoin and handleLobbyLeave implemented
- WsErrorSchema added to shared schemas
- server.ts switched to createServer + setupWebSocket
2026-04-17 09:36:16 +02:00

75 lines
2.1 KiB
TypeScript

import { randomUUID } from "crypto";
import { getGameTerms, getDistractors } from "@lila/db";
import type {
GameQuestion,
AnswerOption,
SupportedLanguageCode,
SupportedPos,
DifficultyLevel,
} from "@lila/shared";
// TODO(game-mode-slice): replace with lobby settings when mode selection lands
const MULTIPLAYER_DEFAULTS = {
sourceLanguage: "en" as SupportedLanguageCode,
targetLanguage: "it" as SupportedLanguageCode,
pos: "noun" as SupportedPos,
difficulty: "easy" as DifficultyLevel,
rounds: 3,
};
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;
};
export type MultiplayerQuestion = GameQuestion & { correctOptionId: number };
export const generateMultiplayerQuestions = async (): Promise<
MultiplayerQuestion[]
> => {
const correctAnswers = await getGameTerms(
MULTIPLAYER_DEFAULTS.sourceLanguage,
MULTIPLAYER_DEFAULTS.targetLanguage,
MULTIPLAYER_DEFAULTS.pos,
MULTIPLAYER_DEFAULTS.difficulty,
MULTIPLAYER_DEFAULTS.rounds,
);
const questions: MultiplayerQuestion[] = await Promise.all(
correctAnswers.map(async (correctAnswer) => {
const distractorTexts = await getDistractors(
correctAnswer.termId,
correctAnswer.targetText,
MULTIPLAYER_DEFAULTS.targetLanguage,
MULTIPLAYER_DEFAULTS.pos,
MULTIPLAYER_DEFAULTS.difficulty,
3,
);
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts);
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
optionId: index,
text,
}));
return {
questionId: randomUUID(),
prompt: correctAnswer.sourceText,
gloss: correctAnswer.sourceGloss,
options,
correctOptionId,
};
}),
);
return questions;
};