update documentation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m23s

This commit is contained in:
lila 2026-04-19 08:38:12 +02:00
commit bbc9a3d630
60 changed files with 4261 additions and 276 deletions

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/src/server.js",
"test": "vitest"
@ -14,12 +14,14 @@
"@lila/shared": "workspace:*",
"better-auth": "^1.6.2",
"cors": "^2.8.6",
"express": "^5.2.1"
"express": "^5.2.1",
"ws": "^8.20.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0"
}

View file

@ -1,7 +1,52 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
import type { GameSession, AnswerResult } from "@lila/shared";
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
type SuccessResponse<T> = { success: true; data: T };
type ErrorResponse = { success: false; error: string };
type GameStartResponse = SuccessResponse<GameSession>;
type GameAnswerResponse = SuccessResponse<AnswerResult>;
vi.mock("@lila/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@lila/db")>();
return { ...actual, getGameTerms: vi.fn(), getDistractors: vi.fn() };
});
vi.mock("../lib/auth.js", () => ({
auth: {
api: {
getSession: vi
.fn()
.mockResolvedValue({
session: {
id: "session-1",
userId: "user-1",
token: "fake-token",
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
createdAt: new Date(),
updatedAt: new Date(),
ipAddress: null,
userAgent: null,
},
user: {
id: "user-1",
name: "Test User",
email: "test@test.com",
emailVerified: false,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
}),
},
handler: vi.fn(),
},
}));
vi.mock("better-auth/node", () => ({
fromNodeHeaders: vi.fn().mockReturnValue({}),
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));
import { getGameTerms, getDistractors } from "@lila/db";
import { createApp } from "../app.js";
@ -33,49 +78,48 @@ beforeEach(() => {
describe("POST /api/v1/game/start", () => {
it("returns 200 with a valid game session", async () => {
const res = await request(app).post("/api/v1/game/start").send(validBody);
const body = res.body as GameStartResponse;
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.sessionId).toBeDefined();
expect(res.body.data.questions).toHaveLength(3);
expect(body.success).toBe(true);
expect(body.data.sessionId).toBeDefined();
expect(body.data.questions).toHaveLength(3);
});
it("returns 400 when the body is empty", async () => {
const res = await request(app).post("/api/v1/game/start").send({});
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toBeDefined();
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
});
it("returns 400 when required fields are missing", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ source_language: "en" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(body.success).toBe(false);
});
it("returns 400 when a field has an invalid value", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, difficulty: "impossible" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(body.success).toBe(false);
});
});
describe("POST /api/v1/game/answer", () => {
it("returns 200 with an answer result for a valid submission", async () => {
// Start a game first
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const { sessionId, questions } = startRes.body.data;
const question = questions[0];
const startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startBody.data;
const question = questions[0]!;
const res = await request(app)
.post("/api/v1/game/answer")
@ -84,20 +128,20 @@ describe("POST /api/v1/game/answer", () => {
questionId: question.questionId,
selectedOptionId: 0,
});
const body = res.body as GameAnswerResponse;
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.questionId).toBe(question.questionId);
expect(typeof res.body.data.isCorrect).toBe("boolean");
expect(typeof res.body.data.correctOptionId).toBe("number");
expect(res.body.data.selectedOptionId).toBe(0);
expect(body.success).toBe(true);
expect(body.data.questionId).toBe(question.questionId);
expect(typeof body.data.isCorrect).toBe("boolean");
expect(typeof body.data.correctOptionId).toBe("number");
expect(body.data.selectedOptionId).toBe(0);
});
it("returns 400 when the body is empty", async () => {
const res = await request(app).post("/api/v1/game/answer").send({});
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(body.success).toBe(false);
});
it("returns 404 when the session does not exist", async () => {
@ -108,18 +152,18 @@ describe("POST /api/v1/game/answer", () => {
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
});
const body = res.body as ErrorResponse;
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain("Game session not found");
expect(body.success).toBe(false);
expect(body.error).toContain("Game session not found");
});
it("returns 404 when the question does not exist in the session", async () => {
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const { sessionId } = startRes.body.data;
const startBody = startRes.body as GameStartResponse;
const { sessionId } = startBody.data;
const res = await request(app)
.post("/api/v1/game/answer")
@ -128,9 +172,9 @@ describe("POST /api/v1/game/answer", () => {
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
});
const body = res.body as ErrorResponse;
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain("Question not found");
expect(body.success).toBe(false);
expect(body.error).toContain("Question not found");
});
});

View file

@ -0,0 +1,37 @@
import type { Request, Response, NextFunction } from "express";
import { createLobby, joinLobby } from "../services/lobbyService.js";
export const createLobbyHandler = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const userId = req.session!.user.id;
const lobby = await createLobby(userId);
res.json({ success: true, data: lobby });
} catch (error) {
next(error);
}
};
export const joinLobbyHandler = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const userId = req.session!.user.id;
const code = req.params["code"];
if (!code) {
return next(new Error("Missing code param"));
}
if (typeof code !== "string") {
return next(new Error("Missing or invalid code param"));
}
const lobby = await joinLobby(code, userId);
res.json({ success: true, data: lobby });
} catch (error) {
next(error);
}
};

View file

@ -19,3 +19,9 @@ export class NotFoundError extends AppError {
super(message, 404);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409);
}
}

View file

@ -3,15 +3,17 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
export class InMemoryGameSessionStore implements GameSessionStore {
private sessions = new Map<string, GameSessionData>();
async create(sessionId: string, data: GameSessionData): Promise<void> {
create(sessionId: string, data: GameSessionData): Promise<void> {
this.sessions.set(sessionId, data);
return Promise.resolve();
}
async get(sessionId: string): Promise<GameSessionData | null> {
return this.sessions.get(sessionId) ?? null;
get(sessionId: string): Promise<GameSessionData | null> {
return Promise.resolve(this.sessions.get(sessionId) ?? null);
}
async delete(sessionId: string): Promise<void> {
delete(sessionId: string): Promise<void> {
this.sessions.delete(sessionId);
return Promise.resolve();
}
}

View file

@ -0,0 +1,27 @@
import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
export class InMemoryLobbyGameStore implements LobbyGameStore {
private games = new Map<string, LobbyGameData>();
create(lobbyId: string, data: LobbyGameData): Promise<void> {
if (this.games.has(lobbyId)) {
throw new Error(`Game already exists for lobby: ${lobbyId}`);
}
this.games.set(lobbyId, data);
return Promise.resolve();
}
get(lobbyId: string): Promise<LobbyGameData | null> {
return Promise.resolve(this.games.get(lobbyId) ?? null);
}
set(lobbyId: string, data: LobbyGameData): Promise<void> {
this.games.set(lobbyId, data);
return Promise.resolve();
}
delete(lobbyId: string): Promise<void> {
this.games.delete(lobbyId);
return Promise.resolve();
}
}

View file

@ -0,0 +1,18 @@
import type { MultiplayerQuestion } from "../services/multiplayerGameService.js";
export type LobbyGameData = {
code: string;
questions: MultiplayerQuestion[];
currentIndex: number;
// NOTE: Map types are used here for O(1) lookups in-process.
// When migrating to Valkey, convert to plain objects for JSON serialization.
playerAnswers: Map<string, number | null>; // userId → selectedOptionId, null = timed out
scores: Map<string, number>; // userId → running total
};
export interface LobbyGameStore {
create(lobbyId: string, data: LobbyGameData): Promise<void>;
get(lobbyId: string): Promise<LobbyGameData | null>;
set(lobbyId: string, data: LobbyGameData): Promise<void>;
delete(lobbyId: string): Promise<void>;
}

View file

@ -0,0 +1,2 @@
export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js";

View file

@ -16,5 +16,7 @@ export const requireAuth = async (
return;
}
req.session = session;
next();
};

View file

@ -2,8 +2,10 @@ import express from "express";
import { Router } from "express";
import { healthRouter } from "./healthRouter.js";
import { gameRouter } from "./gameRouter.js";
import { lobbyRouter } from "./lobbyRouter.js";
export const apiRouter: Router = express.Router();
apiRouter.use("/health", healthRouter);
apiRouter.use("/game", gameRouter);
apiRouter.use("/lobbies", lobbyRouter);

View file

@ -0,0 +1,14 @@
import express from "express";
import type { Router } from "express";
import {
createLobbyHandler,
joinLobbyHandler,
} from "../controllers/lobbyController.js";
import { requireAuth } from "../middleware/authMiddleware.js";
export const lobbyRouter: Router = express.Router();
lobbyRouter.use(requireAuth);
lobbyRouter.post("/", createLobbyHandler);
lobbyRouter.post("/:code/join", joinLobbyHandler);

View file

@ -1,9 +1,14 @@
import { createServer } from "http";
import { createApp } from "./app.js";
import { setupWebSocket } from "./ws/index.js";
const PORT = Number(process.env["PORT"] ?? 3000);
const app = createApp();
const server = createServer(app);
app.listen(PORT, () => {
setupWebSocket(server);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});

View file

@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@lila/db", () => ({
createLobby: vi.fn(),
getLobbyByCodeWithPlayers: vi.fn(),
addPlayer: vi.fn(),
}));
import {
createLobby as createLobbyModel,
getLobbyByCodeWithPlayers,
addPlayer,
} from "@lila/db";
import { createLobby, joinLobby } from "./lobbyService.js";
const mockCreateLobby = vi.mocked(createLobbyModel);
const mockGetLobbyByCodeWithPlayers = vi.mocked(getLobbyByCodeWithPlayers);
const mockAddPlayer = vi.mocked(addPlayer);
const fakeLobby = {
id: "00000000-0000-4000-8000-000000000001",
code: "ABC123",
hostUserId: "user-1",
status: "waiting" as const,
createdAt: new Date(),
};
const fakeLobbyWithPlayers = {
...fakeLobby,
players: [
{
lobbyId: fakeLobby.id,
userId: "user-1",
score: 0,
joinedAt: new Date(),
user: { id: "user-1", name: "Alice" },
},
],
};
beforeEach(() => {
vi.clearAllMocks();
mockCreateLobby.mockResolvedValue(fakeLobby);
mockAddPlayer.mockResolvedValue({
lobbyId: fakeLobby.id,
userId: "user-1",
score: 0,
joinedAt: new Date(),
});
mockGetLobbyByCodeWithPlayers.mockResolvedValue(fakeLobbyWithPlayers);
});
describe("createLobby", () => {
it("creates a lobby and adds the host as the first player", async () => {
const result = await createLobby("user-1");
expect(mockCreateLobby).toHaveBeenCalledOnce();
expect(mockAddPlayer).toHaveBeenCalledWith(
fakeLobby.id,
"user-1",
expect.any(Number),
);
expect(result.id).toBe(fakeLobby.id);
});
it("retries on unique code collision", async () => {
const uniqueViolation = Object.assign(new Error("unique"), {
code: "23505",
});
mockCreateLobby
.mockRejectedValueOnce(uniqueViolation)
.mockResolvedValueOnce(fakeLobby);
const result = await createLobby("user-1");
expect(mockCreateLobby).toHaveBeenCalledTimes(2);
expect(result.id).toBe(fakeLobby.id);
});
it("throws after max retry attempts", async () => {
const uniqueViolation = Object.assign(new Error("unique"), {
code: "23505",
});
mockCreateLobby.mockRejectedValue(uniqueViolation);
await expect(createLobby("user-1")).rejects.toThrow(
"Could not generate a unique lobby code",
);
});
});
describe("joinLobby", () => {
it("returns lobby with players when join succeeds", async () => {
const fullLobby = {
...fakeLobbyWithPlayers,
players: [
...fakeLobbyWithPlayers.players,
{
lobbyId: fakeLobby.id,
userId: "user-2",
score: 0,
joinedAt: new Date(),
user: { id: "user-2", name: "Bob" },
},
],
};
mockGetLobbyByCodeWithPlayers
.mockResolvedValueOnce(fakeLobbyWithPlayers)
.mockResolvedValueOnce(fullLobby);
const result = await joinLobby("ABC123", "user-2");
expect(mockAddPlayer).toHaveBeenCalledWith(
fakeLobby.id,
"user-2",
expect.any(Number),
);
expect(result.players).toHaveLength(2);
});
it("throws NotFoundError when lobby does not exist", async () => {
mockGetLobbyByCodeWithPlayers.mockResolvedValue(undefined);
await expect(joinLobby("XXXXXX", "user-2")).rejects.toThrow(
"Lobby not found",
);
});
it("throws ConflictError when lobby is not waiting", async () => {
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
...fakeLobbyWithPlayers,
status: "in_progress" as const,
});
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
"Game has already started",
);
});
it("returns lobby idempotently when user already joined", async () => {
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
...fakeLobbyWithPlayers,
players: [
{
lobbyId: fakeLobby.id,
userId: "user-1",
score: 0,
joinedAt: new Date(),
user: { id: "user-1", name: "Alice" },
},
],
});
const result = await joinLobby("ABC123", "user-1");
expect(mockAddPlayer).not.toHaveBeenCalled();
expect(result.players).toHaveLength(1);
});
it("throws ConflictError when lobby is full", async () => {
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
...fakeLobbyWithPlayers,
players: Array.from({ length: 4 }, (_, i) => ({
lobbyId: fakeLobby.id,
userId: `user-${i}`,
score: 0,
joinedAt: new Date(),
user: { id: `user-${i}`, name: `Player ${i}` },
})),
});
await expect(joinLobby("ABC123", "user-5")).rejects.toThrow(
"Lobby is full",
);
});
});

View file

@ -0,0 +1,72 @@
import { randomInt } from "crypto";
import {
createLobby as createLobbyModel,
getLobbyByCodeWithPlayers,
addPlayer,
} from "@lila/db";
import type { Lobby, LobbyWithPlayers } from "@lila/db";
import { MAX_LOBBY_PLAYERS } from "@lila/shared";
import { NotFoundError, ConflictError, AppError } from "../errors/AppError.js";
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford Base32
const CODE_LENGTH = 6;
const MAX_CODE_ATTEMPTS = 5;
const generateLobbyCode = (): string => {
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
}
return code;
};
const isUniqueViolation = (err: unknown): boolean => {
return (err as { code?: string })?.code === "23505";
};
export const createLobby = async (hostUserId: string): Promise<Lobby> => {
for (let i = 0; i < MAX_CODE_ATTEMPTS; i++) {
const code = generateLobbyCode();
try {
const lobby = await createLobbyModel(code, hostUserId);
await addPlayer(lobby.id, hostUserId, MAX_LOBBY_PLAYERS);
return lobby;
} catch (err) {
if (isUniqueViolation(err)) continue;
throw err;
}
}
throw new AppError("Could not generate a unique lobby code", 500);
};
export const joinLobby = async (
code: string,
userId: string,
): Promise<LobbyWithPlayers> => {
const lobby = await getLobbyByCodeWithPlayers(code);
if (!lobby) {
throw new NotFoundError(`Lobby not found: ${code}`);
}
if (lobby.status !== "waiting") {
throw new ConflictError("Game has already started");
}
if (lobby.players.some((p) => p.userId === userId)) {
return lobby; // idempotent: already in lobby
}
if (lobby.players.length >= MAX_LOBBY_PLAYERS) {
throw new ConflictError("Lobby is full");
}
const player = await addPlayer(lobby.id, userId, MAX_LOBBY_PLAYERS);
if (!player) {
// Race fallback: another request filled the last slot, started the game,
// or the user joined concurrently. Pre-checks above handle the common cases.
throw new ConflictError("Lobby is no longer available");
}
const fresh = await getLobbyByCodeWithPlayers(code);
if (!fresh) {
throw new AppError("Lobby disappeared during join", 500);
}
return fresh;
};

View file

@ -0,0 +1,75 @@
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;
};

17
apps/api/src/types/express.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
import type { Session, User } from "better-auth";
declare global {
namespace Express {
interface Request {
session?: { session: Session; user: User };
}
}
}
declare module "ws" {
interface WebSocket {
lobbyId?: string | undefined;
}
}
export {};

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { IncomingMessage } from "http";
import { Duplex } from "stream";
vi.mock("better-auth/node", () => ({
fromNodeHeaders: vi.fn().mockReturnValue({}),
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));
vi.mock("../lib/auth.js", () => ({
auth: { api: { getSession: vi.fn() }, handler: vi.fn() },
}));
import { auth } from "../lib/auth.js";
import { handleUpgrade } from "./auth.js";
const mockGetSession = vi.mocked(auth.api.getSession);
const fakeSession = {
session: {
id: "session-1",
userId: "user-1",
token: "fake-token",
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
createdAt: new Date(),
updatedAt: new Date(),
ipAddress: null,
userAgent: null,
},
user: {
id: "user-1",
name: "Test User",
email: "test@test.com",
emailVerified: false,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
};
const makeMockSocket = () => {
const socket = new Duplex();
socket._read = () => {};
socket._write = (_chunk, _encoding, callback) => callback();
const writeSpy = vi.spyOn(socket, "write").mockImplementation(() => true);
const destroySpy = vi
.spyOn(socket, "destroy")
.mockImplementation(() => socket);
return { socket, writeSpy, destroySpy };
};
const makeMockRequest = () => {
const req = new IncomingMessage(null as never);
req.headers = {};
return req;
};
const makeMockWss = () => ({
handleUpgrade: vi.fn((_req, _socket, _head, cb: (ws: unknown) => void) => {
cb({ send: vi.fn(), on: vi.fn() });
}),
emit: vi.fn(),
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("handleUpgrade", () => {
it("rejects with 401 when no session exists", async () => {
mockGetSession.mockResolvedValue(null);
const { socket, writeSpy, destroySpy } = makeMockSocket();
const req = makeMockRequest();
const wss = makeMockWss();
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("401"));
expect(destroySpy).toHaveBeenCalled();
expect(wss.handleUpgrade).not.toHaveBeenCalled();
});
it("upgrades connection when session exists", async () => {
mockGetSession.mockResolvedValue(fakeSession);
const { socket, destroySpy } = makeMockSocket();
const req = makeMockRequest();
const wss = makeMockWss();
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
expect(wss.handleUpgrade).toHaveBeenCalled();
expect(wss.emit).toHaveBeenCalledWith(
"connection",
expect.anything(),
req,
fakeSession,
);
expect(destroySpy).not.toHaveBeenCalled();
});
it("rejects with 500 when getSession throws", async () => {
mockGetSession.mockRejectedValue(new Error("DB error"));
const { socket, writeSpy, destroySpy } = makeMockSocket();
const req = makeMockRequest();
const wss = makeMockWss();
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("500"));
expect(destroySpy).toHaveBeenCalled();
});
});

32
apps/api/src/ws/auth.ts Normal file
View file

@ -0,0 +1,32 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type { WebSocketServer, WebSocket } from "ws";
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "../lib/auth.js";
export const handleUpgrade = async (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
wss: WebSocketServer,
): Promise<void> => {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(request.headers),
});
if (!session) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
wss.emit("connection", ws, request, session);
});
} catch (err) {
console.error("WebSocket auth error:", err);
socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
socket.destroy();
}
};

View file

@ -0,0 +1,44 @@
import type { WebSocket } from "ws";
// Map<lobbyId, Map<userId, WebSocket>>
const connections = new Map<string, Map<string, WebSocket>>();
export const addConnection = (
lobbyId: string,
userId: string,
ws: WebSocket,
): void => {
if (!connections.has(lobbyId)) {
connections.set(lobbyId, new Map());
}
connections.get(lobbyId)!.set(userId, ws);
};
export const removeConnection = (lobbyId: string, userId: string): void => {
const lobby = connections.get(lobbyId);
if (!lobby) return;
lobby.delete(userId);
if (lobby.size === 0) {
connections.delete(lobbyId);
}
};
export const getConnections = (lobbyId: string): Map<string, WebSocket> => {
return connections.get(lobbyId) ?? new Map<string, WebSocket>();
};
export const broadcastToLobby = (
lobbyId: string,
message: unknown,
excludeUserId?: string,
): void => {
const lobby = connections.get(lobbyId);
if (!lobby) return;
const payload = JSON.stringify(message);
for (const [userId, ws] of lobby) {
if (excludeUserId && userId === excludeUserId) continue;
if (ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
};

View file

@ -0,0 +1,4 @@
import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js";
export const lobbyGameStore = new InMemoryLobbyGameStore();
export const timers = new Map<string, NodeJS.Timeout>();

View file

@ -0,0 +1,213 @@
import type { WebSocket } from "ws";
import type { User } from "better-auth";
import type { WsGameAnswer, WsGameReady } from "@lila/shared";
import { finishGame, getLobbyByCodeWithPlayers } from "@lila/db";
import { broadcastToLobby, getConnections } from "../connections.js";
import { lobbyGameStore, timers } from "../gameState.js";
import { NotFoundError, ConflictError } from "../../errors/AppError.js";
export const handleGameAnswer = async (
_ws: WebSocket,
msg: WsGameAnswer,
user: User,
): Promise<void> => {
const state = await lobbyGameStore.get(msg.lobbyId);
if (!state) {
throw new NotFoundError("Game not found");
}
const currentQuestion = state.questions[state.currentIndex];
if (!currentQuestion) {
throw new ConflictError("No active question");
}
// Reject stale answers
if (currentQuestion.questionId !== msg.questionId) {
throw new ConflictError("Answer is for wrong question");
}
// Reject duplicate answers
if (state.playerAnswers.has(user.id)) {
throw new ConflictError("Already answered this question");
}
// Store answer
state.playerAnswers.set(user.id, msg.selectedOptionId);
await lobbyGameStore.set(msg.lobbyId, state);
// Check if all connected players have answered
const connected = getConnections(msg.lobbyId);
const allAnswered = [...connected.keys()].every((userId) =>
state.playerAnswers.has(userId),
);
if (allAnswered) {
// Clear timer — no need to wait
const timer = timers.get(msg.lobbyId);
if (timer) {
clearTimeout(timer);
timers.delete(msg.lobbyId);
}
await resolveRound(msg.lobbyId, state.currentIndex, state.questions.length);
}
};
export const handleGameReady = async (
ws: WebSocket,
msg: WsGameReady,
_user: User,
): Promise<void> => {
const state = await lobbyGameStore.get(msg.lobbyId);
if (!state) throw new NotFoundError("Game not found");
const currentQuestion = state.questions[state.currentIndex];
if (!currentQuestion) throw new NotFoundError("No active question");
ws.send(
JSON.stringify({
type: "game:question",
question: {
questionId: currentQuestion.questionId,
prompt: currentQuestion.prompt,
gloss: currentQuestion.gloss,
options: currentQuestion.options,
},
questionNumber: state.currentIndex + 1,
totalQuestions: state.questions.length,
}),
);
};
export const resolveRound = async (
lobbyId: string,
questionIndex: number,
totalQuestions: number,
): Promise<void> => {
const state = await lobbyGameStore.get(lobbyId);
if (!state) return; // lobby was deleted mid-round, nothing to do
const currentQuestion = state.questions[questionIndex];
if (!currentQuestion) return;
// Fill null for any players who didn't answer (timed out)
const connected = getConnections(lobbyId);
for (const userId of connected.keys()) {
if (!state.playerAnswers.has(userId)) {
state.playerAnswers.set(userId, null);
}
}
// Evaluate answers and update scores
const results: {
userId: string;
selectedOptionId: number | null;
isCorrect: boolean;
}[] = [];
for (const [userId, selectedOptionId] of state.playerAnswers) {
const isCorrect =
selectedOptionId !== null &&
selectedOptionId === currentQuestion.correctOptionId;
if (isCorrect) {
state.scores.set(userId, (state.scores.get(userId) ?? 0) + 1);
}
results.push({ userId, selectedOptionId, isCorrect });
}
// Build updated players array for broadcast
const players = [...state.scores.entries()].map(([userId, score]) => ({
userId,
score,
lobbyId,
user: { id: userId, name: userId }, // name resolved below
}));
// Resolve user names from DB
const lobby = await getLobbyByCodeWithPlayers(state.code);
const namedPlayers = players.map((p) => {
const dbPlayer = lobby?.players.find((dp) => dp.userId === p.userId);
return {
...p,
user: { id: p.userId, name: dbPlayer?.user.name ?? p.userId },
};
});
// Broadcast answer result
broadcastToLobby(lobbyId, {
type: "game:answer_result",
correctOptionId: currentQuestion.correctOptionId,
results,
players: namedPlayers,
});
// Save updated state
state.playerAnswers = new Map();
state.currentIndex = questionIndex + 1;
await lobbyGameStore.set(lobbyId, state);
const isLastRound = questionIndex + 1 >= totalQuestions;
if (isLastRound) {
await endGame(lobbyId, state);
} else {
// Wait 3s then broadcast next question
setTimeout(() => {
void (async () => {
const fresh = await lobbyGameStore.get(lobbyId);
if (!fresh) return;
const nextQuestion = fresh.questions[fresh.currentIndex];
if (!nextQuestion) return;
broadcastToLobby(lobbyId, {
type: "game:question",
question: {
questionId: nextQuestion.questionId,
prompt: nextQuestion.prompt,
gloss: nextQuestion.gloss,
options: nextQuestion.options,
},
questionNumber: fresh.currentIndex + 1,
totalQuestions,
});
// Restart timer for next round
const timer = setTimeout(() => {
void resolveRound(lobbyId, fresh.currentIndex, totalQuestions);
}, 15000);
timers.set(lobbyId, timer);
})();
}, 3000);
}
};
const endGame = async (
lobbyId: string,
state: Awaited<ReturnType<typeof lobbyGameStore.get>> & {},
): Promise<void> => {
// Persist final scores to DB
await finishGame(lobbyId, state.scores);
// Determine winners (handle ties)
const maxScore = Math.max(...state.scores.values());
const winnerIds = [...state.scores.entries()]
.filter(([, score]) => score === maxScore)
.map(([userId]) => userId);
// Build final players array
const lobby = await getLobbyByCodeWithPlayers(state.code);
const players = [...state.scores.entries()].map(([userId, score]) => {
const dbPlayer = lobby?.players.find((p) => p.userId === userId);
return {
lobbyId,
userId,
score,
user: { id: userId, name: dbPlayer?.user.name ?? userId },
};
});
broadcastToLobby(lobbyId, { type: "game:finished", players, winnerIds });
// Clean up game state
await lobbyGameStore.delete(lobbyId);
timers.delete(lobbyId);
};

View file

@ -0,0 +1,158 @@
import type { WebSocket } from "ws";
import type { User } from "better-auth";
import type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared";
import {
getLobbyByCodeWithPlayers,
deleteLobby,
removePlayer,
updateLobbyStatus,
getLobbyByIdWithPlayers,
} from "@lila/db";
import {
addConnection,
getConnections,
removeConnection,
broadcastToLobby,
} from "../connections.js";
import { NotFoundError, ConflictError } from "../../errors/AppError.js";
import { generateMultiplayerQuestions } from "../../services/multiplayerGameService.js";
import { lobbyGameStore, timers } from "../gameState.js";
import { resolveRound } from "./gameHandlers.js";
export const handleLobbyJoin = async (
ws: WebSocket,
msg: WsLobbyJoin,
user: User,
): Promise<void> => {
// Load lobby and validate membership
const lobby = await getLobbyByCodeWithPlayers(msg.code);
if (!lobby) {
throw new NotFoundError("Lobby not found");
}
if (lobby.status !== "waiting") {
throw new ConflictError("Lobby is not in waiting state");
}
if (!lobby.players.some((p) => p.userId === user.id)) {
throw new ConflictError("You are not a member of this lobby");
}
// Register connection and tag the socket with lobbyId
addConnection(lobby.id, user.id, ws);
ws.lobbyId = lobby.id;
// Broadcast updated lobby state to all players
broadcastToLobby(lobby.id, { type: "lobby:state", lobby });
};
export const handleLobbyLeave = async (
ws: WebSocket,
msg: WsLobbyLeave,
user: User,
): Promise<void> => {
const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId);
if (!lobby) return;
removeConnection(msg.lobbyId, user.id);
ws.lobbyId = undefined;
if (lobby.hostUserId === user.id) {
await deleteLobby(msg.lobbyId);
broadcastToLobby(msg.lobbyId, {
type: "error",
code: "LOBBY_CLOSED",
message: "Host left the lobby",
});
for (const player of lobby.players) {
removeConnection(msg.lobbyId, player.userId);
}
} else {
await removePlayer(msg.lobbyId, user.id);
const updated = await getLobbyByCodeWithPlayers(lobby.code);
if (!updated) return;
broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated });
// TODO(reconnection-slice): if lobby.status === 'in_progress', the game
// continues with remaining players. If only one player remains after this
// leave, end the game immediately and declare them winner. Currently we
// broadcast updated lobby state and let the game resolve naturally via
// timeouts — the disconnected player's answers will be null each round.
// When reconnection handling is added, this is the place to change.
}
};
export const handleLobbyStart = async (
_ws: WebSocket,
msg: WsLobbyStart,
user: User,
): Promise<void> => {
// Load lobby and validate
const lobby = await getLobbyByIdWithPlayers(msg.lobbyId);
if (!lobby) {
throw new NotFoundError("Lobby not found");
}
if (lobby.hostUserId !== user.id) {
throw new ConflictError("Only the host can start the game");
}
if (lobby.status !== "waiting") {
throw new ConflictError("Game has already started");
}
// Check connected players, not DB players
const connected = getConnections(msg.lobbyId);
if (connected.size < 2) {
throw new ConflictError("At least 2 players must be connected to start");
}
// Generate questions
const questions = await generateMultiplayerQuestions();
// Initialize scores for all connected players
const scores = new Map<string, number>();
for (const userId of connected.keys()) {
scores.set(userId, 0);
}
// Initialize game state
await lobbyGameStore.create(msg.lobbyId, {
code: lobby.code,
questions,
currentIndex: 0,
playerAnswers: new Map(),
scores,
});
// Update lobby status in DB
await updateLobbyStatus(msg.lobbyId, "in_progress");
// Broadcast first question
const firstQuestion = questions[0]!;
broadcastToLobby(msg.lobbyId, {
type: "game:question",
question: {
questionId: firstQuestion.questionId,
prompt: firstQuestion.prompt,
gloss: firstQuestion.gloss,
options: firstQuestion.options,
},
questionNumber: 1,
totalQuestions: questions.length,
});
// Start 15s timer
startRoundTimer(msg.lobbyId, 0, questions.length);
};
const startRoundTimer = (
lobbyId: string,
questionIndex: number,
totalQuestions: number,
): void => {
const timer = setTimeout(() => {
void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => {
console.error("Error resolving round after timeout:", err);
});
}, 15000);
timers.set(lobbyId, timer);
};

65
apps/api/src/ws/index.ts Normal file
View file

@ -0,0 +1,65 @@
import { WebSocketServer } from "ws";
import type { WebSocket } from "ws";
import type { Server } from "http";
import type { IncomingMessage } from "http";
import { handleUpgrade } from "./auth.js";
import { handleMessage, type AuthenticatedUser } from "./router.js";
import { removeConnection } from "./connections.js";
import { handleLobbyLeave } from "./handlers/lobbyHandlers.js";
export const setupWebSocket = (server: Server): WebSocketServer => {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
if (request.url !== "/ws") {
socket.destroy();
return;
}
void handleUpgrade(request, socket, head, wss).catch((err) => {
console.error("WebSocket upgrade error:", err);
socket.destroy();
});
});
wss.on(
"connection",
(ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => {
ws.on("message", (rawData) => {
void handleMessage(ws, rawData, auth).catch((err) => {
console.error(
`WebSocket message error for user ${auth.user.id}:`,
err,
);
});
});
ws.on("close", () => {
void handleDisconnect(ws, auth).catch((err) => {
console.error(
`WebSocket disconnect error for user ${auth.user.id}:`,
err,
);
});
});
ws.on("error", (err) => {
console.error(`WebSocket error for user ${auth.user.id}:`, err);
});
},
);
return wss;
};
const handleDisconnect = async (
ws: WebSocket,
auth: AuthenticatedUser,
): Promise<void> => {
if (!ws.lobbyId) return; // user connected but never joined a lobby
removeConnection(ws.lobbyId, auth.user.id);
await handleLobbyLeave(
ws,
{ type: "lobby:leave", lobbyId: ws.lobbyId },
auth.user,
);
};

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("./handlers/lobbyHandlers.js", () => ({
handleLobbyJoin: vi.fn(),
handleLobbyLeave: vi.fn(),
handleLobbyStart: vi.fn(),
}));
vi.mock("./handlers/gameHandlers.js", () => ({
handleGameAnswer: vi.fn(),
handleGameReady: vi.fn(),
}));
import { handleMessage } from "./router.js";
import {
handleLobbyJoin,
handleLobbyLeave,
handleLobbyStart,
} from "./handlers/lobbyHandlers.js";
import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js";
const mockHandleLobbyJoin = vi.mocked(handleLobbyJoin);
const mockHandleLobbyLeave = vi.mocked(handleLobbyLeave);
const mockHandleLobbyStart = vi.mocked(handleLobbyStart);
const mockHandleGameAnswer = vi.mocked(handleGameAnswer);
const mockHandleGameReady = vi.mocked(handleGameReady);
const fakeWs = { send: vi.fn(), readyState: 1, OPEN: 1 };
const FAKE_LOBBY_ID = "00000000-0000-4000-8000-000000000001";
const FAKE_QUESTION_ID = "00000000-0000-4000-8000-000000000002";
const fakeAuth = {
session: {
id: "session-1",
userId: "user-1",
token: "fake-token",
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
createdAt: new Date(),
updatedAt: new Date(),
ipAddress: null,
userAgent: null,
},
user: {
id: "user-1",
name: "Test User",
email: "test@test.com",
emailVerified: false,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("handleMessage", () => {
it("dispatches lobby:join to handleLobbyJoin", async () => {
const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" });
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(mockHandleLobbyJoin).toHaveBeenCalledWith(
fakeWs,
{ type: "lobby:join", code: "ABC123" },
fakeAuth.user,
);
});
it("dispatches lobby:leave to handleLobbyLeave", async () => {
const msg = JSON.stringify({ type: "lobby:leave", lobbyId: FAKE_LOBBY_ID });
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(mockHandleLobbyLeave).toHaveBeenCalled();
});
it("dispatches lobby:start to handleLobbyStart", async () => {
const msg = JSON.stringify({ type: "lobby:start", lobbyId: FAKE_LOBBY_ID });
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(mockHandleLobbyStart).toHaveBeenCalled();
});
it("dispatches game:answer to handleGameAnswer", async () => {
const msg = JSON.stringify({
type: "game:answer",
lobbyId: FAKE_LOBBY_ID,
questionId: FAKE_QUESTION_ID,
selectedOptionId: 2,
});
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(mockHandleGameAnswer).toHaveBeenCalled();
});
it("dispatches game:ready to handleGameReady", async () => {
const msg = JSON.stringify({ type: "game:ready", lobbyId: FAKE_LOBBY_ID });
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(mockHandleGameReady).toHaveBeenCalled();
});
it("sends error message for invalid JSON", async () => {
await handleMessage(fakeWs as never, "not json", fakeAuth);
expect(fakeWs.send).toHaveBeenCalledWith(
expect.stringContaining("Invalid JSON"),
);
});
it("sends error message for unknown message type", async () => {
const msg = JSON.stringify({ type: "unknown:type" });
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(fakeWs.send).toHaveBeenCalledWith(
expect.stringContaining("Invalid message format"),
);
});
it("sends error message when handler throws AppError", async () => {
const { AppError } = await import("../errors/AppError.js");
mockHandleLobbyJoin.mockRejectedValueOnce(
new AppError("Lobby not found", 404),
);
const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" });
await handleMessage(fakeWs as never, msg, fakeAuth);
expect(fakeWs.send).toHaveBeenCalledWith(
expect.stringContaining("Lobby not found"),
);
});
});

77
apps/api/src/ws/router.ts Normal file
View file

@ -0,0 +1,77 @@
import type { WebSocket } from "ws";
import type { Session, User } from "better-auth";
import { WsClientMessageSchema } from "@lila/shared";
import {
handleLobbyJoin,
handleLobbyLeave,
handleLobbyStart,
} from "./handlers/lobbyHandlers.js";
import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js";
import { AppError } from "../errors/AppError.js";
export type AuthenticatedUser = { session: Session; user: User };
const sendError = (ws: WebSocket, code: string, message: string): void => {
ws.send(JSON.stringify({ type: "error", code, message }));
};
const assertExhaustive = (_: never): never => {
throw new Error("Unhandled message type");
};
export const handleMessage = async (
ws: WebSocket,
rawData: unknown,
auth: AuthenticatedUser,
): Promise<void> => {
// Layer 1: parse and validate incoming message
let parsed: unknown;
try {
parsed = JSON.parse(
typeof rawData === "string" ? rawData : (rawData as Buffer).toString(),
);
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
const result = WsClientMessageSchema.safeParse(parsed);
if (!result.success) {
ws.send(
JSON.stringify({ type: "error", message: "Invalid message format" }),
);
return;
}
const msg = result.data;
// Layer 2: dispatch to handler, catch and translate errors
try {
switch (msg.type) {
case "lobby:join":
await handleLobbyJoin(ws, msg, auth.user);
break;
case "lobby:leave":
await handleLobbyLeave(ws, msg, auth.user);
break;
case "lobby:start":
await handleLobbyStart(ws, msg, auth.user);
break;
case "game:answer":
await handleGameAnswer(ws, msg, auth.user);
break;
case "game:ready":
await handleGameReady(ws, msg, auth.user);
break;
default:
assertExhaustive(msg);
}
} catch (err) {
if (err instanceof AppError) {
sendError(ws, err.name, err.message);
} else {
console.error("Unhandled WS error:", err);
sendError(ws, "INTERNAL_ERROR", "An unexpected error occurred");
}
}
};

View file

@ -1,3 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node", globals: true } });
export default defineConfig({
test: {
environment: "node",
globals: true,
exclude: ["**/dist/**", "**/node_modules/**"],
},
});

View file

@ -0,0 +1,114 @@
import { useNavigate } from "@tanstack/react-router";
import type { LobbyPlayer } from "@lila/shared";
type MultiplayerScoreScreenProps = {
players: LobbyPlayer[];
winnerIds: string[];
currentUserId: string;
lobbyCode: string;
};
export const MultiplayerScoreScreen = ({
players,
winnerIds,
currentUserId,
lobbyCode,
}: MultiplayerScoreScreenProps) => {
const navigate = useNavigate();
const sortedPlayers = [...players].sort((a, b) => b.score - a.score);
const isWinner = winnerIds.includes(currentUserId);
const isTie = winnerIds.length > 1;
const winnerNames = winnerIds
.map((id) => players.find((p) => p.userId === id)?.user.name ?? id)
.join(" and ");
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
{/* Result header */}
<div className="text-center flex flex-col gap-1">
<h1 className="text-2xl font-bold text-purple-800">
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
</h1>
<p className="text-sm text-gray-500">
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
</p>
</div>
<div className="border-t border-gray-200" />
{/* Score list */}
<div className="flex flex-col gap-2">
{sortedPlayers.map((player, index) => {
const isCurrentUser = player.userId === currentUserId;
const isPlayerWinner = winnerIds.includes(player.userId);
return (
<div
key={player.userId}
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
isCurrentUser
? "bg-purple-50 border border-purple-200"
: "bg-gray-50"
}`}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-400 w-4">
{index + 1}.
</span>
<span
className={`text-sm font-medium ${
isCurrentUser ? "text-purple-800" : "text-gray-700"
}`}
>
{player.user.name}
{isCurrentUser && (
<span className="text-xs text-purple-400 ml-1">
(you)
</span>
)}
</span>
{isPlayerWinner && (
<span className="text-xs text-yellow-500 font-medium">
👑
</span>
)}
</div>
<span className="text-sm font-bold text-gray-700">
{player.score} pts
</span>
</div>
);
})}
</div>
<div className="border-t border-gray-200" />
{/* Actions */}
<div className="flex flex-col gap-3">
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
onClick={() => {
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: lobbyCode },
});
}}
>
Play Again
</button>
<button
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
onClick={() => {
void navigate({ to: "/multiplayer" });
}}
>
Leave
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,135 @@
import { WsServerMessageSchema } from "@lila/shared";
import type { WsClientMessage, WsServerMessage } from "@lila/shared";
/**
* Minimal WebSocket client for multiplayer communication.
*
* NOTE: Callbacks registered via `on()` are stored by reference.
* When using in React components, wrap callbacks in `useCallback`
* to ensure the same reference is passed to both `on()` and `off()`.
*/
export class WsClient {
private ws: WebSocket | null = null;
private callbacks = new Map<string, Set<(msg: WsServerMessage) => void>>();
/**
* Called when the WebSocket connection closes.
* Set by WsProvider do not set directly in components.
*/
public onError: ((event: Event) => void) | null = null;
/**
* Called when the WebSocket connection encounters an error.
* Set by WsProvider do not set directly in components.
*/
public onClose: ((event: CloseEvent) => void) | null = null;
connect(apiUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
) {
resolve();
return;
}
let wsUrl: string;
if (!apiUrl) {
wsUrl = "/ws";
} else {
wsUrl =
apiUrl
.replace(/^https:\/\//, "wss://")
.replace(/^http:\/\//, "ws://") + "/ws";
}
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
resolve();
};
this.ws.onmessage = (event: MessageEvent) => {
let parsed: unknown;
try {
parsed = JSON.parse(event.data as string);
} catch {
console.error("WsClient: received invalid JSON", event.data);
return;
}
const result = WsServerMessageSchema.safeParse(parsed);
if (!result.success) {
console.error("WsClient: received unknown message shape", parsed);
return;
}
const msg = result.data;
const handlers = this.callbacks.get(msg.type);
if (!handlers) return;
for (const handler of handlers) {
handler(msg);
}
};
this.ws.onerror = (event: Event) => {
this.onError?.(event);
reject(new Error("WebSocket connection failed"));
};
this.ws.onclose = (event: CloseEvent) => {
this.ws = null;
this.onClose?.(event);
};
});
}
disconnect(): void {
if (!this.ws) return;
this.ws.close();
this.ws = null;
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
send(message: WsClientMessage): void {
if (!this.isConnected()) {
console.warn(
"WsClient: attempted to send message while disconnected",
message,
);
return;
}
this.ws!.send(JSON.stringify(message));
}
on<T extends WsServerMessage["type"]>(
type: T,
callback: (msg: Extract<WsServerMessage, { type: T }>) => void,
): void {
if (!this.callbacks.has(type)) {
this.callbacks.set(type, new Set());
}
this.callbacks.get(type)!.add(callback as (msg: WsServerMessage) => void);
}
off<T extends WsServerMessage["type"]>(
type: T,
callback: (msg: Extract<WsServerMessage, { type: T }>) => void,
): void {
const handlers = this.callbacks.get(type);
if (!handlers) return;
handlers.delete(callback as (msg: WsServerMessage) => void);
if (handlers.size === 0) {
this.callbacks.delete(type);
}
}
clearCallbacks(): void {
this.callbacks.clear();
}
}

View file

@ -0,0 +1,11 @@
import { createContext } from "react";
import type { WsClient } from "./ws-client.js";
export type WsContextValue = {
client: WsClient;
isConnected: boolean;
connect: (url: string) => Promise<void>;
disconnect: () => void;
};
export const WsContext = createContext<WsContextValue | null>(null);

View file

@ -0,0 +1,35 @@
import { useContext } from "react";
import { WsContext } from "./ws-context.js";
import type { WsClient } from "./ws-client.js";
export const useWsClient = (): WsClient => {
const ctx = useContext(WsContext);
if (!ctx) {
throw new Error("useWsClient must be used within a WsProvider");
}
return ctx.client;
};
export const useWsConnected = (): boolean => {
const ctx = useContext(WsContext);
if (!ctx) {
throw new Error("useWsConnected must be used within a WsProvider");
}
return ctx.isConnected;
};
export const useWsConnect = (): ((url: string) => Promise<void>) => {
const ctx = useContext(WsContext);
if (!ctx) {
throw new Error("useWsConnect must be used within a WsProvider");
}
return ctx.connect;
};
export const useWsDisconnect = (): (() => void) => {
const ctx = useContext(WsContext);
if (!ctx) {
throw new Error("useWsDisconnect must be used within a WsProvider");
}
return ctx.disconnect;
};

View file

@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { WsClient } from "./ws-client.js";
import { WsContext } from "./ws-context.js";
const wsClient = new WsClient();
export const WsProvider = ({ children }: { children: ReactNode }) => {
const [isConnected, setIsConnected] = useState(false);
const connect = useCallback(async (url: string): Promise<void> => {
if (wsClient.isConnected()) return;
wsClient.onClose = () => setIsConnected(false);
wsClient.onError = () => setIsConnected(false);
try {
await wsClient.connect(url);
setIsConnected(true);
} catch (err) {
setIsConnected(false);
throw err;
}
}, []);
const disconnect = useCallback((): void => {
wsClient.disconnect();
setIsConnected(false);
}, []);
useEffect(() => {
return () => {
wsClient.disconnect();
wsClient.clearCallbacks();
wsClient.onClose = null;
wsClient.onError = null;
};
}, []);
return (
<WsContext.Provider
value={{ client: wsClient, isConnected, connect, disconnect }}
>
{children}
</WsContext.Provider>
);
};

View file

@ -1,4 +1,3 @@
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import "./index.css";
@ -20,9 +19,5 @@ declare module "@tanstack/react-router" {
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
root.render(<RouterProvider router={router} />);
}

View file

@ -10,15 +10,24 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play'
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
const PlayRoute = PlayRouteImport.update({
id: '/play',
path: '/play',
getParentRoute: () => rootRouteImport,
} as any)
const MultiplayerRoute = MultiplayerRouteImport.update({
id: '/multiplayer',
path: '/multiplayer',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@ -34,38 +43,89 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const MultiplayerIndexRoute = MultiplayerIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => MultiplayerRoute,
} as any)
const MultiplayerLobbyCodeRoute = MultiplayerLobbyCodeRouteImport.update({
id: '/lobby/$code',
path: '/lobby/$code',
getParentRoute: () => MultiplayerRoute,
} as any)
const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
id: '/game/$code',
path: '/game/$code',
getParentRoute: () => MultiplayerRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
'/multiplayer': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/login' | '/play'
fullPaths:
| '/'
| '/about'
| '/login'
| '/multiplayer'
| '/play'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/login' | '/play'
to:
| '/'
| '/about'
| '/login'
| '/play'
| '/multiplayer'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
id:
| '__root__'
| '/'
| '/about'
| '/login'
| '/multiplayer'
| '/play'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
MultiplayerRoute: typeof MultiplayerRouteWithChildren
PlayRoute: typeof PlayRoute
}
@ -78,6 +138,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/multiplayer': {
id: '/multiplayer'
path: '/multiplayer'
fullPath: '/multiplayer'
preLoaderRoute: typeof MultiplayerRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@ -99,13 +166,51 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/multiplayer/': {
id: '/multiplayer/'
path: '/'
fullPath: '/multiplayer/'
preLoaderRoute: typeof MultiplayerIndexRouteImport
parentRoute: typeof MultiplayerRoute
}
'/multiplayer/lobby/$code': {
id: '/multiplayer/lobby/$code'
path: '/lobby/$code'
fullPath: '/multiplayer/lobby/$code'
preLoaderRoute: typeof MultiplayerLobbyCodeRouteImport
parentRoute: typeof MultiplayerRoute
}
'/multiplayer/game/$code': {
id: '/multiplayer/game/$code'
path: '/game/$code'
fullPath: '/multiplayer/game/$code'
preLoaderRoute: typeof MultiplayerGameCodeRouteImport
parentRoute: typeof MultiplayerRoute
}
}
}
interface MultiplayerRouteChildren {
MultiplayerIndexRoute: typeof MultiplayerIndexRoute
MultiplayerGameCodeRoute: typeof MultiplayerGameCodeRoute
MultiplayerLobbyCodeRoute: typeof MultiplayerLobbyCodeRoute
}
const MultiplayerRouteChildren: MultiplayerRouteChildren = {
MultiplayerIndexRoute: MultiplayerIndexRoute,
MultiplayerGameCodeRoute: MultiplayerGameCodeRoute,
MultiplayerLobbyCodeRoute: MultiplayerLobbyCodeRoute,
}
const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
MultiplayerRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
MultiplayerRoute: MultiplayerRouteWithChildren,
PlayRoute: PlayRoute,
}
export const routeTree = rootRouteImport

View file

@ -17,16 +17,24 @@ const RootLayout = () => {
<Link to="/" className="[&.active]:font-bold">
Home
</Link>
<Link to="/about" className="[&.active]:font-bold">
About
<Link to="/play" className="[&.active]:font-bold">
Play
</Link>
<Link to="/multiplayer" className="[&.active]:font-bold">
Multiplayer
</Link>
<div className="ml-auto">
{session ? (
<button
className="text-sm text-gray-600 hover:text-gray-900"
onClick={async () => {
await signOut();
navigate({ to: "/" });
onClick={() => {
void signOut()
.then(() => {
void navigate({ to: "/" });
})
.catch((err) => {
console.error("Sign out error:", err);
});
}}
>
Sign out ({session.user.name})

View file

@ -8,7 +8,7 @@ const LoginPage = () => {
if (isPending) return <div className="p-4">Loading...</div>;
if (session) {
navigate({ to: "/" });
void navigate({ to: "/" });
return null;
}
@ -17,23 +17,25 @@ const LoginPage = () => {
<h1 className="text-2xl font-bold">sign in to lila</h1>
<button
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
onClick={() =>
signIn.social({
provider: "github",
callbackURL: window.location.origin,
})
}
onClick={() => {
void signIn
.social({ provider: "github", callbackURL: window.location.origin })
.catch((err) => {
console.error("GitHub sign in error:", err);
});
}}
>
Continue with GitHub
</button>
<button
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
onClick={() =>
signIn.social({
provider: "google",
callbackURL: window.location.origin,
})
}
onClick={() => {
void signIn
.social({ provider: "google", callbackURL: window.location.origin })
.catch((err) => {
console.error("Google sign in error:", err);
});
}}
>
Continue with Google
</button>

View file

@ -0,0 +1,43 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { authClient } from "../lib/auth-client.js";
import { WsProvider } from "../lib/ws-provider.js";
import { useWsConnect } from "../lib/ws-hooks.js";
const wsBaseUrl =
(import.meta.env["VITE_WS_URL"] as string) ||
(import.meta.env["VITE_API_URL"] as string) ||
"";
export const Route = createFileRoute("/multiplayer")({
component: MultiplayerLayout,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
return { session };
},
});
function WsConnector() {
const connect = useWsConnect();
useEffect(() => {
void connect(wsBaseUrl).catch((err) => {
console.error("WebSocket connection failed:", err);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
function MultiplayerLayout() {
return (
<WsProvider>
<WsConnector />
<Outlet />
</WsProvider>
);
}

View file

@ -0,0 +1,188 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react";
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
import { QuestionCard } from "../../components/game/QuestionCard.js";
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
import { GameRouteSearchSchema } from "@lila/shared";
import type {
WsGameQuestion,
WsGameAnswerResult,
WsGameFinished,
WsError,
} from "@lila/shared";
export const Route = createFileRoute("/multiplayer/game/$code")({
component: GamePage,
validateSearch: GameRouteSearchSchema,
});
function GamePage() {
const { code } = Route.useParams();
const { lobbyId } = Route.useSearch();
const { session } = Route.useRouteContext();
const currentUserId = session.user.id;
const client = useWsClient();
const isConnected = useWsConnected();
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
null,
);
const [answerResult, setAnswerResult] = useState<WsGameAnswerResult | null>(
null,
);
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
const [error, setError] = useState<string | null>(null);
const [hasAnswered, setHasAnswered] = useState(false);
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
setCurrentQuestion(msg);
setAnswerResult(null);
setHasAnswered(false);
setError(null);
}, []);
const handleAnswerResult = useCallback((msg: WsGameAnswerResult) => {
setAnswerResult(msg);
}, []);
const handleGameFinished = useCallback((msg: WsGameFinished) => {
setGameFinished(msg);
}, []);
const handleWsError = useCallback((msg: WsError) => {
setError(msg.message);
}, []);
useEffect(() => {
if (!isConnected) return;
client.on("game:question", handleGameQuestion);
client.on("game:answer_result", handleAnswerResult);
client.on("game:finished", handleGameFinished);
client.on("error", handleWsError);
client.send({ type: "game:ready", lobbyId });
return () => {
client.off("game:question", handleGameQuestion);
client.off("game:answer_result", handleAnswerResult);
client.off("game:finished", handleGameFinished);
client.off("error", handleWsError);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected]);
const handleAnswer = useCallback(
(optionId: number) => {
if (hasAnswered || !currentQuestion) return;
setHasAnswered(true);
client.send({
type: "game:answer",
lobbyId,
questionId: currentQuestion.question.questionId,
selectedOptionId: optionId,
});
},
[hasAnswered, currentQuestion, client, lobbyId],
);
// Phase: finished
if (gameFinished) {
return (
<MultiplayerScoreScreen
players={gameFinished.players}
winnerIds={gameFinished.winnerIds}
currentUserId={currentUserId}
lobbyCode={code}
/>
);
}
// Phase: loading
if (!isConnected || !currentQuestion) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">
{error ?? (isConnected ? "Loading game..." : "Connecting...")}
</p>
</div>
);
}
// Phase: playing
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
{/* Progress */}
<p className="text-sm text-gray-500 text-center">
Question {currentQuestion.questionNumber} of{" "}
{currentQuestion.totalQuestions}
</p>
{/* Question */}
<QuestionCard
question={currentQuestion.question}
questionNumber={currentQuestion.questionNumber}
totalQuestions={currentQuestion.totalQuestions}
currentResult={
answerResult
? {
questionId: currentQuestion.question.questionId,
isCorrect:
answerResult.results.find((r) => r.userId === currentUserId)
?.isCorrect ?? false,
correctOptionId: answerResult.correctOptionId,
selectedOptionId:
answerResult.results.find((r) => r.userId === currentUserId)
?.selectedOptionId ?? 0,
}
: null
}
onAnswer={handleAnswer}
onNext={() => {
setAnswerResult(null);
}}
/>
{/* Error */}
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
{/* Round results */}
{answerResult && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-gray-700">
Round results
</h3>
{answerResult.players.map((player) => {
const result = answerResult.results.find(
(r) => r.userId === player.userId,
);
return (
<div
key={player.userId}
className="flex items-center justify-between text-sm"
>
<span className="text-gray-700">{player.user.name}</span>
<span
className={
result?.isCorrect
? "text-green-600 font-medium"
: "text-red-500"
}
>
{result?.selectedOptionId === null
? "Timed out"
: result?.isCorrect
? "✓ Correct"
: "✗ Wrong"}
</span>
<span className="text-gray-500">{player.score} pts</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import type { Lobby } from "@lila/shared";
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
type LobbySuccessResponse = { success: true; data: Lobby };
type LobbyErrorResponse = { success: false; error: string };
type LobbyApiResponse = LobbySuccessResponse | LobbyErrorResponse;
export const Route = createFileRoute("/multiplayer/")({
component: MultiplayerPage,
});
function MultiplayerPage() {
const navigate = useNavigate();
const [joinCode, setJoinCode] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [isJoining, setIsJoining] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async (): Promise<void> => {
setIsCreating(true);
setError(null);
try {
const response = await fetch(`${API_URL}/api/v1/lobbies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
});
const data = (await response.json()) as LobbyApiResponse;
if (!data.success) {
setError(data.error);
return;
}
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: data.data.code },
});
} catch {
setError("Could not connect to server. Please try again.");
} finally {
setIsCreating(false);
}
};
const handleJoin = async (): Promise<void> => {
const code = joinCode.trim().toUpperCase();
if (!code) {
setError("Please enter a lobby code.");
return;
}
setIsJoining(true);
setError(null);
try {
const response = await fetch(`${API_URL}/api/v1/lobbies/${code}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
});
const data = (await response.json()) as LobbyApiResponse;
if (!data.success) {
setError(data.error);
return;
}
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: data.data.code },
});
} catch {
setError("Could not connect to server. Please try again.");
} finally {
setIsJoining(false);
}
};
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
<h1 className="text-2xl font-bold text-center text-purple-800">
Multiplayer
</h1>
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
{/* Create lobby */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-gray-700">
Create a lobby
</h2>
<p className="text-sm text-gray-500">
Start a new game and invite friends with a code.
</p>
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
onClick={() => {
void handleCreate().catch((err) => {
console.error("Create lobby error:", err);
});
}}
disabled={isCreating || isJoining}
>
{isCreating ? "Creating..." : "Create Lobby"}
</button>
</div>
<div className="border-t border-gray-200" />
{/* Join lobby */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
<p className="text-sm text-gray-500">
Enter the code shared by your host.
</p>
<input
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
placeholder="Enter code (e.g. WOLF42)"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleJoin().catch((err) => {
console.error("Join lobby error:", err);
});
}
}}
maxLength={10}
disabled={isCreating || isJoining}
/>
<button
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
onClick={() => {
void handleJoin().catch((err) => {
console.error("Join lobby error:", err);
});
}}
disabled={isCreating || isJoining || !joinCode.trim()}
>
{isJoining ? "Joining..." : "Join Lobby"}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,159 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState, useCallback, useRef } from "react";
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
import type {
Lobby,
WsLobbyState,
WsError,
WsGameQuestion,
} from "@lila/shared";
export const Route = createFileRoute("/multiplayer/lobby/$code")({
component: LobbyPage,
});
function LobbyPage() {
const { code } = Route.useParams();
const { session } = Route.useRouteContext();
const currentUserId = session.user.id;
const navigate = useNavigate();
const client = useWsClient();
const isConnected = useWsConnected();
const [lobby, setLobby] = useState<Lobby | null>(null);
const [error, setError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const lobbyIdRef = useRef<string | null>(null);
const handleLobbyState = useCallback((msg: WsLobbyState) => {
setLobby(msg.lobby);
lobbyIdRef.current = msg.lobby.id;
setError(null);
}, []);
const handleGameQuestion = useCallback(
(_msg: WsGameQuestion) => {
void navigate({
to: "/multiplayer/game/$code",
params: { code },
search: { lobbyId: lobbyIdRef.current ?? "" },
});
},
[navigate, code],
);
const handleWsError = useCallback((msg: WsError) => {
setError(msg.message);
setIsStarting(false);
}, []);
useEffect(() => {
if (!isConnected) return;
client.on("lobby:state", handleLobbyState);
client.on("game:question", handleGameQuestion);
client.on("error", handleWsError);
client.send({ type: "lobby:join", code });
return () => {
client.off("lobby:state", handleLobbyState);
client.off("game:question", handleGameQuestion);
client.off("error", handleWsError);
if (lobbyIdRef.current) {
client.send({ type: "lobby:leave", lobbyId: lobbyIdRef.current });
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected]);
const handleStart = useCallback(() => {
if (!lobby) return;
setIsStarting(true);
client.send({ type: "lobby:start", lobbyId: lobby.id });
}, [lobby, client]);
if (!isConnected || !lobby) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">
{error ?? (isConnected ? "Joining lobby..." : "Connecting...")}
</p>
</div>
);
}
const isHost = lobby.hostUserId === currentUserId;
const canStart = isHost && lobby.players.length >= 2 && !isStarting;
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
{/* Lobby code */}
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-gray-500">Lobby code</p>
<button
className="text-4xl font-bold tracking-widest text-purple-800 hover:text-purple-600 cursor-pointer"
onClick={() => {
void navigator.clipboard.writeText(code);
}}
title="Click to copy"
>
{code}
</button>
<p className="text-xs text-gray-400">Click to copy</p>
</div>
<div className="border-t border-gray-200" />
{/* Player list */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-gray-700">
Players ({lobby.players.length})
</h2>
<ul className="flex flex-col gap-1">
{lobby.players.map((player) => (
<li
key={player.userId}
className="flex items-center gap-2 text-sm text-gray-700"
>
<span className="w-2 h-2 rounded-full bg-green-400" />
{player.user.name}
{player.userId === lobby.hostUserId && (
<span className="text-xs text-purple-500 font-medium">
host
</span>
)}
</li>
))}
</ul>
</div>
{/* Error */}
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
{/* Start button — host only */}
{isHost && (
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
onClick={handleStart}
disabled={!canStart}
>
{isStarting
? "Starting..."
: lobby.players.length < 2
? "Waiting for players..."
: "Start Game"}
</button>
)}
{/* Non-host waiting message */}
{!isHost && (
<p className="text-sm text-gray-500 text-center">
Waiting for host to start the game...
</p>
)}
</div>
</div>
);
}

View file

@ -6,9 +6,12 @@ import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
function Play() {
const API_URL = import.meta.env["VITE_API_URL"] || "";
type GameStartResponse = { success: true; data: GameSession };
type GameAnswerResponse = { success: true; data: AnswerResult };
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@ -17,13 +20,15 @@ function Play() {
const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true);
const response = await fetch(`${API_URL}/api/v1/game/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(settings),
});
const data = await response.json();
const data = (await response.json()) as GameStartResponse;
setGameSession(data.data);
setCurrentQuestionIndex(0);
setResults([]);
@ -55,7 +60,7 @@ function Play() {
selectedOptionId: optionId,
}),
});
const data = await response.json();
const data = (await response.json()) as GameAnswerResponse;
setCurrentResult(data.data);
};
@ -70,7 +75,13 @@ function Play() {
if (!gameSession && !isLoading) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<GameSetup onStart={startGame} />
<GameSetup
onStart={(settings) => {
void startGame(settings).catch((err) => {
console.error("Start game error:", err);
});
}}
/>
</div>
);
}
@ -99,11 +110,15 @@ function Play() {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<QuestionCard
onAnswer={(optionId) => {
void handleAnswer(optionId).catch((err) => {
console.error("Answer error:", err);
});
}}
question={question}
questionNumber={currentQuestionIndex + 1}
totalQuestions={gameSession.questions.length}
currentResult={currentResult}
onAnswer={handleAnswer}
onNext={handleNext}
/>
</div>

View file

@ -10,5 +10,22 @@ export default defineConfig({
react(),
tailwindcss(),
],
server: { proxy: { "/api": "http://localhost:3000" } },
server: {
proxy: {
"/ws": {
target: "http://localhost:3000",
ws: true,
rewriteWsOrigin: true,
configure: (proxy) => {
proxy.on("error", (err) => {
console.log("[ws proxy error]", err.message);
});
proxy.on("proxyReqWs", (_proxyReq, req) => {
console.log("[ws proxy] forwarding", req.url);
});
},
},
"/api": { target: "http://localhost:3000", changeOrigin: true },
},
},
});

View file

@ -1,91 +0,0 @@
services:
caddy:
container_name: lila-caddy
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks:
- lila-network
api:
container_name: lila-api
build:
context: .
dockerfile: ./apps/api/Dockerfile
target: runner
env_file:
- .env
restart: unless-stopped
healthcheck:
test:
["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
interval: 5s
timeout: 3s
retries: 5
depends_on:
database:
condition: service_healthy
networks:
- lila-network
web:
container_name: lila-web
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: production
args:
VITE_API_URL: https://api.lilastudy.com
restart: unless-stopped
networks:
- lila-network
database:
container_name: lila-database
image: postgres:18.3-alpine3.23
env_file:
- .env
environment:
- PGDATA=/var/lib/postgresql/data
volumes:
- lila-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- lila-network
forgejo:
container_name: lila-forgejo
image: codeberg.org/forgejo/forgejo:11
volumes:
- forgejo-data:/data
environment:
- USER_UID=1000
- USER_GID=1000
ports:
- "2222:22"
restart: unless-stopped
networks:
- lila-network
networks:
lila-network:
volumes:
lila-db:
caddy_data:
caddy_config:
forgejo-data:

View file

@ -42,11 +42,12 @@ services:
volumes:
- ./apps/api:/app/apps/api # Hot reload API code
- ./packages/shared:/app/packages/shared # Hot reload shared
- ./packages/db:/app/packages/db
- /app/node_modules
restart: unless-stopped
healthcheck:
test:
["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/health || exit 1"]
interval: 5s
timeout: 3s
retries: 5
@ -66,6 +67,7 @@ services:
- "5173:5173"
volumes:
- ./apps/web:/app/apps/web # Hot reload: local edits reflect immediately
- ./packages/shared:/app/packages/shared
- /app/node_modules # Protect container's node_modules from being overwritten
environment:
- VITE_API_URL=http://localhost:3000

View file

@ -12,19 +12,19 @@ This document describes the production deployment of the lila vocabulary trainer
### Subdomain Routing
| Subdomain | Service | Container port |
|---|---|---|
| `lilastudy.com` | Frontend (nginx serving static files) | 80 |
| `api.lilastudy.com` | Express API | 3000 |
| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 |
| Subdomain | Service | Container port |
| ------------------- | ------------------------------------- | -------------- |
| `lilastudy.com` | Frontend (nginx serving static files) | 80 |
| `api.lilastudy.com` | Express API | 3000 |
| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 |
### Ports Exposed to the Internet
| Port | Service |
|---|---|
| 80 | Caddy (HTTP, redirects to HTTPS) |
| 443 | Caddy (HTTPS) |
| 2222 | Forgejo SSH (git clone/push) |
| Port | Service |
| ---- | -------------------------------- |
| 80 | Caddy (HTTP, redirects to HTTPS) |
| 443 | Caddy (HTTPS) |
| 2222 | Forgejo SSH (git clone/push) |
All other services (Postgres, API, frontend) communicate only over the internal Docker network.

View file

@ -0,0 +1,83 @@
# Game Modes
This document describes the planned game modes for lila. Each mode uses the same lobby system and vocabulary data but differs in how answers are submitted, scored, and how a winner is determined.
The first multiplayer mode to implement is TBD. The lobby infrastructure (create, join, WebSocket connection) is mode-agnostic — adding a new mode means adding new game logic, not changing the lobby.
---
## TV Quiz Show
**Type:** Multiplayer
**Answer model:** Buzzer — first to press gets to answer
**Rounds:** Fixed (e.g. 10)
A question appears for all players. The first player to buzz in gets to answer. If correct, they score a point. If wrong, other players may get a chance to answer (TBD: whether the question passes to the next buzzer or the round ends). The host or a timer controls the pace.
Key difference from other modes: only one player answers per question. Speed of reaction matters as much as knowledge.
---
## Race to the Top
**Type:** Multiplayer
**Answer model:** Simultaneous — all players answer independently
**Rounds:** None — play until target score reached
All players see the same question and answer independently. No fixed round count. The first player to reach a target number of correct answers wins (e.g. 20). Fast-paced and competitive.
Open questions: what happens if two players hit the target on the same question? Tiebreaker by speed? Shared win?
---
## Chain Link
**Type:** Multiplayer
**Answer model:** Turn-based — one player at a time, in rotation
**Rounds:** None — play until a player fails
Players answer in a fixed rotation: Player 1, Player 2, Player 3, then back to Player 1. Each player gets one question per turn. The game continues until a player answers incorrectly — that player is out (or the game ends). Last correct answerer wins, or the game simply ends on the first wrong answer.
Key difference from other modes: turn-based, not simultaneous. Pressure builds as you wait for your turn.
Open questions: does the player who answers wrong lose, or does the game just end? If the game continues, does it become elimination?
---
## Elimination Round
**Type:** Multiplayer
**Answer model:** Simultaneous — all players answer independently
**Rounds:** Continue until one player remains
All players see the same question and answer simultaneously. Players who answer incorrectly are eliminated. Rounds continue until only one player is left standing.
Open questions: what if everyone gets it wrong in the same round? Reset that round? Eliminate nobody? What if it comes down to two players and both get it wrong repeatedly?
---
## Cooperative Challenge
**Type:** Multiplayer
**Answer model:** TBD
**Rounds:** TBD
Players work together rather than competing. Concept not yet defined. Possible ideas: shared team score with a target, each player contributes answers to a collective pool, or players take turns and the team survives as long as the chain doesn't break.
---
## Single Player Extended
**Type:** Singleplayer
**Answer model:** TBD
**Rounds:** TBD
An expanded version of the current singleplayer quiz. Concept not yet defined. Possible ideas: longer sessions with increasing difficulty, mixed POS/language rounds, streak bonuses, progress tracking across sessions, or timed challenge mode.
---
## Schema Impact
The `lobbies` table includes a `game_mode` column (varchar) with values like `tv_quiz`, `race_to_top`, `chain_link`, `elimination`. Mode-specific settings (e.g. target score for Race to the Top) can be stored in a `settings` jsonb column if needed.
The singleplayer modes (Single Player Extended) don't require a lobby — they extend the existing singleplayer flow.

View file

@ -21,9 +21,14 @@ WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/c
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/
### docker containers on startup?
laptop: verify if docker containers run on startup (they shouldnt)
### vps setup
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
<<<<<<< HEAD
- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy
### ~~cd/ci pipeline~~ ✅ RESOLVED
@ -33,6 +38,8 @@ Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `de
### ~~postgres backups~~ ✅ RESOLVED
Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`.
=======
>>>>>>> dev
### try now option

View file

@ -290,6 +290,7 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
## 11. Post-MVP Ladder
<<<<<<< HEAD
| Phase | What it adds | Status |
| ----------------- | ------------------------------------------------------------------------------- | ------ |
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
@ -299,6 +300,17 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
=======
| Phase | What it adds | Status |
| ------------------- | ----------------------------------------------------------------------- | ------ |
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
| User Stats | Games played, score history, profile page | ❌ |
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
>>>>>>> dev
### Future Data Model Extensions (deferred, additive)

View file

@ -13,6 +13,7 @@ export default defineConfig([
"eslint.config.mjs",
"**/*.config.ts",
"routeTree.gen.ts",
"scripts/**",
]),
eslint.configs.recommended,
@ -38,6 +39,27 @@ export default defineConfig([
},
{
files: ["apps/web/src/routes/**/*.{ts,tsx}"],
rules: { "react-refresh/only-export-components": "off" },
rules: {
"react-refresh/only-export-components": "off",
"@typescript-eslint/only-throw-error": "off",
},
},
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
{
// better-auth's createAuthClient return type is insufficiently typed upstream.
// This is a known issue: https://github.com/better-auth/better-auth/issues
files: ["apps/web/src/lib/auth-client.ts"],
rules: { "@typescript-eslint/no-unsafe-assignment": "off" },
},
]);

View file

@ -0,0 +1,21 @@
CREATE TABLE "lobbies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(10) NOT NULL,
"host_user_id" text NOT NULL,
"status" varchar(20) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lobbies_code_unique" UNIQUE("code"),
CONSTRAINT "lobby_status_check" CHECK ("lobbies"."status" IN ('waiting', 'in_progress', 'finished'))
);
--> statement-breakpoint
CREATE TABLE "lobby_players" (
"lobby_id" uuid NOT NULL,
"user_id" text NOT NULL,
"score" integer DEFAULT 0 NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lobby_players_lobby_id_user_id_pk" PRIMARY KEY("lobby_id","user_id")
);
--> statement-breakpoint
ALTER TABLE "lobbies" ADD CONSTRAINT "lobbies_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_lobby_id_lobbies_id_fk" FOREIGN KEY ("lobby_id") REFERENCES "public"."lobbies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -110,12 +110,8 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -149,12 +145,8 @@
"name": "deck_terms_deck_id_decks_id_fk",
"tableFrom": "deck_terms",
"tableTo": "decks",
"columnsFrom": [
"deck_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@ -162,12 +154,8 @@
"name": "deck_terms_term_id_terms_id_fk",
"tableFrom": "deck_terms",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -175,10 +163,7 @@
"compositePrimaryKeys": {
"deck_terms_deck_id_term_id_pk": {
"name": "deck_terms_deck_id_term_id_pk",
"columns": [
"deck_id",
"term_id"
]
"columns": ["deck_id", "term_id"]
}
},
"uniqueConstraints": {},
@ -265,10 +250,7 @@
"unique_deck_name": {
"name": "unique_deck_name",
"nullsNotDistinct": false,
"columns": [
"name",
"source_language"
]
"columns": ["name", "source_language"]
}
},
"policies": {},
@ -368,12 +350,8 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -383,9 +361,7 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@ -435,12 +411,8 @@
"name": "term_glosses_term_id_terms_id_fk",
"tableFrom": "term_glosses",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -450,10 +422,7 @@
"unique_term_gloss": {
"name": "unique_term_gloss",
"nullsNotDistinct": false,
"columns": [
"term_id",
"language_code"
]
"columns": ["term_id", "language_code"]
}
},
"policies": {},
@ -488,12 +457,8 @@
"name": "term_topics_term_id_terms_id_fk",
"tableFrom": "term_topics",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@ -501,12 +466,8 @@
"name": "term_topics_topic_id_topics_id_fk",
"tableFrom": "term_topics",
"tableTo": "topics",
"columnsFrom": [
"topic_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["topic_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -514,10 +475,7 @@
"compositePrimaryKeys": {
"term_topics_term_id_topic_id_pk": {
"name": "term_topics_term_id_topic_id_pk",
"columns": [
"term_id",
"topic_id"
]
"columns": ["term_id", "topic_id"]
}
},
"uniqueConstraints": {},
@ -591,10 +549,7 @@
"unique_source_id": {
"name": "unique_source_id",
"nullsNotDistinct": false,
"columns": [
"source",
"source_id"
]
"columns": ["source", "source_id"]
}
},
"policies": {},
@ -650,9 +605,7 @@
"topics_slug_unique": {
"name": "topics_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
"columns": ["slug"]
}
},
"policies": {},
@ -748,12 +701,8 @@
"name": "translations_term_id_terms_id_fk",
"tableFrom": "translations",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -763,11 +712,7 @@
"unique_translations": {
"name": "unique_translations",
"nullsNotDistinct": false,
"columns": [
"term_id",
"language_code",
"text"
]
"columns": ["term_id", "language_code", "text"]
}
},
"policies": {},
@ -844,9 +789,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -927,9 +870,5 @@
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}

File diff suppressed because it is too large Load diff

View file

@ -43,6 +43,13 @@
"when": 1776154563168,
"tag": "0005_broad_mariko_yashida",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1776270391189,
"tag": "0006_certain_adam_destine",
"breakpoints": true
}
]
}
}

View file

@ -9,6 +9,7 @@ import {
primaryKey,
index,
boolean,
integer,
} from "drizzle-orm/pg-core";
import { sql, relations } from "drizzle-orm";
@ -19,6 +20,7 @@ import {
CEFR_LEVELS,
SUPPORTED_DECK_TYPES,
DIFFICULTY_LEVELS,
LOBBY_STATUSES,
} from "@lila/shared";
export const terms = pgTable(
@ -252,12 +254,53 @@ export const accountRelations = relations(account, ({ one }) => ({
user: one(user, { fields: [account.userId], references: [user.id] }),
}));
/*
* INTENTIONAL DESIGN DECISIONS see decisions.md for full reasoning
*
* source + source_id (terms): idempotency key per import pipeline
* display_name UNIQUE (users): multiplayer requires distinguishable names
* UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates
* updated_at omitted: misleading without a trigger to maintain it
* FK indexes: all FK columns covered, no sequential scans on joins
*/
export const lobbies = pgTable(
"lobbies",
{
id: uuid().primaryKey().defaultRandom(),
code: varchar({ length: 10 }).notNull().unique(),
hostUserId: text("host_user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: varchar({ length: 20 }).notNull().default("waiting"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
check(
"lobby_status_check",
sql`${table.status} IN (${sql.raw(LOBBY_STATUSES.map((s) => `'${s}'`).join(", "))})`,
),
],
);
export const lobby_players = pgTable(
"lobby_players",
{
lobbyId: uuid("lobby_id")
.notNull()
.references(() => lobbies.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
score: integer().notNull().default(0),
joinedAt: timestamp("joined_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [primaryKey({ columns: [table.lobbyId, table.userId] })],
);
export const lobbyRelations = relations(lobbies, ({ one, many }) => ({
host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }),
players: many(lobby_players),
}));
export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({
lobby: one(lobbies, {
fields: [lobby_players.lobbyId],
references: [lobbies.id],
}),
user: one(user, { fields: [lobby_players.userId], references: [user.id] }),
}));

View file

@ -3,11 +3,13 @@ import { drizzle } from "drizzle-orm/node-postgres";
import { resolve } from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import * as schema from "./db/schema.js";
config({
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
});
export const db = drizzle(process.env["DATABASE_URL"]!);
export const db = drizzle(process.env["DATABASE_URL"]!, { schema });
export * from "./models/termModel.js";
export * from "./models/lobbyModel.js";

View file

@ -0,0 +1,133 @@
import { db } from "@lila/db";
import { lobbies, lobby_players } from "@lila/db/schema";
import { eq, and, sql } from "drizzle-orm";
import type { LobbyStatus } from "@lila/shared";
export type Lobby = typeof lobbies.$inferSelect;
export type LobbyPlayer = typeof lobby_players.$inferSelect;
export type LobbyWithPlayers = Lobby & {
players: (LobbyPlayer & { user: { id: string; name: string } })[];
};
export const createLobby = async (
code: string,
hostUserId: string,
): Promise<Lobby> => {
const [newLobby] = await db
.insert(lobbies)
.values({ code, hostUserId, status: "waiting" })
.returning();
if (!newLobby) {
throw new Error("Failed to create lobby");
}
return newLobby;
};
export const getLobbyByCodeWithPlayers = async (
code: string,
): Promise<LobbyWithPlayers | undefined> => {
return db.query.lobbies.findFirst({
where: eq(lobbies.code, code),
with: {
players: { with: { user: { columns: { id: true, name: true } } } },
},
});
};
export const getLobbyByIdWithPlayers = async (
lobbyId: string,
): Promise<LobbyWithPlayers | undefined> => {
return db.query.lobbies.findFirst({
where: eq(lobbies.id, lobbyId),
with: {
players: { with: { user: { columns: { id: true, name: true } } } },
},
});
};
export const updateLobbyStatus = async (
lobbyId: string,
status: LobbyStatus,
): Promise<void> => {
await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId));
};
export const deleteLobby = async (lobbyId: string): Promise<void> => {
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
};
/**
* Atomically inserts a player into a lobby. Returns the new player row,
* or undefined if the insert was skipped because:
* - the lobby is at capacity, or
* - the lobby is not in 'waiting' status, or
* - the user is already in the lobby (PK conflict).
*
* Callers are expected to pre-check these conditions against a hydrated
* lobby state to produce specific error messages; the undefined return
* is a safety net for concurrent races.
*/
export const addPlayer = async (
lobbyId: string,
userId: string,
maxPlayers: number,
): Promise<LobbyPlayer | undefined> => {
const result = await db.execute(sql`
INSERT INTO lobby_players (lobby_id, user_id)
SELECT ${lobbyId}::uuid, ${userId}
WHERE (
SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid
) < ${maxPlayers}
AND EXISTS (
SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting'
)
ON CONFLICT (lobby_id, user_id) DO NOTHING
`);
if (!result.rowCount) return undefined;
const [player] = await db
.select()
.from(lobby_players)
.where(
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
);
return player;
};
export const removePlayer = async (
lobbyId: string,
userId: string,
): Promise<void> => {
await db
.delete(lobby_players)
.where(
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
);
};
export const finishGame = async (
lobbyId: string,
scoresByUser: Map<string, number>,
): Promise<void> => {
await db.transaction(async (tx) => {
for (const [userId, score] of scoresByUser) {
await tx
.update(lobby_players)
.set({ score })
.where(
and(
eq(lobby_players.lobbyId, lobbyId),
eq(lobby_players.userId, userId),
),
);
}
await tx
.update(lobbies)
.set({ status: "finished" })
.where(eq(lobbies.id, lobbyId));
});
};

View file

@ -13,3 +13,8 @@ export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const;
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
export const LOBBY_STATUSES = ["waiting", "in_progress", "finished"] as const;
export type LobbyStatus = (typeof LOBBY_STATUSES)[number];
export const MAX_LOBBY_PLAYERS = 4;

View file

@ -1,2 +1,3 @@
export * from "./constants.js";
export * from "./schemas/game.js";
export * from "./schemas/lobby.js";

View file

@ -0,0 +1,140 @@
import * as z from "zod";
import { LOBBY_STATUSES } from "../constants.js";
import { GameQuestionSchema } from "./game.js";
export const LobbyPlayerSchema = z.object({
lobbyId: z.uuid(),
userId: z.string(),
score: z.number().int().min(0),
user: z.object({ id: z.string(), name: z.string() }),
});
export type LobbyPlayer = z.infer<typeof LobbyPlayerSchema>;
export const LobbySchema = z.object({
id: z.uuid(),
code: z.string().min(1).max(10),
hostUserId: z.string(),
status: z.enum(LOBBY_STATUSES),
createdAt: z.iso.datetime(),
players: z.array(LobbyPlayerSchema),
});
export type Lobby = z.infer<typeof LobbySchema>;
export const JoinLobbyResponseSchema = LobbySchema;
export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>;
export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() });
export type GameRouteSearch = z.infer<typeof GameRouteSearchSchema>;
// ----------------------------------------------------------------------------
// WebSocket: Client → Server
// ----------------------------------------------------------------------------
export const WsLobbyJoinSchema = z.object({
type: z.literal("lobby:join"),
code: z.string().min(1).max(10),
});
export type WsLobbyJoin = z.infer<typeof WsLobbyJoinSchema>;
export const WsLobbyLeaveSchema = z.object({
type: z.literal("lobby:leave"),
lobbyId: z.uuid(),
});
export type WsLobbyLeave = z.infer<typeof WsLobbyLeaveSchema>;
export const WsLobbyStartSchema = z.object({
type: z.literal("lobby:start"),
lobbyId: z.uuid(),
});
export type WsLobbyStart = z.infer<typeof WsLobbyStartSchema>;
export const WsGameReadySchema = z.object({
type: z.literal("game:ready"),
lobbyId: z.uuid(),
});
export type WsGameReady = z.infer<typeof WsGameReadySchema>;
export const WsGameAnswerSchema = z.object({
type: z.literal("game:answer"),
lobbyId: z.uuid(),
questionId: z.uuid(),
selectedOptionId: z.number().int().min(0).max(3),
});
export type WsGameAnswer = z.infer<typeof WsGameAnswerSchema>;
export const WsClientMessageSchema = z.discriminatedUnion("type", [
WsLobbyJoinSchema,
WsLobbyLeaveSchema,
WsLobbyStartSchema,
WsGameAnswerSchema,
WsGameReadySchema,
]);
export type WsClientMessage = z.infer<typeof WsClientMessageSchema>;
// ----------------------------------------------------------------------------
// WebSocket: Server → Client
// ----------------------------------------------------------------------------
export const WsLobbyStateSchema = z.object({
type: z.literal("lobby:state"),
lobby: LobbySchema,
});
export type WsLobbyState = z.infer<typeof WsLobbyStateSchema>;
export const WsGameQuestionSchema = z.object({
type: z.literal("game:question"),
question: GameQuestionSchema,
questionNumber: z.number().int().min(1),
totalQuestions: z.number().int().min(1),
});
export type WsGameQuestion = z.infer<typeof WsGameQuestionSchema>;
export const WsGameAnswerResultSchema = z.object({
type: z.literal("game:answer_result"),
correctOptionId: z.number().int().min(0).max(3),
results: z.array(
z.object({
userId: z.string(),
selectedOptionId: z.number().int().min(0).max(3).nullable(),
isCorrect: z.boolean(),
}),
),
players: z.array(LobbyPlayerSchema),
});
export type WsGameAnswerResult = z.infer<typeof WsGameAnswerResultSchema>;
export const WsGameFinishedSchema = z.object({
type: z.literal("game:finished"),
players: z.array(LobbyPlayerSchema),
winnerIds: z.array(z.string()),
});
export type WsGameFinished = z.infer<typeof WsGameFinishedSchema>;
export const WsErrorSchema = z.object({
type: z.literal("error"),
code: z.string(),
message: z.string(),
});
export type WsError = z.infer<typeof WsErrorSchema>;
export const WsServerMessageSchema = z.discriminatedUnion("type", [
WsLobbyStateSchema,
WsGameQuestionSchema,
WsGameAnswerResultSchema,
WsGameFinishedSchema,
WsErrorSchema,
]);
export type WsServerMessage = z.infer<typeof WsServerMessageSchema>;

27
pnpm-lock.yaml generated
View file

@ -62,6 +62,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
ws:
specifier: ^8.20.0
version: 8.20.0
devDependencies:
'@types/cors':
specifier: ^2.8.19
@ -72,6 +75,9 @@ importers:
'@types/supertest':
specifier: ^7.2.0
version: 7.2.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
supertest:
specifier: ^7.2.2
version: 7.2.2
@ -1311,6 +1317,9 @@ packages:
'@types/supertest@7.2.0':
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2983,6 +2992,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
@ -3915,6 +3936,10 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.0
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -5586,6 +5611,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.20.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1

280
scripts/create-issues.sh Normal file
View file

@ -0,0 +1,280 @@
#!/bin/bash
# Forgejo batch issue creator for lila
# Usage: FORGEJO_TOKEN=your_token ./create-issues.sh
FORGEJO_URL="https://git.lilastudy.com"
OWNER="forgejo-lila"
REPO="lila"
TOKEN="${FORGEJO_TOKEN:?Set FORGEJO_TOKEN environment variable}"
API="${FORGEJO_URL}/api/v1/repos/${OWNER}/${REPO}"
# Helper: create a label (ignores if already exists)
create_label() {
local name="$1" color="$2" description="$3"
curl -s -X POST "${API}/labels" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${name}\",\"color\":\"${color}\",\"description\":\"${description}\"}" > /dev/null
echo "Label: ${name}"
}
# Helper: create an issue with labels
create_issue() {
local title="$1" body="$2"
shift 2
local labels="$*"
# Build labels JSON array
local label_ids=""
for label in $labels; do
local id
id=$(curl -s "${API}/labels" \
-H "Authorization: token ${TOKEN}" | \
python3 -c "import sys,json; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='${label}']")
if [ -n "$label_ids" ]; then
label_ids="${label_ids},${id}"
else
label_ids="${id}"
fi
done
curl -s -X POST "${API}/issues" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"title\":$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"body\":$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"labels\":[${label_ids}]}" > /dev/null
echo "Issue: ${title}"
}
echo "=== Creating labels ==="
create_label "feature" "#0075ca" "New user-facing functionality"
create_label "infra" "#e4e669" "Infrastructure, deployment, DevOps"
create_label "debt" "#d876e3" "Technical cleanup, refactoring"
create_label "security" "#b60205" "Security improvements"
create_label "ux" "#1d76db" "User experience, accessibility, polish"
create_label "multiplayer" "#0e8a16" "Multiplayer lobby and game features"
echo ""
echo "=== Creating issues ==="
# ── feature ──
create_issue \
"Add guest/try-now option — play without account" \
"Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a 'Try without account' button on the login/landing page." \
feature
create_issue \
"Add Apple login provider" \
"Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration." \
feature
create_issue \
"Add email+password login" \
"Add traditional email and password authentication as an alternative to social login. Configure via Better Auth." \
feature
create_issue \
"User stats endpoint + profile page" \
"Add GET /users/me/stats endpoint returning games played, score history, etc. Build a frontend profile page displaying the stats." \
feature
# ── infra ──
create_issue \
"Google OAuth app verification and publishing" \
"Currently only test users can log in via Google. Publish the OAuth consent screen so any Google user can sign in. Requires branding verification through Google Cloud Console." \
infra
create_issue \
"Set up Docker credential helper on dev laptop" \
"Docker credentials are stored unencrypted in ~/.docker/config.json. Set up a credential helper to store them securely. See https://docs.docker.com/go/credential-store/" \
infra
create_issue \
"VPS monitoring and logging" \
"Set up monitoring and centralized logging on the VPS. Options: chkrootkit/rkhunter for security, logwatch/monit for daily summaries, uptime monitoring for service health." \
infra
create_issue \
"Move to offsite backup storage" \
"Currently database backups live on the same VPS. Add offsite copies to Hetzner Object Storage or similar S3-compatible service to protect against VPS failure." \
infra
create_issue \
"Replace in-memory game session store with Valkey" \
"Add Valkey container to the production Docker stack. Implement ValkeyGameSessionStore using the existing GameSessionStore interface. Required before multiplayer." \
infra
create_issue \
"Modern env management approach" \
"Evaluate replacing .env files with a more robust approach (e.g. dotenvx, infisical, or similar). Current setup works but .env files are error-prone and not versioned." \
infra
create_issue \
"Pin dependencies in package.json files" \
"Pin all dependency versions in package.json files to exact versions to prevent unexpected updates from breaking builds." \
infra
# ── debt ──
create_issue \
"Rethink organization of datafiles and wordlists" \
"The current layout of data-sources/, scripts/datafiles/, scripts/data-sources/, and packages/db/src/data/ is confusing with overlapping content. Consolidate into a clear structure." \
debt
create_issue \
"Resolve eslint peer dependency warning" \
"eslint-plugin-react-hooks 7.0.1 expects eslint ^3.0.0-^9.0.0 but found 10.0.3. Resolve the peer dependency mismatch." \
debt
# ── security ──
create_issue \
"Rate limiting on API endpoints" \
"Add rate limiting to prevent abuse. At minimum: auth endpoints (brute force prevention), game endpoints (spam prevention). Consider express-rate-limit or similar." \
security
# ── ux ──
create_issue \
"404/redirect handling for unknown routes and subdomains" \
"Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider Caddy fallback for unrecognized subdomains." \
ux
create_issue \
"React error boundaries" \
"Add error boundaries to catch and display runtime errors gracefully instead of crashing the entire app." \
ux
create_issue \
"Accessibility pass" \
"Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow." \
ux
create_issue \
"Favicon, page titles, Open Graph meta" \
"Add favicon, set proper page titles per route, add Open Graph meta tags for link previews when sharing." \
ux
# ── multiplayer ──
create_issue \
"Drizzle schema: lobbies, lobby_players + migration" \
"Create lobbies table (id, code, host_user_id, status, is_private, game_mode, settings, created_at) and lobby_players table (lobby_id, user_id, score, joined_at). Run migration. See game-modes.md for game_mode values." \
multiplayer
create_issue \
"REST endpoints: POST /lobbies, POST /lobbies/:code/join" \
"Create lobby (generates short code, sets host) and join lobby (validates code, adds player, enforces max limit)." \
multiplayer
create_issue \
"LobbyService: create lobby, join lobby, enforce player limit" \
"Service layer for lobby management. Generate human-readable codes, validate join requests, track lobby state. Public lobbies are browsable, private lobbies require code." \
multiplayer
create_issue \
"WebSocket server: attach ws upgrade to Express" \
"Attach ws library upgrade handler to the existing Express HTTP server. Handle connection lifecycle." \
multiplayer
create_issue \
"WS auth middleware: validate session on upgrade" \
"Validate Better Auth session on WebSocket upgrade request. Reject unauthenticated connections." \
multiplayer
create_issue \
"WS message router: dispatch by type" \
"Route incoming WebSocket messages by their type field to the appropriate handler. Use Zod discriminated union for type safety." \
multiplayer
create_issue \
"Lobby join/leave handlers + broadcast lobby state" \
"Handle lobby:join and lobby:leave WebSocket events. Broadcast updated player list to all connected players in the lobby." \
multiplayer
create_issue \
"Lobby state in Valkey (ephemeral) + PostgreSQL (durable)" \
"Store live lobby state (connected players, current question, timer) in Valkey. Store durable records (who played, final scores) in PostgreSQL." \
multiplayer
create_issue \
"WS event Zod schemas in packages/shared" \
"Define all WebSocket message types as Zod discriminated unions in packages/shared. Covers lobby events (join, leave, start) and game events (question, answer, result, finished)." \
multiplayer
create_issue \
"Frontend: lobby browser + create/join lobby" \
"Lobby list showing public open lobbies. Create lobby form (game mode, public/private). Join-by-code input for private lobbies." \
multiplayer
create_issue \
"Frontend: lobby view (player list, code, start game)" \
"Show lobby code, connected players, game mode. Host sees Start Game button. Players see waiting state. Real-time updates via WebSocket." \
multiplayer
create_issue \
"Frontend: WS client singleton with reconnect" \
"WebSocket client that maintains a single connection, handles reconnection on disconnect, and dispatches incoming messages to the appropriate state handlers." \
multiplayer
create_issue \
"GameService: question sequence + server timer" \
"Generate question sequence for a lobby game. Enforce per-question timer (e.g. 15s). Timer logic varies by game mode — see game-modes.md." \
multiplayer
create_issue \
"lobby:start WS handler — broadcast first question" \
"When host starts the game, generate questions, change lobby status to in_progress, broadcast first question to all players." \
multiplayer
create_issue \
"game:answer WS handler — collect answers" \
"Receive player answers via WebSocket. Track who has answered. Behavior varies by game mode (simultaneous vs turn-based vs buzzer)." \
multiplayer
create_issue \
"Answer evaluation + broadcast results" \
"On all-answered or timeout: evaluate answers, calculate scores, broadcast game:answer_result to all players. Then send next question or end game." \
multiplayer
create_issue \
"Game finished: broadcast results, update DB" \
"After final round: broadcast game:finished with final scores and winner. Write game results to PostgreSQL (transactional). Change lobby status to finished." \
multiplayer
create_issue \
"Frontend: multiplayer game route" \
"Route for active multiplayer games. Receives questions and results via WebSocket. Reuses QuestionCard and OptionButton components." \
multiplayer
create_issue \
"Frontend: countdown timer component" \
"Visual countdown timer synchronized with server timer. Shows remaining seconds per question." \
multiplayer
create_issue \
"Frontend: ScoreBoard component (live per-player scores)" \
"Displays live scores for all players during a multiplayer game. Updates in real-time via WebSocket." \
multiplayer
create_issue \
"Frontend: GameFinished screen" \
"Winner highlight, final scores, play again option. Returns to lobby on play again." \
multiplayer
create_issue \
"Multiplayer GameService unit tests" \
"Unit tests for round evaluation, scoring, tie-breaking, timeout handling across different game modes." \
multiplayer
create_issue \
"Graceful WS reconnect with exponential back-off" \
"Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if game is still in progress." \
multiplayer
echo ""
echo "=== Done ==="