Compare commits

..

No commits in common. "8aaafea3fc383def5d4f04f83a88177dab741eaf" and "a7be7152cc4ae30367a1c65ee677491cc07cb91d" have entirely different histories.

55 changed files with 192 additions and 3884 deletions

View file

@ -14,14 +14,12 @@
"@lila/shared": "workspace:*",
"better-auth": "^1.6.2",
"cors": "^2.8.6",
"express": "^5.2.1",
"ws": "^8.20.0"
"express": "^5.2.1"
},
"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,52 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
import type { GameSession, AnswerResult } from "@lila/shared";
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()),
}));
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
import { getGameTerms, getDistractors } from "@lila/db";
import { createApp } from "../app.js";
@ -78,48 +33,49 @@ 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(body.success).toBe(true);
expect(body.data.sessionId).toBeDefined();
expect(body.data.questions).toHaveLength(3);
expect(res.body.success).toBe(true);
expect(res.body.data.sessionId).toBeDefined();
expect(res.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(body.success).toBe(false);
expect(body.error).toBeDefined();
expect(res.body.success).toBe(false);
expect(res.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(body.success).toBe(false);
expect(res.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(body.success).toBe(false);
expect(res.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 startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startBody.data;
const question = questions[0]!;
const { sessionId, questions } = startRes.body.data;
const question = questions[0];
const res = await request(app)
.post("/api/v1/game/answer")
@ -128,20 +84,20 @@ describe("POST /api/v1/game/answer", () => {
questionId: question.questionId,
selectedOptionId: 0,
});
const body = res.body as GameAnswerResponse;
expect(res.status).toBe(200);
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);
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);
});
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(body.success).toBe(false);
expect(res.body.success).toBe(false);
});
it("returns 404 when the session does not exist", async () => {
@ -152,18 +108,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(body.success).toBe(false);
expect(body.error).toContain("Game session not found");
expect(res.body.success).toBe(false);
expect(res.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 startBody = startRes.body as GameStartResponse;
const { sessionId } = startBody.data;
const { sessionId } = startRes.body.data;
const res = await request(app)
.post("/api/v1/game/answer")
@ -172,9 +128,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(body.success).toBe(false);
expect(body.error).toContain("Question not found");
expect(res.body.success).toBe(false);
expect(res.body.error).toContain("Question not found");
});
});

View file

@ -1,37 +0,0 @@
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,9 +19,3 @@ export class NotFoundError extends AppError {
super(message, 404);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409);
}
}

View file

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

View file

@ -1,27 +0,0 @@
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

@ -1,18 +0,0 @@
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

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

View file

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

View file

@ -2,10 +2,8 @@ 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

@ -1,14 +0,0 @@
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,14 +1,9 @@
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);
setupWebSocket(server);
server.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});

View file

@ -1,176 +0,0 @@
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

@ -1,72 +0,0 @@
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

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

View file

@ -1,17 +0,0 @@
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

@ -1,106 +0,0 @@
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();
});
});

View file

@ -1,32 +0,0 @@
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

@ -1,44 +0,0 @@
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

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

View file

@ -1,213 +0,0 @@
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

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

View file

@ -1,65 +0,0 @@
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

@ -1,125 +0,0 @@
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"),
);
});
});

View file

@ -1,77 +0,0 @@
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,9 +1,3 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
exclude: ["**/dist/**", "**/node_modules/**"],
},
});
export default defineConfig({ test: { environment: "node", globals: true } });

View file

@ -1,114 +0,0 @@
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

@ -1,135 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,35 +0,0 @@
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

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

View file

@ -10,24 +10,15 @@
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',
@ -43,89 +34,38 @@ 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'
| '/multiplayer'
| '/play'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
fullPaths: '/' | '/about' | '/login' | '/play'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/about'
| '/login'
| '/play'
| '/multiplayer'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
id:
| '__root__'
| '/'
| '/about'
| '/login'
| '/multiplayer'
| '/play'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/login' | '/play'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
MultiplayerRoute: typeof MultiplayerRouteWithChildren
PlayRoute: typeof PlayRoute
}
@ -138,13 +78,6 @@ 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'
@ -166,51 +99,13 @@ 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,24 +17,16 @@ const RootLayout = () => {
<Link to="/" className="[&.active]:font-bold">
Home
</Link>
<Link to="/play" className="[&.active]:font-bold">
Play
</Link>
<Link to="/multiplayer" className="[&.active]:font-bold">
Multiplayer
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
<div className="ml-auto">
{session ? (
<button
className="text-sm text-gray-600 hover:text-gray-900"
onClick={() => {
void signOut()
.then(() => {
void navigate({ to: "/" });
})
.catch((err) => {
console.error("Sign out error:", err);
});
onClick={async () => {
await signOut();
navigate({ to: "/" });
}}
>
Sign out ({session.user.name})

View file

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

View file

@ -1,43 +0,0 @@
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

@ -1,188 +0,0 @@
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

@ -1,145 +0,0 @@
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

@ -1,159 +0,0 @@
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,12 +6,9 @@ import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
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 API_URL = import.meta.env["VITE_API_URL"] || "";
const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@ -20,15 +17,13 @@ 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()) as GameStartResponse;
const data = await response.json();
setGameSession(data.data);
setCurrentQuestionIndex(0);
setResults([]);
@ -60,7 +55,7 @@ function Play() {
selectedOptionId: optionId,
}),
});
const data = (await response.json()) as GameAnswerResponse;
const data = await response.json();
setCurrentResult(data.data);
};
@ -75,13 +70,7 @@ 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={(settings) => {
void startGame(settings).catch((err) => {
console.error("Start game error:", err);
});
}}
/>
<GameSetup onStart={startGame} />
</div>
);
}
@ -110,15 +99,11 @@ 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,22 +10,5 @@ export default defineConfig({
react(),
tailwindcss(),
],
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 },
},
},
server: { proxy: { "/api": "http://localhost:3000" } },
});

View file

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

View file

@ -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,7 +13,6 @@ export default defineConfig([
"eslint.config.mjs",
"**/*.config.ts",
"routeTree.gen.ts",
"scripts/**",
]),
eslint.configs.recommended,
@ -39,27 +38,6 @@ export default defineConfig([
},
{
files: ["apps/web/src/routes/**/*.{ts,tsx}"],
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" },
rules: { "react-refresh/only-export-components": "off" },
},
]);

View file

@ -1,21 +0,0 @@
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,8 +110,12 @@
"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"
}
@ -145,8 +149,12 @@
"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"
},
@ -154,8 +162,12 @@
"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"
}
@ -163,7 +175,10 @@
"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": {},
@ -250,7 +265,10 @@
"unique_deck_name": {
"name": "unique_deck_name",
"nullsNotDistinct": false,
"columns": ["name", "source_language"]
"columns": [
"name",
"source_language"
]
}
},
"policies": {},
@ -350,8 +368,12 @@
"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"
}
@ -361,7 +383,9 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
"columns": [
"token"
]
}
},
"policies": {},
@ -411,8 +435,12 @@
"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"
}
@ -422,7 +450,10 @@
"unique_term_gloss": {
"name": "unique_term_gloss",
"nullsNotDistinct": false,
"columns": ["term_id", "language_code"]
"columns": [
"term_id",
"language_code"
]
}
},
"policies": {},
@ -457,8 +488,12 @@
"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"
},
@ -466,8 +501,12 @@
"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"
}
@ -475,7 +514,10 @@
"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": {},
@ -549,7 +591,10 @@
"unique_source_id": {
"name": "unique_source_id",
"nullsNotDistinct": false,
"columns": ["source", "source_id"]
"columns": [
"source",
"source_id"
]
}
},
"policies": {},
@ -605,7 +650,9 @@
"topics_slug_unique": {
"name": "topics_slug_unique",
"nullsNotDistinct": false,
"columns": ["slug"]
"columns": [
"slug"
]
}
},
"policies": {},
@ -701,8 +748,12 @@
"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"
}
@ -712,7 +763,11 @@
"unique_translations": {
"name": "unique_translations",
"nullsNotDistinct": false,
"columns": ["term_id", "language_code", "text"]
"columns": [
"term_id",
"language_code",
"text"
]
}
},
"policies": {},
@ -789,7 +844,9 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
"columns": [
"email"
]
}
},
"policies": {},
@ -870,5 +927,9 @@
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load diff

View file

@ -43,13 +43,6 @@
"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,7 +9,6 @@ import {
primaryKey,
index,
boolean,
integer,
} from "drizzle-orm/pg-core";
import { sql, relations } from "drizzle-orm";
@ -20,7 +19,6 @@ import {
CEFR_LEVELS,
SUPPORTED_DECK_TYPES,
DIFFICULTY_LEVELS,
LOBBY_STATUSES,
} from "@lila/shared";
export const terms = pgTable(
@ -254,53 +252,12 @@ export const accountRelations = relations(account, ({ one }) => ({
user: one(user, { fields: [account.userId], references: [user.id] }),
}));
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] }),
}));
/*
* 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
*/

View file

@ -3,13 +3,11 @@ 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"]!, { schema });
export const db = drizzle(process.env["DATABASE_URL"]!);
export * from "./models/termModel.js";
export * from "./models/lobbyModel.js";

View file

@ -1,133 +0,0 @@
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,8 +13,3 @@ 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,3 +1,2 @@
export * from "./constants.js";
export * from "./schemas/game.js";
export * from "./schemas/lobby.js";

View file

@ -1,140 +0,0 @@
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,9 +62,6 @@ 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
@ -75,9 +72,6 @@ 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
@ -1317,9 +1311,6 @@ 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}
@ -2992,18 +2983,6 @@ 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'}
@ -3936,10 +3915,6 @@ 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
@ -5611,8 +5586,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.20.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1