Compare commits

...

19 commits

Author SHA1 Message Date
lila
8aaafea3fc feat: multiplayer slice — end to end working
WebSocket server:
- WS auth via Better Auth session on upgrade request
- Router with discriminated union dispatch and two-layer error handling
- In-memory connections map with broadcastToLobby
- Lobby handlers: join, leave, start
- Game handlers: answer, resolve round, end game, game:ready for state sync
- Shared game state store (LobbyGameStore interface + InMemory impl)
- Timer map separate from store for Valkey-readiness

REST API:
- POST /api/v1/lobbies — create lobby + add host as first player
- POST /api/v1/lobbies/:code/join — atomic join with capacity/status checks
- getLobbyWithPlayers added to model for id-based lookup

Frontend:
- WsClient class with typed on/off, connect/disconnect, isConnected
- WsProvider owns connection lifecycle (connect/disconnect/isConnected state)
- WsConnector component triggers connection at multiplayer layout mount
- Lobby waiting room: live player list, copyable code, host Start button
- Game view: reuses QuestionCard, game:ready on mount, round results
- MultiplayerScoreScreen: sorted scores, winner highlight, tie handling
- Vite proxy: /ws and /api proxied to localhost:3000 for dev cookie fix

Tests:
- lobbyService.test.ts: create, join, retry, idempotency, full lobby
- auth.test.ts: 401 reject, upgrade success, 500 on error
- router.test.ts: dispatch all message types, error handling
- vitest.config.ts: exclude dist folder

Fixes:
- server.ts: server.listen() instead of app.listen() for WS support
- StrictMode removed from main.tsx (incompatible with WS lifecycle)
- getLobbyWithPlayers(id) added for handleLobbyStart lookup
2026-04-18 23:32:21 +02:00
lila
540155788a fix(api): use server.listen instead of app.listen for WebSocket support
- server.ts: switch from app.listen() to server.listen() so WebSocket
  upgrade handler is on the same server as HTTP requests
- lobbyService: add host as first player on lobby creation
- ws-client: guard against reconnect when already connecting
- ws-provider: skip connect if already connected
2026-04-18 21:57:58 +02:00
lila
974646ebfb feat(web): update navigation with Play and Multiplayer links
- Add Play link to /play
- Add Multiplayer link to /multiplayer
- Remove About link (route kept, just not linked)
- Simplify signOut onClick to .then() chain
2026-04-18 10:59:50 +02:00
lila
f2eb6ce17f feat(web): add multiplayer lobby, game, and score screen routes
- lobby.$code.tsx: waiting room with live player list via lobby:state,
  copyable lobby code, host Start Game button (disabled until 2+ players),
  sends lobby:join on connect, lobby:leave on unmount
- game.$code.tsx: in-game view, sends game:ready on mount to get current
  question, handles game:question/answer_result/finished messages,
  reuses QuestionCard component, shows round results after each answer
- MultiplayerScoreScreen: final score screen sorted by score, highlights
  winner(s) with crown, handles ties via winnerIds array, Play Again
  navigates back to lobby, Leave goes to multiplayer landing
- GameRouteSearchSchema added to shared for typed lobbyId search param
  without requiring Zod in apps/web
2026-04-18 10:33:48 +02:00
lila
d064338145 feat(web): add multiplayer lobby waiting room
- connects WebSocket on mount, sends lobby:join after connection open
- registers handlers for lobby:state, game:question, error messages
- lobby:state updates player list in real time
- game:question navigates to game route (server re-sends via game:ready)
- displays lobby code as copyable button
- host sees Start Game button, disabled until 2+ players connected
- non-host sees waiting message
- cleanup sends lobby:leave and disconnects on unmount
- lobbyIdRef tracks lobby id for reliable cleanup before lobby state arrives
2026-04-18 10:10:25 +02:00
lila
6975384751 feat(api): add game:ready message for client state sync
- WsGameReadySchema added to shared schemas and WsClientMessageSchema
- handleGameReady sends current game:question directly to requesting
  client socket (not broadcast) — foundation for reconnection slice
- router dispatches game:ready to handleGameReady handler
2026-04-18 09:54:31 +02:00
lila
4d4715b4ee feat(web): add multiplayer layout route and landing page
- multiplayer.tsx: layout route wrapping all multiplayer children
  with WsProvider, auth guard via beforeLoad
- multiplayer/index.tsx: create/join landing page
  - POST /api/v1/lobbies to create, navigates to lobby waiting room
  - POST /api/v1/lobbies/:code/join to join, normalizes code to
    uppercase before sending
  - loading states per action, error display, Enter key on join input
  - imports Lobby type from @lila/shared (single source of truth)
2026-04-17 21:33:40 +02:00
lila
9affe339c6 feat(web): add WebSocket client and context infrastructure
- WsClient class: connect/disconnect/send/on/off/isConnected/clearCallbacks
- connect() derives wss:// from https:// automatically, returns Promise<void>
- on/off typed with Extract<WsServerMessage, { type: T }> for precise
  callback narrowing, callbacks stored as Map<string, Set<fn>>
- ws-context.ts: WsContextValue type + WsContext definition
- ws-provider.tsx: WsProvider with module-level wsClient singleton,
  owns connection lifecycle (connect/disconnect/isConnected state)
- ws-hooks.ts: useWsClient, useWsConnected, useWsConnect, useWsDisconnect
2026-04-17 21:12:15 +02:00
lila
d60b0da9df feat(web): add WsClient class for multiplayer WebSocket communication
- connect(apiUrl) derives wss:// from https:// automatically, returns
  Promise<void> resolving on open, rejecting on error
- disconnect() closes connection, no-op if already closed
- isConnected() checks readyState === OPEN
- send(message) typed to WsClientMessage discriminated union
- on/off typed with Extract<WsServerMessage, { type: T }> for
  precise callback narrowing per message type
- callbacks stored as Map<string, Set<fn>> supporting multiple
  listeners per message type
- clearCallbacks() for explicit cleanup on provider unmount
- onError/onClose as separate lifecycle properties distinct
  from message handlers
2026-04-17 20:44:33 +02:00
lila
ce19740cc8 fix(lint): resolve all eslint errors across monorepo
- Type response bodies in gameController.test.ts to fix no-unsafe-member-access
- Replace async methods with Promise.resolve() in InMemoryGameSessionStore
  and InMemoryLobbyGameStore to satisfy require-await rule
- Add argsIgnorePattern and varsIgnorePattern to eslint config so
  underscore-prefixed params are globally ignored
- Fix no-misused-promises in ws/index.ts, lobbyHandlers, gameHandlers,
  __root.tsx, login.tsx and play.tsx by using void + .catch()
- Fix no-floating-promises on navigate calls in login.tsx
- Move API_URL outside Play component to fix useCallback dependency warning
- Type fetch response bodies in play.tsx to fix no-unsafe-assignment
- Add only-throw-error: off for route files (TanStack Router throw redirect)
- Remove unused WebSocket import from express.d.ts
- Fix unsafe return in connections.ts by typing empty Map constructor
- Exclude scripts/ folder from eslint
- Add targeted override for better-auth auth-client.ts (upstream typing issue)
2026-04-17 16:46:33 +02:00
lila
a6d8ddec3b formatting 2026-04-17 15:52:50 +02:00
lila
7f56ad89e6 feat(api): add WebSocket handlers and game state management
- handleLobbyJoin: validates DB membership and waiting status,
  registers connection, tags ws.lobbyId, broadcasts lobby:state
- handleLobbyLeave: host leave deletes lobby, non-host leave
  removes player and broadcasts updated state
- handleLobbyStart: validates host + connected players >= 2,
  generates questions, initializes LobbyGameData, broadcasts
  first game:question, starts 15s round timer
- handleGameAnswer: stores answer, resolves round when all
  players answered or timer fires
- resolveRound: evaluates answers, updates scores, broadcasts
  game:answer_result, advances to next question or ends game
- endGame: persists final scores via finishGame transaction,
  determines winnerIds handling ties, broadcasts game:finished
- gameState.ts: shared lobbyGameStore singleton and timers Map
- LobbyGameData extended with code field to avoid mid-game
  DB lookups by ID
2026-04-17 15:50:08 +02:00
lila
745c5c4e3a feat(api): add WebSocket foundation and multiplayer game store
- Add ws/ directory: server setup, auth, router, connections map
- WebSocket auth rejects upgrade with 401 if no Better Auth session
- Router parses WsClientMessageSchema, dispatches to handlers,
  two-layer error handling (AppError -> WsErrorSchema, unknown -> 500)
- connections.ts: in-memory Map<lobbyId, Map<userId, WebSocket>>
  with addConnection, removeConnection, broadcastToLobby
- LobbyGameStore interface + InMemoryLobbyGameStore implementation
  following existing GameSessionStore pattern
- multiplayerGameService: generateMultiplayerQuestions() decoupled
  from single-player flow, hardcoded defaults en->it nouns easy 3 rounds
- handleLobbyJoin and handleLobbyLeave implemented
- WsErrorSchema added to shared schemas
- server.ts switched to createServer + setupWebSocket
2026-04-17 09:36:16 +02:00
lila
b0aef8cc16 added export for lobby model 2026-04-16 19:52:36 +02:00
lila
93cf14857f added max players 2026-04-16 19:52:08 +02:00
lila
4d1ebe2450 feat(api): add REST endpoints for lobby create and join
- POST /api/v1/lobbies creates a lobby with a Crockford-Base32
  6-char code, retrying on unique violation up to 5 times
- POST /api/v1/lobbies/:code/join validates lobby state then
  calls the model's atomic addPlayer, idempotent for repeat
  joins from the same user
- Routes require authentication via requireAuth
2026-04-16 19:51:38 +02:00
lila
8c241636bf feat(api): attach session to request in requireAuth
- Add Express Request type augmentation for req.session
- requireAuth now sets req.session after session validation,
  so protected handlers can read the user without calling
  getSession again
- Add ConflictError (409) alongside existing AppError subclasses
2026-04-16 19:51:10 +02:00
lila
cf56399a5e feat(db): add lobbies and lobby_players tables + model
- Add lobbies and lobby_players tables with camelCase TS aliases
- Add LOBBY_STATUSES constant in shared
- Add lobbyModel with atomic addPlayer and transactional finishGame
- Enable Drizzle relational query API via { schema } option
2026-04-16 19:08:53 +02:00
lila
47a68c0315 feat(db): add lobbies and lobby_players tables + model 2026-04-16 14:45:45 +02:00
55 changed files with 3884 additions and 192 deletions

View file

@ -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

@ -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

@ -290,15 +290,15 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
## 11. Post-MVP Ladder
| 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 | ❌ |
| 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 | ❌ |
### 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