diff --git a/apps/api/package.json b/apps/api/package.json index bfd2878..60fd85a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 7c4d563..9746328 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -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 = { success: true; data: T }; -type ErrorResponse = { success: false; error: string }; -type GameStartResponse = SuccessResponse; -type GameAnswerResponse = SuccessResponse; - -vi.mock("@lila/db", async (importOriginal) => { - const actual = await importOriginal(); - 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"); }); }); diff --git a/apps/api/src/controllers/lobbyController.ts b/apps/api/src/controllers/lobbyController.ts deleted file mode 100644 index 113c8c5..0000000 --- a/apps/api/src/controllers/lobbyController.ts +++ /dev/null @@ -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); - } -}; diff --git a/apps/api/src/errors/AppError.ts b/apps/api/src/errors/AppError.ts index 4677d9f..6611b9b 100644 --- a/apps/api/src/errors/AppError.ts +++ b/apps/api/src/errors/AppError.ts @@ -19,9 +19,3 @@ export class NotFoundError extends AppError { super(message, 404); } } - -export class ConflictError extends AppError { - constructor(message: string) { - super(message, 409); - } -} diff --git a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts index f29ca59..d4a339c 100644 --- a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts @@ -3,17 +3,15 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js"; export class InMemoryGameSessionStore implements GameSessionStore { private sessions = new Map(); - create(sessionId: string, data: GameSessionData): Promise { + async create(sessionId: string, data: GameSessionData): Promise { this.sessions.set(sessionId, data); - return Promise.resolve(); } - get(sessionId: string): Promise { - return Promise.resolve(this.sessions.get(sessionId) ?? null); + async get(sessionId: string): Promise { + return this.sessions.get(sessionId) ?? null; } - delete(sessionId: string): Promise { + async delete(sessionId: string): Promise { this.sessions.delete(sessionId); - return Promise.resolve(); } } diff --git a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts deleted file mode 100644 index d3d00f2..0000000 --- a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; - -export class InMemoryLobbyGameStore implements LobbyGameStore { - private games = new Map(); - - create(lobbyId: string, data: LobbyGameData): Promise { - 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 { - return Promise.resolve(this.games.get(lobbyId) ?? null); - } - - set(lobbyId: string, data: LobbyGameData): Promise { - this.games.set(lobbyId, data); - return Promise.resolve(); - } - - delete(lobbyId: string): Promise { - this.games.delete(lobbyId); - return Promise.resolve(); - } -} diff --git a/apps/api/src/lobbyGameStore/LobbyGameStore.ts b/apps/api/src/lobbyGameStore/LobbyGameStore.ts deleted file mode 100644 index bf59c3b..0000000 --- a/apps/api/src/lobbyGameStore/LobbyGameStore.ts +++ /dev/null @@ -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; // userId → selectedOptionId, null = timed out - scores: Map; // userId → running total -}; - -export interface LobbyGameStore { - create(lobbyId: string, data: LobbyGameData): Promise; - get(lobbyId: string): Promise; - set(lobbyId: string, data: LobbyGameData): Promise; - delete(lobbyId: string): Promise; -} diff --git a/apps/api/src/lobbyGameStore/index.ts b/apps/api/src/lobbyGameStore/index.ts deleted file mode 100644 index 67dc9a2..0000000 --- a/apps/api/src/lobbyGameStore/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; -export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js"; diff --git a/apps/api/src/middleware/authMiddleware.ts b/apps/api/src/middleware/authMiddleware.ts index 744c5e4..da18d01 100644 --- a/apps/api/src/middleware/authMiddleware.ts +++ b/apps/api/src/middleware/authMiddleware.ts @@ -16,7 +16,5 @@ export const requireAuth = async ( return; } - req.session = session; - next(); }; diff --git a/apps/api/src/routes/apiRouter.ts b/apps/api/src/routes/apiRouter.ts index f5ebd01..6ad84eb 100644 --- a/apps/api/src/routes/apiRouter.ts +++ b/apps/api/src/routes/apiRouter.ts @@ -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); diff --git a/apps/api/src/routes/lobbyRouter.ts b/apps/api/src/routes/lobbyRouter.ts deleted file mode 100644 index 5bd82dd..0000000 --- a/apps/api/src/routes/lobbyRouter.ts +++ /dev/null @@ -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); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 2cdeb0e..86d05ed 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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}`); }); diff --git a/apps/api/src/services/lobbyService.test.ts b/apps/api/src/services/lobbyService.test.ts deleted file mode 100644 index c998c12..0000000 --- a/apps/api/src/services/lobbyService.test.ts +++ /dev/null @@ -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", - ); - }); -}); diff --git a/apps/api/src/services/lobbyService.ts b/apps/api/src/services/lobbyService.ts deleted file mode 100644 index 985b776..0000000 --- a/apps/api/src/services/lobbyService.ts +++ /dev/null @@ -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 => { - 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 => { - 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; -}; diff --git a/apps/api/src/services/multiplayerGameService.ts b/apps/api/src/services/multiplayerGameService.ts deleted file mode 100644 index 32727b1..0000000 --- a/apps/api/src/services/multiplayerGameService.ts +++ /dev/null @@ -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 = (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; -}; diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts deleted file mode 100644 index 2fe1ac8..0000000 --- a/apps/api/src/types/express.d.ts +++ /dev/null @@ -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 {}; diff --git a/apps/api/src/ws/auth.test.ts b/apps/api/src/ws/auth.test.ts deleted file mode 100644 index 5866c8e..0000000 --- a/apps/api/src/ws/auth.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/apps/api/src/ws/auth.ts b/apps/api/src/ws/auth.ts deleted file mode 100644 index 75c3613..0000000 --- a/apps/api/src/ws/auth.ts +++ /dev/null @@ -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 => { - 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(); - } -}; diff --git a/apps/api/src/ws/connections.ts b/apps/api/src/ws/connections.ts deleted file mode 100644 index 189be61..0000000 --- a/apps/api/src/ws/connections.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { WebSocket } from "ws"; - -// Map> -const connections = new Map>(); - -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 => { - return connections.get(lobbyId) ?? new Map(); -}; - -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); - } - } -}; diff --git a/apps/api/src/ws/gameState.ts b/apps/api/src/ws/gameState.ts deleted file mode 100644 index 3591843..0000000 --- a/apps/api/src/ws/gameState.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js"; - -export const lobbyGameStore = new InMemoryLobbyGameStore(); -export const timers = new Map(); diff --git a/apps/api/src/ws/handlers/gameHandlers.ts b/apps/api/src/ws/handlers/gameHandlers.ts deleted file mode 100644 index 5ced050..0000000 --- a/apps/api/src/ws/handlers/gameHandlers.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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> & {}, -): Promise => { - // 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); -}; diff --git a/apps/api/src/ws/handlers/lobbyHandlers.ts b/apps/api/src/ws/handlers/lobbyHandlers.ts deleted file mode 100644 index 2a7fa3b..0000000 --- a/apps/api/src/ws/handlers/lobbyHandlers.ts +++ /dev/null @@ -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 => { - // 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 => { - 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 => { - // 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(); - 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); -}; diff --git a/apps/api/src/ws/index.ts b/apps/api/src/ws/index.ts deleted file mode 100644 index 048b734..0000000 --- a/apps/api/src/ws/index.ts +++ /dev/null @@ -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 => { - 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, - ); -}; diff --git a/apps/api/src/ws/router.test.ts b/apps/api/src/ws/router.test.ts deleted file mode 100644 index 7b6d90e..0000000 --- a/apps/api/src/ws/router.test.ts +++ /dev/null @@ -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"), - ); - }); -}); diff --git a/apps/api/src/ws/router.ts b/apps/api/src/ws/router.ts deleted file mode 100644 index d4b04f4..0000000 --- a/apps/api/src/ws/router.ts +++ /dev/null @@ -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 => { - // 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"); - } - } -}; diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 9dc6b4f..b764028 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -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 } }); diff --git a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx deleted file mode 100644 index 5e95588..0000000 --- a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx +++ /dev/null @@ -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 ( -
-
- {/* Result header */} -
-

- {isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"} -

-

- {isTie ? `${winnerNames} tied` : `${winnerNames} wins!`} -

-
- -
- - {/* Score list */} -
- {sortedPlayers.map((player, index) => { - const isCurrentUser = player.userId === currentUserId; - const isPlayerWinner = winnerIds.includes(player.userId); - return ( -
-
- - {index + 1}. - - - {player.user.name} - {isCurrentUser && ( - - (you) - - )} - - {isPlayerWinner && ( - - 👑 - - )} -
- - {player.score} pts - -
- ); - })} -
- -
- - {/* Actions */} -
- - -
-
-
- ); -}; diff --git a/apps/web/src/lib/ws-client.ts b/apps/web/src/lib/ws-client.ts deleted file mode 100644 index 94c8fd5..0000000 --- a/apps/web/src/lib/ws-client.ts +++ /dev/null @@ -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 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 { - 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( - type: T, - callback: (msg: Extract) => void, - ): void { - if (!this.callbacks.has(type)) { - this.callbacks.set(type, new Set()); - } - this.callbacks.get(type)!.add(callback as (msg: WsServerMessage) => void); - } - - off( - type: T, - callback: (msg: Extract) => 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(); - } -} diff --git a/apps/web/src/lib/ws-context.ts b/apps/web/src/lib/ws-context.ts deleted file mode 100644 index d6c230c..0000000 --- a/apps/web/src/lib/ws-context.ts +++ /dev/null @@ -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; - disconnect: () => void; -}; - -export const WsContext = createContext(null); diff --git a/apps/web/src/lib/ws-hooks.ts b/apps/web/src/lib/ws-hooks.ts deleted file mode 100644 index 08417c0..0000000 --- a/apps/web/src/lib/ws-hooks.ts +++ /dev/null @@ -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) => { - 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; -}; diff --git a/apps/web/src/lib/ws-provider.tsx b/apps/web/src/lib/ws-provider.tsx deleted file mode 100644 index b4a56d3..0000000 --- a/apps/web/src/lib/ws-provider.tsx +++ /dev/null @@ -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 => { - 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 ( - - {children} - - ); -}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 06c2243..ac22087 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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(); + root.render( + + + , + ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 96c3044..ce1cdf1 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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 diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0add685..1448282 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,24 +17,16 @@ const RootLayout = () => { Home - - Play - - - Multiplayer + + About
{session ? ( diff --git a/apps/web/src/routes/multiplayer.tsx b/apps/web/src/routes/multiplayer.tsx deleted file mode 100644 index 7adffd6..0000000 --- a/apps/web/src/routes/multiplayer.tsx +++ /dev/null @@ -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 ( - - - - - ); -} diff --git a/apps/web/src/routes/multiplayer/game.$code.tsx b/apps/web/src/routes/multiplayer/game.$code.tsx deleted file mode 100644 index 4d38d79..0000000 --- a/apps/web/src/routes/multiplayer/game.$code.tsx +++ /dev/null @@ -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( - null, - ); - const [answerResult, setAnswerResult] = useState( - null, - ); - const [gameFinished, setGameFinished] = useState(null); - const [error, setError] = useState(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 ( - - ); - } - - // Phase: loading - if (!isConnected || !currentQuestion) { - return ( -
-

- {error ?? (isConnected ? "Loading game..." : "Connecting...")} -

-
- ); - } - - // Phase: playing - return ( -
-
- {/* Progress */} -

- Question {currentQuestion.questionNumber} of{" "} - {currentQuestion.totalQuestions} -

- - {/* Question */} - 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 &&

{error}

} - - {/* Round results */} - {answerResult && ( -
-

- Round results -

- {answerResult.players.map((player) => { - const result = answerResult.results.find( - (r) => r.userId === player.userId, - ); - return ( -
- {player.user.name} - - {result?.selectedOptionId === null - ? "Timed out" - : result?.isCorrect - ? "✓ Correct" - : "✗ Wrong"} - - {player.score} pts -
- ); - })} -
- )} -
-
- ); -} diff --git a/apps/web/src/routes/multiplayer/index.tsx b/apps/web/src/routes/multiplayer/index.tsx deleted file mode 100644 index 55f2da6..0000000 --- a/apps/web/src/routes/multiplayer/index.tsx +++ /dev/null @@ -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(null); - - const handleCreate = async (): Promise => { - 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 => { - 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 ( -
-
-

- Multiplayer -

- - {error &&

{error}

} - - {/* Create lobby */} -
-

- Create a lobby -

-

- Start a new game and invite friends with a code. -

- -
- -
- - {/* Join lobby */} -
-

Join a lobby

-

- Enter the code shared by your host. -

- 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} - /> - -
-
-
- ); -} diff --git a/apps/web/src/routes/multiplayer/lobby.$code.tsx b/apps/web/src/routes/multiplayer/lobby.$code.tsx deleted file mode 100644 index 53bc9d2..0000000 --- a/apps/web/src/routes/multiplayer/lobby.$code.tsx +++ /dev/null @@ -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(null); - const [error, setError] = useState(null); - const [isStarting, setIsStarting] = useState(false); - const lobbyIdRef = useRef(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 ( -
-

- {error ?? (isConnected ? "Joining lobby..." : "Connecting...")} -

-
- ); - } - - const isHost = lobby.hostUserId === currentUserId; - const canStart = isHost && lobby.players.length >= 2 && !isStarting; - - return ( -
-
- {/* Lobby code */} -
-

Lobby code

- -

Click to copy

-
- -
- - {/* Player list */} -
-

- Players ({lobby.players.length}) -

-
    - {lobby.players.map((player) => ( -
  • - - {player.user.name} - {player.userId === lobby.hostUserId && ( - - host - - )} -
  • - ))} -
-
- - {/* Error */} - {error &&

{error}

} - - {/* Start button — host only */} - {isHost && ( - - )} - - {/* Non-host waiting message */} - {!isHost && ( -

- Waiting for host to start the game... -

- )} -
-
- ); -} diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index 32db5c4..d55bff0 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -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(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 (
- { - void startGame(settings).catch((err) => { - console.error("Start game error:", err); - }); - }} - /> +
); } @@ -110,15 +99,11 @@ function Play() { return (
{ - 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} />
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 00d768a..a6e9dbd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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" } }, }); diff --git a/documentation/deployment.md b/documentation/deployment.md index e51400c..5912d2f 100644 --- a/documentation/deployment.md +++ b/documentation/deployment.md @@ -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. diff --git a/documentation/spec.md b/documentation/spec.md index 637da00..4bf2835 100644 --- a/documentation/spec.md +++ b/documentation/spec.md @@ -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) diff --git a/eslint.config.mjs b/eslint.config.mjs index 31b3da2..f7b125e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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" }, }, ]); diff --git a/packages/db/drizzle/0006_certain_adam_destine.sql b/packages/db/drizzle/0006_certain_adam_destine.sql deleted file mode 100644 index 04d62fd..0000000 --- a/packages/db/drizzle/0006_certain_adam_destine.sql +++ /dev/null @@ -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; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0005_snapshot.json b/packages/db/drizzle/meta/0005_snapshot.json index 1a57e01..cfbb350 100644 --- a/packages/db/drizzle/meta/0005_snapshot.json +++ b/packages/db/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json deleted file mode 100644 index 4578a80..0000000 --- a/packages/db/drizzle/meta/0006_snapshot.json +++ /dev/null @@ -1,1003 +0,0 @@ -{ - "id": "66d16ffb-4cdd-4437-b82f-a9fb7ee7b243", - "prevId": "8f34bafa-cffc-4933-952f-64b46afa9c5c", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.deck_terms": { - "name": "deck_terms", - "schema": "", - "columns": { - "deck_id": { - "name": "deck_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "deck_terms_deck_id_decks_id_fk": { - "name": "deck_terms_deck_id_decks_id_fk", - "tableFrom": "deck_terms", - "tableTo": "decks", - "columnsFrom": ["deck_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "deck_terms_term_id_terms_id_fk": { - "name": "deck_terms_term_id_terms_id_fk", - "tableFrom": "deck_terms", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "deck_terms_deck_id_term_id_pk": { - "name": "deck_terms_deck_id_term_id_pk", - "columns": ["deck_id", "term_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.decks": { - "name": "decks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_language": { - "name": "source_language", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "validated_languages": { - "name": "validated_languages", - "type": "varchar(10)[]", - "primaryKey": false, - "notNull": true, - "default": "'{}'" - }, - "type": { - "name": "type", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_decks_type": { - "name": "idx_decks_type", - "columns": [ - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "source_language", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_deck_name": { - "name": "unique_deck_name", - "nullsNotDistinct": false, - "columns": ["name", "source_language"] - } - }, - "policies": {}, - "checkConstraints": { - "source_language_check": { - "name": "source_language_check", - "value": "\"decks\".\"source_language\" IN ('en', 'it')" - }, - "validated_languages_check": { - "name": "validated_languages_check", - "value": "validated_languages <@ ARRAY['en', 'it']::varchar[]" - }, - "validated_languages_excludes_source": { - "name": "validated_languages_excludes_source", - "value": "NOT (\"decks\".\"source_language\" = ANY(\"decks\".\"validated_languages\"))" - }, - "deck_type_check": { - "name": "deck_type_check", - "value": "\"decks\".\"type\" IN ('grammar', 'media')" - } - }, - "isRLSEnabled": false - }, - "public.lobbies": { - "name": "lobbies", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "code": { - "name": "code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "host_user_id": { - "name": "host_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "lobbies_host_user_id_user_id_fk": { - "name": "lobbies_host_user_id_user_id_fk", - "tableFrom": "lobbies", - "tableTo": "user", - "columnsFrom": ["host_user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "lobbies_code_unique": { - "name": "lobbies_code_unique", - "nullsNotDistinct": false, - "columns": ["code"] - } - }, - "policies": {}, - "checkConstraints": { - "lobby_status_check": { - "name": "lobby_status_check", - "value": "\"lobbies\".\"status\" IN ('waiting', 'in_progress', 'finished')" - } - }, - "isRLSEnabled": false - }, - "public.lobby_players": { - "name": "lobby_players", - "schema": "", - "columns": { - "lobby_id": { - "name": "lobby_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "score": { - "name": "score", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "lobby_players_lobby_id_lobbies_id_fk": { - "name": "lobby_players_lobby_id_lobbies_id_fk", - "tableFrom": "lobby_players", - "tableTo": "lobbies", - "columnsFrom": ["lobby_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "lobby_players_user_id_user_id_fk": { - "name": "lobby_players_user_id_user_id_fk", - "tableFrom": "lobby_players", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "lobby_players_lobby_id_user_id_pk": { - "name": "lobby_players_lobby_id_user_id_pk", - "columns": ["lobby_id", "user_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.term_glosses": { - "name": "term_glosses", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "language_code": { - "name": "language_code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "term_glosses_term_id_terms_id_fk": { - "name": "term_glosses_term_id_terms_id_fk", - "tableFrom": "term_glosses", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_term_gloss": { - "name": "unique_term_gloss", - "nullsNotDistinct": false, - "columns": ["term_id", "language_code"] - } - }, - "policies": {}, - "checkConstraints": { - "language_code_check": { - "name": "language_code_check", - "value": "\"term_glosses\".\"language_code\" IN ('en', 'it')" - } - }, - "isRLSEnabled": false - }, - "public.term_topics": { - "name": "term_topics", - "schema": "", - "columns": { - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "topic_id": { - "name": "topic_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "term_topics_term_id_terms_id_fk": { - "name": "term_topics_term_id_terms_id_fk", - "tableFrom": "term_topics", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "term_topics_topic_id_topics_id_fk": { - "name": "term_topics_topic_id_topics_id_fk", - "tableFrom": "term_topics", - "tableTo": "topics", - "columnsFrom": ["topic_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "term_topics_term_id_topic_id_pk": { - "name": "term_topics_term_id_topic_id_pk", - "columns": ["term_id", "topic_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.terms": { - "name": "terms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "source": { - "name": "source", - "type": "varchar(50)", - "primaryKey": false, - "notNull": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pos": { - "name": "pos", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_terms_source_pos": { - "name": "idx_terms_source_pos", - "columns": [ - { - "expression": "source", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "pos", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_source_id": { - "name": "unique_source_id", - "nullsNotDistinct": false, - "columns": ["source", "source_id"] - } - }, - "policies": {}, - "checkConstraints": { - "pos_check": { - "name": "pos_check", - "value": "\"terms\".\"pos\" IN ('noun', 'verb')" - } - }, - "isRLSEnabled": false - }, - "public.topics": { - "name": "topics", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "topics_slug_unique": { - "name": "topics_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.translations": { - "name": "translations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "language_code": { - "name": "language_code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "cefr_level": { - "name": "cefr_level", - "type": "varchar(2)", - "primaryKey": false, - "notNull": false - }, - "difficulty": { - "name": "difficulty", - "type": "varchar(20)", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_translations_lang": { - "name": "idx_translations_lang", - "columns": [ - { - "expression": "language_code", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "difficulty", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "cefr_level", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "term_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "translations_term_id_terms_id_fk": { - "name": "translations_term_id_terms_id_fk", - "tableFrom": "translations", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_translations": { - "name": "unique_translations", - "nullsNotDistinct": false, - "columns": ["term_id", "language_code", "text"] - } - }, - "policies": {}, - "checkConstraints": { - "language_code_check": { - "name": "language_code_check", - "value": "\"translations\".\"language_code\" IN ('en', 'it')" - }, - "cefr_check": { - "name": "cefr_check", - "value": "\"translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')" - }, - "difficulty_check": { - "name": "difficulty_check", - "value": "\"translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')" - } - }, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { "columns": {}, "schemas": {}, "tables": {} } -} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4ae9761..3613600 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -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 } ] -} +} \ No newline at end of file diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index 11ea51b..7fb5cd3 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -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 + */ diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index baa05e0..cd261de 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; diff --git a/packages/db/src/models/lobbyModel.ts b/packages/db/src/models/lobbyModel.ts deleted file mode 100644 index 264e527..0000000 --- a/packages/db/src/models/lobbyModel.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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 => { - await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId)); -}; - -export const deleteLobby = async (lobbyId: string): Promise => { - 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 => { - 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 => { - 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, -): Promise => { - 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)); - }); -}; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e218a43..b0ae2f3 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7dc79f5..ff5f988 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,2 @@ export * from "./constants.js"; export * from "./schemas/game.js"; -export * from "./schemas/lobby.js"; diff --git a/packages/shared/src/schemas/lobby.ts b/packages/shared/src/schemas/lobby.ts deleted file mode 100644 index c9687c4..0000000 --- a/packages/shared/src/schemas/lobby.ts +++ /dev/null @@ -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; - -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; - -export const JoinLobbyResponseSchema = LobbySchema; - -export type JoinLobbyResponse = z.infer; - -export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() }); -export type GameRouteSearch = z.infer; - -// ---------------------------------------------------------------------------- -// 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; - -export const WsLobbyLeaveSchema = z.object({ - type: z.literal("lobby:leave"), - lobbyId: z.uuid(), -}); - -export type WsLobbyLeave = z.infer; - -export const WsLobbyStartSchema = z.object({ - type: z.literal("lobby:start"), - lobbyId: z.uuid(), -}); - -export type WsLobbyStart = z.infer; - -export const WsGameReadySchema = z.object({ - type: z.literal("game:ready"), - lobbyId: z.uuid(), -}); -export type WsGameReady = z.infer; - -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; - -export const WsClientMessageSchema = z.discriminatedUnion("type", [ - WsLobbyJoinSchema, - WsLobbyLeaveSchema, - WsLobbyStartSchema, - WsGameAnswerSchema, - WsGameReadySchema, -]); -export type WsClientMessage = z.infer; - -// ---------------------------------------------------------------------------- -// WebSocket: Server → Client -// ---------------------------------------------------------------------------- - -export const WsLobbyStateSchema = z.object({ - type: z.literal("lobby:state"), - lobby: LobbySchema, -}); - -export type WsLobbyState = z.infer; - -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; - -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; - -export const WsGameFinishedSchema = z.object({ - type: z.literal("game:finished"), - players: z.array(LobbyPlayerSchema), - winnerIds: z.array(z.string()), -}); - -export type WsGameFinished = z.infer; - -export const WsErrorSchema = z.object({ - type: z.literal("error"), - code: z.string(), - message: z.string(), -}); -export type WsError = z.infer; - -export const WsServerMessageSchema = z.discriminatedUnion("type", [ - WsLobbyStateSchema, - WsGameQuestionSchema, - WsGameAnswerResultSchema, - WsGameFinishedSchema, - WsErrorSchema, -]); - -export type WsServerMessage = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaea759..11520d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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