diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a48cee1..306cc78 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -8,9 +8,6 @@ jobs: build-and-deploy: runs-on: docker steps: - - name: Install tools - run: apt-get update && apt-get install -y docker.io openssh-client - - name: Checkout code uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/apps/api/package.json b/apps/api/package.json index 155b859..bfd2878 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/server.ts", + "dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts", "build": "tsc", "start": "node dist/src/server.js", "test": "vitest" @@ -14,12 +14,14 @@ "@lila/shared": "workspace:*", "better-auth": "^1.6.2", "cors": "^2.8.6", - "express": "^5.2.1" + "express": "^5.2.1", + "ws": "^8.20.0" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", "supertest": "^7.2.2", "tsx": "^4.21.0" } diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 9746328..a4eb176 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -1,5 +1,11 @@ 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", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); @@ -33,49 +39,48 @@ beforeEach(() => { describe("POST /api/v1/game/start", () => { it("returns 200 with a valid game session", async () => { const res = await request(app).post("/api/v1/game/start").send(validBody); - + const body = res.body as GameStartResponse; expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.sessionId).toBeDefined(); - expect(res.body.data.questions).toHaveLength(3); + expect(body.success).toBe(true); + expect(body.data.sessionId).toBeDefined(); + expect(body.data.questions).toHaveLength(3); }); it("returns 400 when the body is empty", async () => { const res = await request(app).post("/api/v1/game/start").send({}); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - expect(res.body.error).toBeDefined(); + expect(body.success).toBe(false); + expect(body.error).toBeDefined(); }); it("returns 400 when required fields are missing", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ source_language: "en" }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + expect(body.success).toBe(false); }); it("returns 400 when a field has an invalid value", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ ...validBody, difficulty: "impossible" }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + expect(body.success).toBe(false); }); }); describe("POST /api/v1/game/answer", () => { it("returns 200 with an answer result for a valid submission", async () => { - // Start a game first const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); - - const { sessionId, questions } = startRes.body.data; - const question = questions[0]; + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + const question = questions[0]!; const res = await request(app) .post("/api/v1/game/answer") @@ -84,20 +89,20 @@ describe("POST /api/v1/game/answer", () => { questionId: question.questionId, selectedOptionId: 0, }); - + const body = res.body as GameAnswerResponse; expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.questionId).toBe(question.questionId); - expect(typeof res.body.data.isCorrect).toBe("boolean"); - expect(typeof res.body.data.correctOptionId).toBe("number"); - expect(res.body.data.selectedOptionId).toBe(0); + expect(body.success).toBe(true); + expect(body.data.questionId).toBe(question.questionId); + expect(typeof body.data.isCorrect).toBe("boolean"); + expect(typeof body.data.correctOptionId).toBe("number"); + expect(body.data.selectedOptionId).toBe(0); }); it("returns 400 when the body is empty", async () => { const res = await request(app).post("/api/v1/game/answer").send({}); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + expect(body.success).toBe(false); }); it("returns 404 when the session does not exist", async () => { @@ -108,18 +113,18 @@ describe("POST /api/v1/game/answer", () => { questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(404); - expect(res.body.success).toBe(false); - expect(res.body.error).toContain("Game session not found"); + expect(body.success).toBe(false); + expect(body.error).toContain("Game session not found"); }); it("returns 404 when the question does not exist in the session", async () => { const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); - - const { sessionId } = startRes.body.data; + const startBody = startRes.body as GameStartResponse; + const { sessionId } = startBody.data; const res = await request(app) .post("/api/v1/game/answer") @@ -128,9 +133,9 @@ describe("POST /api/v1/game/answer", () => { questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(404); - expect(res.body.success).toBe(false); - expect(res.body.error).toContain("Question not found"); + expect(body.success).toBe(false); + expect(body.error).toContain("Question not found"); }); }); diff --git a/apps/api/src/controllers/lobbyController.ts b/apps/api/src/controllers/lobbyController.ts new file mode 100644 index 0000000..113c8c5 --- /dev/null +++ b/apps/api/src/controllers/lobbyController.ts @@ -0,0 +1,37 @@ +import type { Request, Response, NextFunction } from "express"; +import { createLobby, joinLobby } from "../services/lobbyService.js"; + +export const createLobbyHandler = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.session!.user.id; + const lobby = await createLobby(userId); + res.json({ success: true, data: lobby }); + } catch (error) { + next(error); + } +}; + +export const joinLobbyHandler = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.session!.user.id; + const code = req.params["code"]; + if (!code) { + return next(new Error("Missing code param")); + } + if (typeof code !== "string") { + return next(new Error("Missing or invalid code param")); + } + const lobby = await joinLobby(code, userId); + res.json({ success: true, data: lobby }); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/errors/AppError.ts b/apps/api/src/errors/AppError.ts index 6611b9b..4677d9f 100644 --- a/apps/api/src/errors/AppError.ts +++ b/apps/api/src/errors/AppError.ts @@ -19,3 +19,9 @@ 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 d4a339c..f29ca59 100644 --- a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts @@ -3,15 +3,17 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js"; export class InMemoryGameSessionStore implements GameSessionStore { private sessions = new Map(); - async create(sessionId: string, data: GameSessionData): Promise { + create(sessionId: string, data: GameSessionData): Promise { this.sessions.set(sessionId, data); + return Promise.resolve(); } - async get(sessionId: string): Promise { - return this.sessions.get(sessionId) ?? null; + get(sessionId: string): Promise { + return Promise.resolve(this.sessions.get(sessionId) ?? null); } - async delete(sessionId: string): Promise { + 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 new file mode 100644 index 0000000..d3d00f2 --- /dev/null +++ b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..bf59c3b --- /dev/null +++ b/apps/api/src/lobbyGameStore/LobbyGameStore.ts @@ -0,0 +1,18 @@ +import type { MultiplayerQuestion } from "../services/multiplayerGameService.js"; + +export type LobbyGameData = { + code: string; + questions: MultiplayerQuestion[]; + currentIndex: number; + // NOTE: Map types are used here for O(1) lookups in-process. + // When migrating to Valkey, convert to plain objects for JSON serialization. + playerAnswers: Map; // 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 new file mode 100644 index 0000000..67dc9a2 --- /dev/null +++ b/apps/api/src/lobbyGameStore/index.ts @@ -0,0 +1,2 @@ +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 da18d01..744c5e4 100644 --- a/apps/api/src/middleware/authMiddleware.ts +++ b/apps/api/src/middleware/authMiddleware.ts @@ -16,5 +16,7 @@ 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 6ad84eb..f5ebd01 100644 --- a/apps/api/src/routes/apiRouter.ts +++ b/apps/api/src/routes/apiRouter.ts @@ -2,8 +2,10 @@ import express from "express"; import { Router } from "express"; import { healthRouter } from "./healthRouter.js"; import { gameRouter } from "./gameRouter.js"; +import { lobbyRouter } from "./lobbyRouter.js"; export const apiRouter: Router = express.Router(); apiRouter.use("/health", healthRouter); apiRouter.use("/game", gameRouter); +apiRouter.use("/lobbies", lobbyRouter); diff --git a/apps/api/src/routes/lobbyRouter.ts b/apps/api/src/routes/lobbyRouter.ts new file mode 100644 index 0000000..5bd82dd --- /dev/null +++ b/apps/api/src/routes/lobbyRouter.ts @@ -0,0 +1,14 @@ +import express from "express"; +import type { Router } from "express"; +import { + createLobbyHandler, + joinLobbyHandler, +} from "../controllers/lobbyController.js"; +import { requireAuth } from "../middleware/authMiddleware.js"; + +export const lobbyRouter: Router = express.Router(); + +lobbyRouter.use(requireAuth); + +lobbyRouter.post("/", createLobbyHandler); +lobbyRouter.post("/:code/join", joinLobbyHandler); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 86d05ed..c2b6d34 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,8 +1,13 @@ +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); app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); diff --git a/apps/api/src/services/lobbyService.ts b/apps/api/src/services/lobbyService.ts new file mode 100644 index 0000000..3e307ef --- /dev/null +++ b/apps/api/src/services/lobbyService.ts @@ -0,0 +1,70 @@ +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 { + return await createLobbyModel(code, hostUserId); + } 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 new file mode 100644 index 0000000..32727b1 --- /dev/null +++ b/apps/api/src/services/multiplayerGameService.ts @@ -0,0 +1,75 @@ +import { randomUUID } from "crypto"; +import { getGameTerms, getDistractors } from "@lila/db"; +import type { + GameQuestion, + AnswerOption, + SupportedLanguageCode, + SupportedPos, + DifficultyLevel, +} from "@lila/shared"; + +// TODO(game-mode-slice): replace with lobby settings when mode selection lands +const MULTIPLAYER_DEFAULTS = { + sourceLanguage: "en" as SupportedLanguageCode, + targetLanguage: "it" as SupportedLanguageCode, + pos: "noun" as SupportedPos, + difficulty: "easy" as DifficultyLevel, + rounds: 3, +}; + +const shuffle = (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 new file mode 100644 index 0000000..2fe1ac8 --- /dev/null +++ b/apps/api/src/types/express.d.ts @@ -0,0 +1,17 @@ +import type { Session, User } from "better-auth"; + +declare global { + namespace Express { + interface Request { + session?: { session: Session; user: User }; + } + } +} + +declare module "ws" { + interface WebSocket { + lobbyId?: string | undefined; + } +} + +export {}; diff --git a/apps/api/src/ws/auth.ts b/apps/api/src/ws/auth.ts new file mode 100644 index 0000000..75c3613 --- /dev/null +++ b/apps/api/src/ws/auth.ts @@ -0,0 +1,32 @@ +import type { IncomingMessage } from "http"; +import type { Duplex } from "stream"; +import type { WebSocketServer, WebSocket } from "ws"; +import { fromNodeHeaders } from "better-auth/node"; +import { auth } from "../lib/auth.js"; + +export const handleUpgrade = async ( + request: IncomingMessage, + socket: Duplex, + head: Buffer, + wss: WebSocketServer, +): Promise => { + 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 new file mode 100644 index 0000000..189be61 --- /dev/null +++ b/apps/api/src/ws/connections.ts @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..3591843 --- /dev/null +++ b/apps/api/src/ws/gameState.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..ee06ce8 --- /dev/null +++ b/apps/api/src/ws/handlers/gameHandlers.ts @@ -0,0 +1,187 @@ +import type { WebSocket } from "ws"; +import type { User } from "better-auth"; +import type { WsGameAnswer } 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 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 new file mode 100644 index 0000000..12a59bf --- /dev/null +++ b/apps/api/src/ws/handlers/lobbyHandlers.ts @@ -0,0 +1,157 @@ +import type { WebSocket } from "ws"; +import type { User } from "better-auth"; +import type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared"; +import { + getLobbyByCodeWithPlayers, + deleteLobby, + removePlayer, + updateLobbyStatus, +} 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 getLobbyByCodeWithPlayers(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 new file mode 100644 index 0000000..048b734 --- /dev/null +++ b/apps/api/src/ws/index.ts @@ -0,0 +1,65 @@ +import { WebSocketServer } from "ws"; +import type { WebSocket } from "ws"; +import type { Server } from "http"; +import type { IncomingMessage } from "http"; +import { handleUpgrade } from "./auth.js"; +import { handleMessage, type AuthenticatedUser } from "./router.js"; +import { removeConnection } from "./connections.js"; +import { handleLobbyLeave } from "./handlers/lobbyHandlers.js"; + +export const setupWebSocket = (server: Server): WebSocketServer => { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (request, socket, head) => { + if (request.url !== "/ws") { + socket.destroy(); + return; + } + void handleUpgrade(request, socket, head, wss).catch((err) => { + console.error("WebSocket upgrade error:", err); + socket.destroy(); + }); + }); + + wss.on( + "connection", + (ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => { + ws.on("message", (rawData) => { + void handleMessage(ws, rawData, auth).catch((err) => { + console.error( + `WebSocket message error for user ${auth.user.id}:`, + err, + ); + }); + }); + + ws.on("close", () => { + void handleDisconnect(ws, auth).catch((err) => { + console.error( + `WebSocket disconnect error for user ${auth.user.id}:`, + err, + ); + }); + }); + + ws.on("error", (err) => { + console.error(`WebSocket error for user ${auth.user.id}:`, err); + }); + }, + ); + + return wss; +}; + +const handleDisconnect = async ( + ws: WebSocket, + auth: AuthenticatedUser, +): Promise => { + 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.ts b/apps/api/src/ws/router.ts new file mode 100644 index 0000000..1a1dad4 --- /dev/null +++ b/apps/api/src/ws/router.ts @@ -0,0 +1,74 @@ +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 } 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; + 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/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1448282..e7fe4b0 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -24,9 +24,13 @@ const RootLayout = () => { {session ? ( diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index d55bff0..32db5c4 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -6,9 +6,12 @@ import { ScoreScreen } from "../components/game/ScoreScreen"; import { GameSetup } from "../components/game/GameSetup"; import { authClient } from "../lib/auth-client"; -function Play() { - const API_URL = import.meta.env["VITE_API_URL"] || ""; +type GameStartResponse = { success: true; data: GameSession }; +type GameAnswerResponse = { success: true; data: AnswerResult }; +const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; + +function Play() { const [gameSession, setGameSession] = useState(null); const [isLoading, setIsLoading] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -17,13 +20,15 @@ function Play() { const startGame = useCallback(async (settings: GameRequest) => { setIsLoading(true); + const response = await fetch(`${API_URL}/api/v1/game/start`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(settings), }); - const data = await response.json(); + + const data = (await response.json()) as GameStartResponse; setGameSession(data.data); setCurrentQuestionIndex(0); setResults([]); @@ -55,7 +60,7 @@ function Play() { selectedOptionId: optionId, }), }); - const data = await response.json(); + const data = (await response.json()) as GameAnswerResponse; setCurrentResult(data.data); }; @@ -70,7 +75,13 @@ function Play() { if (!gameSession && !isLoading) { return (
- + { + void startGame(settings).catch((err) => { + console.error("Start game error:", err); + }); + }} + />
); } @@ -99,11 +110,15 @@ 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/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 92135eb..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,91 +0,0 @@ -services: - caddy: - container_name: lila-caddy - image: caddy:2-alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - caddy_data:/data - - caddy_config:/config - restart: unless-stopped - depends_on: - api: - condition: service_healthy - networks: - - lila-network - - api: - container_name: lila-api - build: - context: . - dockerfile: ./apps/api/Dockerfile - target: runner - env_file: - - .env - restart: unless-stopped - healthcheck: - test: - ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] - interval: 5s - timeout: 3s - retries: 5 - depends_on: - database: - condition: service_healthy - networks: - - lila-network - - web: - container_name: lila-web - build: - context: . - dockerfile: ./apps/web/Dockerfile - target: production - args: - VITE_API_URL: https://api.lilastudy.com - restart: unless-stopped - networks: - - lila-network - - database: - container_name: lila-database - image: postgres:18.3-alpine3.23 - env_file: - - .env - environment: - - PGDATA=/var/lib/postgresql/data - volumes: - - lila-db:/var/lib/postgresql/data - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 5 - networks: - - lila-network - - forgejo: - container_name: lila-forgejo - image: codeberg.org/forgejo/forgejo:11 - volumes: - - forgejo-data:/data - environment: - - USER_UID=1000 - - USER_GID=1000 - ports: - - "2222:22" - restart: unless-stopped - networks: - - lila-network - -networks: - lila-network: - -volumes: - lila-db: - caddy_data: - caddy_config: - forgejo-data: diff --git a/docker-compose.yml b/docker-compose.yml index 5903fa6..b661975 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,11 +42,12 @@ services: volumes: - ./apps/api:/app/apps/api # Hot reload API code - ./packages/shared:/app/packages/shared # Hot reload shared + - ./packages/db:/app/packages/db - /app/node_modules restart: unless-stopped healthcheck: test: - ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] + ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/health || exit 1"] interval: 5s timeout: 3s retries: 5 @@ -66,6 +67,7 @@ services: - "5173:5173" volumes: - ./apps/web:/app/apps/web # Hot reload: local edits reflect immediately + - ./packages/shared:/app/packages/shared - /app/node_modules # Protect container's node_modules from being overwritten environment: - VITE_API_URL=http://localhost:3000 diff --git a/documentation/deployment.md b/documentation/deployment.md index f95fea4..e51400c 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. @@ -225,59 +225,9 @@ Host git.lilastudy.com This allows standard git commands without specifying the port. -## CI/CD Pipeline - -Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers. - -### Components - -- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/` -- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64 -- **Workflow file** — `.forgejo/workflows/deploy.yml` - -### Pipeline Steps - -1. Install Docker CLI and SSH client in the job container -2. Checkout the repository -3. Login to the Forgejo container registry -4. Build API image (target: `runner`) -5. Build Web image (target: `production`, with `VITE_API_URL` baked in) -6. Push both images to `git.lilastudy.com` -7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images - -### Secrets (stored in Forgejo repo settings → Actions → Secrets) - -| Secret | Value | -|---|---| -| REGISTRY_USER | Forgejo username | -| REGISTRY_PASSWORD | Forgejo password | -| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS | -| SSH_HOST | VPS IP address | -| SSH_USER | `lila` | - -### Runner Configuration - -The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings: - -- `docker_host: "automount"` — mounts the host Docker socket into job containers -- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount -- `privileged: true` — required for Docker access from job containers -- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers - -The runner command must explicitly reference the config file: - -```yaml -command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"' -``` - -### Deploy Cycle - -Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required. - -To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button. - ## Known Issues and Future Work +- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation) - **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added - **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state - **Monitoring/logging**: No centralized logging or uptime monitoring configured diff --git a/documentation/game_modes.md b/documentation/game_modes.md new file mode 100644 index 0000000..22eff3d --- /dev/null +++ b/documentation/game_modes.md @@ -0,0 +1,83 @@ +# Game Modes + +This document describes the planned game modes for lila. Each mode uses the same lobby system and vocabulary data but differs in how answers are submitted, scored, and how a winner is determined. + +The first multiplayer mode to implement is TBD. The lobby infrastructure (create, join, WebSocket connection) is mode-agnostic — adding a new mode means adding new game logic, not changing the lobby. + +--- + +## TV Quiz Show + +**Type:** Multiplayer +**Answer model:** Buzzer — first to press gets to answer +**Rounds:** Fixed (e.g. 10) + +A question appears for all players. The first player to buzz in gets to answer. If correct, they score a point. If wrong, other players may get a chance to answer (TBD: whether the question passes to the next buzzer or the round ends). The host or a timer controls the pace. + +Key difference from other modes: only one player answers per question. Speed of reaction matters as much as knowledge. + +--- + +## Race to the Top + +**Type:** Multiplayer +**Answer model:** Simultaneous — all players answer independently +**Rounds:** None — play until target score reached + +All players see the same question and answer independently. No fixed round count. The first player to reach a target number of correct answers wins (e.g. 20). Fast-paced and competitive. + +Open questions: what happens if two players hit the target on the same question? Tiebreaker by speed? Shared win? + +--- + +## Chain Link + +**Type:** Multiplayer +**Answer model:** Turn-based — one player at a time, in rotation +**Rounds:** None — play until a player fails + +Players answer in a fixed rotation: Player 1, Player 2, Player 3, then back to Player 1. Each player gets one question per turn. The game continues until a player answers incorrectly — that player is out (or the game ends). Last correct answerer wins, or the game simply ends on the first wrong answer. + +Key difference from other modes: turn-based, not simultaneous. Pressure builds as you wait for your turn. + +Open questions: does the player who answers wrong lose, or does the game just end? If the game continues, does it become elimination? + +--- + +## Elimination Round + +**Type:** Multiplayer +**Answer model:** Simultaneous — all players answer independently +**Rounds:** Continue until one player remains + +All players see the same question and answer simultaneously. Players who answer incorrectly are eliminated. Rounds continue until only one player is left standing. + +Open questions: what if everyone gets it wrong in the same round? Reset that round? Eliminate nobody? What if it comes down to two players and both get it wrong repeatedly? + +--- + +## Cooperative Challenge + +**Type:** Multiplayer +**Answer model:** TBD +**Rounds:** TBD + +Players work together rather than competing. Concept not yet defined. Possible ideas: shared team score with a target, each player contributes answers to a collective pool, or players take turns and the team survives as long as the chain doesn't break. + +--- + +## Single Player Extended + +**Type:** Singleplayer +**Answer model:** TBD +**Rounds:** TBD + +An expanded version of the current singleplayer quiz. Concept not yet defined. Possible ideas: longer sessions with increasing difficulty, mixed POS/language rounds, streak bonuses, progress tracking across sessions, or timed challenge mode. + +--- + +## Schema Impact + +The `lobbies` table includes a `game_mode` column (varchar) with values like `tv_quiz`, `race_to_top`, `chain_link`, `elimination`. Mode-specific settings (e.g. target score for Race to the Top) can be stored in a `settings` jsonb column if needed. + +The singleplayer modes (Single Player Extended) don't require a lobby — they extend the existing singleplayer flow. diff --git a/documentation/notes.md b/documentation/notes.md index 998cabf..c750683 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -21,18 +21,13 @@ WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/c Configure a credential helper to remove this warning. See https://docs.docker.com/go/credential-store/ +### docker containers on startup? + +laptop: verify if docker containers run on startup (they shouldnt) + ### vps setup - monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary) -- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy - -### ~~cd/ci pipeline~~ ✅ RESOLVED - -Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `deployment.md`. - -### ~~postgres backups~~ ✅ RESOLVED - -Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`. ### try now option diff --git a/documentation/spec.md b/documentation/spec.md index 4bf2835..637da00 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 f7b125e..31b3da2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default defineConfig([ "eslint.config.mjs", "**/*.config.ts", "routeTree.gen.ts", + "scripts/**", ]), eslint.configs.recommended, @@ -38,6 +39,27 @@ export default defineConfig([ }, { files: ["apps/web/src/routes/**/*.{ts,tsx}"], - rules: { "react-refresh/only-export-components": "off" }, + rules: { + "react-refresh/only-export-components": "off", + "@typescript-eslint/only-throw-error": "off", + }, + }, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, + { + // better-auth's createAuthClient return type is insufficiently typed upstream. + // This is a known issue: https://github.com/better-auth/better-auth/issues + files: ["apps/web/src/lib/auth-client.ts"], + rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, }, ]); diff --git a/packages/db/drizzle/0006_certain_adam_destine.sql b/packages/db/drizzle/0006_certain_adam_destine.sql new file mode 100644 index 0000000..04d62fd --- /dev/null +++ b/packages/db/drizzle/0006_certain_adam_destine.sql @@ -0,0 +1,21 @@ +CREATE TABLE "lobbies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" varchar(10) NOT NULL, + "host_user_id" text NOT NULL, + "status" varchar(20) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "lobbies_code_unique" UNIQUE("code"), + CONSTRAINT "lobby_status_check" CHECK ("lobbies"."status" IN ('waiting', 'in_progress', 'finished')) +); +--> statement-breakpoint +CREATE TABLE "lobby_players" ( + "lobby_id" uuid NOT NULL, + "user_id" text NOT NULL, + "score" integer DEFAULT 0 NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "lobby_players_lobby_id_user_id_pk" PRIMARY KEY("lobby_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "lobbies" ADD CONSTRAINT "lobbies_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_lobby_id_lobbies_id_fk" FOREIGN KEY ("lobby_id") REFERENCES "public"."lobbies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ 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 cfbb350..1a57e01 100644 --- a/packages/db/drizzle/meta/0005_snapshot.json +++ b/packages/db/drizzle/meta/0005_snapshot.json @@ -110,12 +110,8 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -149,12 +145,8 @@ "name": "deck_terms_deck_id_decks_id_fk", "tableFrom": "deck_terms", "tableTo": "decks", - "columnsFrom": [ - "deck_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -162,12 +154,8 @@ "name": "deck_terms_term_id_terms_id_fk", "tableFrom": "deck_terms", "tableTo": "terms", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["term_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -175,10 +163,7 @@ "compositePrimaryKeys": { "deck_terms_deck_id_term_id_pk": { "name": "deck_terms_deck_id_term_id_pk", - "columns": [ - "deck_id", - "term_id" - ] + "columns": ["deck_id", "term_id"] } }, "uniqueConstraints": {}, @@ -265,10 +250,7 @@ "unique_deck_name": { "name": "unique_deck_name", "nullsNotDistinct": false, - "columns": [ - "name", - "source_language" - ] + "columns": ["name", "source_language"] } }, "policies": {}, @@ -368,12 +350,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -383,9 +361,7 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -435,12 +411,8 @@ "name": "term_glosses_term_id_terms_id_fk", "tableFrom": "term_glosses", "tableTo": "terms", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["term_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -450,10 +422,7 @@ "unique_term_gloss": { "name": "unique_term_gloss", "nullsNotDistinct": false, - "columns": [ - "term_id", - "language_code" - ] + "columns": ["term_id", "language_code"] } }, "policies": {}, @@ -488,12 +457,8 @@ "name": "term_topics_term_id_terms_id_fk", "tableFrom": "term_topics", "tableTo": "terms", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["term_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -501,12 +466,8 @@ "name": "term_topics_topic_id_topics_id_fk", "tableFrom": "term_topics", "tableTo": "topics", - "columnsFrom": [ - "topic_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -514,10 +475,7 @@ "compositePrimaryKeys": { "term_topics_term_id_topic_id_pk": { "name": "term_topics_term_id_topic_id_pk", - "columns": [ - "term_id", - "topic_id" - ] + "columns": ["term_id", "topic_id"] } }, "uniqueConstraints": {}, @@ -591,10 +549,7 @@ "unique_source_id": { "name": "unique_source_id", "nullsNotDistinct": false, - "columns": [ - "source", - "source_id" - ] + "columns": ["source", "source_id"] } }, "policies": {}, @@ -650,9 +605,7 @@ "topics_slug_unique": { "name": "topics_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -748,12 +701,8 @@ "name": "translations_term_id_terms_id_fk", "tableFrom": "translations", "tableTo": "terms", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["term_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -763,11 +712,7 @@ "unique_translations": { "name": "unique_translations", "nullsNotDistinct": false, - "columns": [ - "term_id", - "language_code", - "text" - ] + "columns": ["term_id", "language_code", "text"] } }, "policies": {}, @@ -844,9 +789,7 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -927,9 +870,5 @@ "roles": {}, "policies": {}, "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file + "_meta": { "columns": {}, "schemas": {}, "tables": {} } +} diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..4578a80 --- /dev/null +++ b/packages/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1003 @@ +{ + "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 3613600..4ae9761 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1776154563168, "tag": "0005_broad_mariko_yashida", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1776270391189, + "tag": "0006_certain_adam_destine", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index 7fb5cd3..11ea51b 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -9,6 +9,7 @@ import { primaryKey, index, boolean, + integer, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -19,6 +20,7 @@ import { CEFR_LEVELS, SUPPORTED_DECK_TYPES, DIFFICULTY_LEVELS, + LOBBY_STATUSES, } from "@lila/shared"; export const terms = pgTable( @@ -252,12 +254,53 @@ export const accountRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id] }), })); -/* - * INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning - * - * source + source_id (terms): idempotency key per import pipeline - * display_name UNIQUE (users): multiplayer requires distinguishable names - * UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates - * updated_at omitted: misleading without a trigger to maintain it - * FK indexes: all FK columns covered, no sequential scans on joins - */ +export const lobbies = pgTable( + "lobbies", + { + id: uuid().primaryKey().defaultRandom(), + code: varchar({ length: 10 }).notNull().unique(), + hostUserId: text("host_user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + status: varchar({ length: 20 }).notNull().default("waiting"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + check( + "lobby_status_check", + sql`${table.status} IN (${sql.raw(LOBBY_STATUSES.map((s) => `'${s}'`).join(", "))})`, + ), + ], +); + +export const lobby_players = pgTable( + "lobby_players", + { + lobbyId: uuid("lobby_id") + .notNull() + .references(() => lobbies.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + score: integer().notNull().default(0), + joinedAt: timestamp("joined_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [primaryKey({ columns: [table.lobbyId, table.userId] })], +); + +export const lobbyRelations = relations(lobbies, ({ one, many }) => ({ + host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }), + players: many(lobby_players), +})); + +export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({ + lobby: one(lobbies, { + fields: [lobby_players.lobbyId], + references: [lobbies.id], + }), + user: one(user, { fields: [lobby_players.userId], references: [user.id] }), +})); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cd261de..baa05e0 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,11 +3,13 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { resolve } from "path"; import { fileURLToPath } from "url"; import { dirname } from "path"; +import * as schema from "./db/schema.js"; config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"), }); -export const db = drizzle(process.env["DATABASE_URL"]!); +export const db = drizzle(process.env["DATABASE_URL"]!, { schema }); export * from "./models/termModel.js"; +export * from "./models/lobbyModel.js"; diff --git a/packages/db/src/models/lobbyModel.ts b/packages/db/src/models/lobbyModel.ts new file mode 100644 index 0000000..7aa02d2 --- /dev/null +++ b/packages/db/src/models/lobbyModel.ts @@ -0,0 +1,122 @@ +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 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 b0ae2f3..e218a43 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -13,3 +13,8 @@ export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const; export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const; export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number]; + +export const LOBBY_STATUSES = ["waiting", "in_progress", "finished"] as const; +export type LobbyStatus = (typeof LOBBY_STATUSES)[number]; + +export const MAX_LOBBY_PLAYERS = 4; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff5f988..7dc79f5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ 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 new file mode 100644 index 0000000..b2e3c55 --- /dev/null +++ b/packages/shared/src/schemas/lobby.ts @@ -0,0 +1,130 @@ +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; + +// ---------------------------------------------------------------------------- +// 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 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, +]); +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 11520d7..eaea759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + ws: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@types/cors': specifier: ^2.8.19 @@ -72,6 +75,9 @@ importers: '@types/supertest': specifier: ^7.2.0 version: 7.2.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 supertest: specifier: ^7.2.2 version: 7.2.2 @@ -1311,6 +1317,9 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2983,6 +2992,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} @@ -3915,6 +3936,10 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.0 + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5586,6 +5611,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.0: {} + xlsx@0.18.5: dependencies: adler-32: 1.3.1 diff --git a/scripts/create-issues.sh b/scripts/create-issues.sh new file mode 100644 index 0000000..fefb072 --- /dev/null +++ b/scripts/create-issues.sh @@ -0,0 +1,280 @@ +#!/bin/bash + +# Forgejo batch issue creator for lila +# Usage: FORGEJO_TOKEN=your_token ./create-issues.sh + +FORGEJO_URL="https://git.lilastudy.com" +OWNER="forgejo-lila" +REPO="lila" +TOKEN="${FORGEJO_TOKEN:?Set FORGEJO_TOKEN environment variable}" + +API="${FORGEJO_URL}/api/v1/repos/${OWNER}/${REPO}" + +# Helper: create a label (ignores if already exists) +create_label() { + local name="$1" color="$2" description="$3" + curl -s -X POST "${API}/labels" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"${name}\",\"color\":\"${color}\",\"description\":\"${description}\"}" > /dev/null + echo "Label: ${name}" +} + +# Helper: create an issue with labels +create_issue() { + local title="$1" body="$2" + shift 2 + local labels="$*" + + # Build labels JSON array + local label_ids="" + for label in $labels; do + local id + id=$(curl -s "${API}/labels" \ + -H "Authorization: token ${TOKEN}" | \ + python3 -c "import sys,json; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='${label}']") + if [ -n "$label_ids" ]; then + label_ids="${label_ids},${id}" + else + label_ids="${id}" + fi + done + + curl -s -X POST "${API}/issues" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"title\":$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"body\":$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"labels\":[${label_ids}]}" > /dev/null + + echo "Issue: ${title}" +} + +echo "=== Creating labels ===" +create_label "feature" "#0075ca" "New user-facing functionality" +create_label "infra" "#e4e669" "Infrastructure, deployment, DevOps" +create_label "debt" "#d876e3" "Technical cleanup, refactoring" +create_label "security" "#b60205" "Security improvements" +create_label "ux" "#1d76db" "User experience, accessibility, polish" +create_label "multiplayer" "#0e8a16" "Multiplayer lobby and game features" + +echo "" +echo "=== Creating issues ===" + +# ── feature ── + +create_issue \ + "Add guest/try-now option — play without account" \ + "Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a 'Try without account' button on the login/landing page." \ + feature + +create_issue \ + "Add Apple login provider" \ + "Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration." \ + feature + +create_issue \ + "Add email+password login" \ + "Add traditional email and password authentication as an alternative to social login. Configure via Better Auth." \ + feature + +create_issue \ + "User stats endpoint + profile page" \ + "Add GET /users/me/stats endpoint returning games played, score history, etc. Build a frontend profile page displaying the stats." \ + feature + +# ── infra ── + +create_issue \ + "Google OAuth app verification and publishing" \ + "Currently only test users can log in via Google. Publish the OAuth consent screen so any Google user can sign in. Requires branding verification through Google Cloud Console." \ + infra + +create_issue \ + "Set up Docker credential helper on dev laptop" \ + "Docker credentials are stored unencrypted in ~/.docker/config.json. Set up a credential helper to store them securely. See https://docs.docker.com/go/credential-store/" \ + infra + +create_issue \ + "VPS monitoring and logging" \ + "Set up monitoring and centralized logging on the VPS. Options: chkrootkit/rkhunter for security, logwatch/monit for daily summaries, uptime monitoring for service health." \ + infra + +create_issue \ + "Move to offsite backup storage" \ + "Currently database backups live on the same VPS. Add offsite copies to Hetzner Object Storage or similar S3-compatible service to protect against VPS failure." \ + infra + +create_issue \ + "Replace in-memory game session store with Valkey" \ + "Add Valkey container to the production Docker stack. Implement ValkeyGameSessionStore using the existing GameSessionStore interface. Required before multiplayer." \ + infra + +create_issue \ + "Modern env management approach" \ + "Evaluate replacing .env files with a more robust approach (e.g. dotenvx, infisical, or similar). Current setup works but .env files are error-prone and not versioned." \ + infra + +create_issue \ + "Pin dependencies in package.json files" \ + "Pin all dependency versions in package.json files to exact versions to prevent unexpected updates from breaking builds." \ + infra + +# ── debt ── + +create_issue \ + "Rethink organization of datafiles and wordlists" \ + "The current layout of data-sources/, scripts/datafiles/, scripts/data-sources/, and packages/db/src/data/ is confusing with overlapping content. Consolidate into a clear structure." \ + debt + +create_issue \ + "Resolve eslint peer dependency warning" \ + "eslint-plugin-react-hooks 7.0.1 expects eslint ^3.0.0-^9.0.0 but found 10.0.3. Resolve the peer dependency mismatch." \ + debt + +# ── security ── + +create_issue \ + "Rate limiting on API endpoints" \ + "Add rate limiting to prevent abuse. At minimum: auth endpoints (brute force prevention), game endpoints (spam prevention). Consider express-rate-limit or similar." \ + security + +# ── ux ── + +create_issue \ + "404/redirect handling for unknown routes and subdomains" \ + "Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider Caddy fallback for unrecognized subdomains." \ + ux + +create_issue \ + "React error boundaries" \ + "Add error boundaries to catch and display runtime errors gracefully instead of crashing the entire app." \ + ux + +create_issue \ + "Accessibility pass" \ + "Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow." \ + ux + +create_issue \ + "Favicon, page titles, Open Graph meta" \ + "Add favicon, set proper page titles per route, add Open Graph meta tags for link previews when sharing." \ + ux + +# ── multiplayer ── + +create_issue \ + "Drizzle schema: lobbies, lobby_players + migration" \ + "Create lobbies table (id, code, host_user_id, status, is_private, game_mode, settings, created_at) and lobby_players table (lobby_id, user_id, score, joined_at). Run migration. See game-modes.md for game_mode values." \ + multiplayer + +create_issue \ + "REST endpoints: POST /lobbies, POST /lobbies/:code/join" \ + "Create lobby (generates short code, sets host) and join lobby (validates code, adds player, enforces max limit)." \ + multiplayer + +create_issue \ + "LobbyService: create lobby, join lobby, enforce player limit" \ + "Service layer for lobby management. Generate human-readable codes, validate join requests, track lobby state. Public lobbies are browsable, private lobbies require code." \ + multiplayer + +create_issue \ + "WebSocket server: attach ws upgrade to Express" \ + "Attach ws library upgrade handler to the existing Express HTTP server. Handle connection lifecycle." \ + multiplayer + +create_issue \ + "WS auth middleware: validate session on upgrade" \ + "Validate Better Auth session on WebSocket upgrade request. Reject unauthenticated connections." \ + multiplayer + +create_issue \ + "WS message router: dispatch by type" \ + "Route incoming WebSocket messages by their type field to the appropriate handler. Use Zod discriminated union for type safety." \ + multiplayer + +create_issue \ + "Lobby join/leave handlers + broadcast lobby state" \ + "Handle lobby:join and lobby:leave WebSocket events. Broadcast updated player list to all connected players in the lobby." \ + multiplayer + +create_issue \ + "Lobby state in Valkey (ephemeral) + PostgreSQL (durable)" \ + "Store live lobby state (connected players, current question, timer) in Valkey. Store durable records (who played, final scores) in PostgreSQL." \ + multiplayer + +create_issue \ + "WS event Zod schemas in packages/shared" \ + "Define all WebSocket message types as Zod discriminated unions in packages/shared. Covers lobby events (join, leave, start) and game events (question, answer, result, finished)." \ + multiplayer + +create_issue \ + "Frontend: lobby browser + create/join lobby" \ + "Lobby list showing public open lobbies. Create lobby form (game mode, public/private). Join-by-code input for private lobbies." \ + multiplayer + +create_issue \ + "Frontend: lobby view (player list, code, start game)" \ + "Show lobby code, connected players, game mode. Host sees Start Game button. Players see waiting state. Real-time updates via WebSocket." \ + multiplayer + +create_issue \ + "Frontend: WS client singleton with reconnect" \ + "WebSocket client that maintains a single connection, handles reconnection on disconnect, and dispatches incoming messages to the appropriate state handlers." \ + multiplayer + +create_issue \ + "GameService: question sequence + server timer" \ + "Generate question sequence for a lobby game. Enforce per-question timer (e.g. 15s). Timer logic varies by game mode — see game-modes.md." \ + multiplayer + +create_issue \ + "lobby:start WS handler — broadcast first question" \ + "When host starts the game, generate questions, change lobby status to in_progress, broadcast first question to all players." \ + multiplayer + +create_issue \ + "game:answer WS handler — collect answers" \ + "Receive player answers via WebSocket. Track who has answered. Behavior varies by game mode (simultaneous vs turn-based vs buzzer)." \ + multiplayer + +create_issue \ + "Answer evaluation + broadcast results" \ + "On all-answered or timeout: evaluate answers, calculate scores, broadcast game:answer_result to all players. Then send next question or end game." \ + multiplayer + +create_issue \ + "Game finished: broadcast results, update DB" \ + "After final round: broadcast game:finished with final scores and winner. Write game results to PostgreSQL (transactional). Change lobby status to finished." \ + multiplayer + +create_issue \ + "Frontend: multiplayer game route" \ + "Route for active multiplayer games. Receives questions and results via WebSocket. Reuses QuestionCard and OptionButton components." \ + multiplayer + +create_issue \ + "Frontend: countdown timer component" \ + "Visual countdown timer synchronized with server timer. Shows remaining seconds per question." \ + multiplayer + +create_issue \ + "Frontend: ScoreBoard component (live per-player scores)" \ + "Displays live scores for all players during a multiplayer game. Updates in real-time via WebSocket." \ + multiplayer + +create_issue \ + "Frontend: GameFinished screen" \ + "Winner highlight, final scores, play again option. Returns to lobby on play again." \ + multiplayer + +create_issue \ + "Multiplayer GameService unit tests" \ + "Unit tests for round evaluation, scoring, tie-breaking, timeout handling across different game modes." \ + multiplayer + +create_issue \ + "Graceful WS reconnect with exponential back-off" \ + "Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if game is still in progress." \ + multiplayer + +echo "" +echo "=== Done ==="