Compare commits
18 commits
main
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce19740cc8 | ||
|
|
a6d8ddec3b | ||
|
|
7f56ad89e6 | ||
|
|
745c5c4e3a | ||
|
|
b0aef8cc16 | ||
|
|
93cf14857f | ||
|
|
4d1ebe2450 | ||
|
|
8c241636bf | ||
|
|
cf56399a5e | ||
|
|
47a68c0315 | ||
|
|
a7be7152cc | ||
|
|
fe0315938a | ||
|
|
fbc611c49f | ||
|
|
fef7c82a3e | ||
|
|
2cb16ed5f0 | ||
|
|
1b02f6ce8e | ||
|
|
8d35876838 | ||
|
|
69d4cfde97 |
46 changed files with 2945 additions and 325 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/src/server.js",
|
"start": "node dist/src/server.js",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
|
|
@ -14,12 +14,14 @@
|
||||||
"@lila/shared": "workspace:*",
|
"@lila/shared": "workspace:*",
|
||||||
"better-auth": "^1.6.2",
|
"better-auth": "^1.6.2",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
import type { GameSession, AnswerResult } from "@lila/shared";
|
||||||
|
|
||||||
|
type SuccessResponse<T> = { success: true; data: T };
|
||||||
|
type ErrorResponse = { success: false; error: string };
|
||||||
|
type GameStartResponse = SuccessResponse<GameSession>;
|
||||||
|
type GameAnswerResponse = SuccessResponse<AnswerResult>;
|
||||||
|
|
||||||
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -33,49 +39,48 @@ beforeEach(() => {
|
||||||
describe("POST /api/v1/game/start", () => {
|
describe("POST /api/v1/game/start", () => {
|
||||||
it("returns 200 with a valid game session", async () => {
|
it("returns 200 with a valid game session", async () => {
|
||||||
const res = await request(app).post("/api/v1/game/start").send(validBody);
|
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.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(res.body.data.sessionId).toBeDefined();
|
expect(body.data.sessionId).toBeDefined();
|
||||||
expect(res.body.data.questions).toHaveLength(3);
|
expect(body.data.questions).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when the body is empty", async () => {
|
it("returns 400 when the body is empty", async () => {
|
||||||
const res = await request(app).post("/api/v1/game/start").send({});
|
const res = await request(app).post("/api/v1/game/start").send({});
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
expect(res.body.error).toBeDefined();
|
expect(body.error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when required fields are missing", async () => {
|
it("returns 400 when required fields are missing", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/v1/game/start")
|
.post("/api/v1/game/start")
|
||||||
.send({ source_language: "en" });
|
.send({ source_language: "en" });
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(400);
|
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 () => {
|
it("returns 400 when a field has an invalid value", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/v1/game/start")
|
.post("/api/v1/game/start")
|
||||||
.send({ ...validBody, difficulty: "impossible" });
|
.send({ ...validBody, difficulty: "impossible" });
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/v1/game/answer", () => {
|
describe("POST /api/v1/game/answer", () => {
|
||||||
it("returns 200 with an answer result for a valid submission", async () => {
|
it("returns 200 with an answer result for a valid submission", async () => {
|
||||||
// Start a game first
|
|
||||||
const startRes = await request(app)
|
const startRes = await request(app)
|
||||||
.post("/api/v1/game/start")
|
.post("/api/v1/game/start")
|
||||||
.send(validBody);
|
.send(validBody);
|
||||||
|
const startBody = startRes.body as GameStartResponse;
|
||||||
const { sessionId, questions } = startRes.body.data;
|
const { sessionId, questions } = startBody.data;
|
||||||
const question = questions[0];
|
const question = questions[0]!;
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/v1/game/answer")
|
.post("/api/v1/game/answer")
|
||||||
|
|
@ -84,20 +89,20 @@ describe("POST /api/v1/game/answer", () => {
|
||||||
questionId: question.questionId,
|
questionId: question.questionId,
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
});
|
});
|
||||||
|
const body = res.body as GameAnswerResponse;
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(res.body.data.questionId).toBe(question.questionId);
|
expect(body.data.questionId).toBe(question.questionId);
|
||||||
expect(typeof res.body.data.isCorrect).toBe("boolean");
|
expect(typeof body.data.isCorrect).toBe("boolean");
|
||||||
expect(typeof res.body.data.correctOptionId).toBe("number");
|
expect(typeof body.data.correctOptionId).toBe("number");
|
||||||
expect(res.body.data.selectedOptionId).toBe(0);
|
expect(body.data.selectedOptionId).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when the body is empty", async () => {
|
it("returns 400 when the body is empty", async () => {
|
||||||
const res = await request(app).post("/api/v1/game/answer").send({});
|
const res = await request(app).post("/api/v1/game/answer").send({});
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(400);
|
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 () => {
|
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",
|
questionId: "00000000-0000-0000-0000-000000000000",
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
});
|
});
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
expect(res.body.error).toContain("Game session not found");
|
expect(body.error).toContain("Game session not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 when the question does not exist in the session", async () => {
|
it("returns 404 when the question does not exist in the session", async () => {
|
||||||
const startRes = await request(app)
|
const startRes = await request(app)
|
||||||
.post("/api/v1/game/start")
|
.post("/api/v1/game/start")
|
||||||
.send(validBody);
|
.send(validBody);
|
||||||
|
const startBody = startRes.body as GameStartResponse;
|
||||||
const { sessionId } = startRes.body.data;
|
const { sessionId } = startBody.data;
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/v1/game/answer")
|
.post("/api/v1/game/answer")
|
||||||
|
|
@ -128,9 +133,9 @@ describe("POST /api/v1/game/answer", () => {
|
||||||
questionId: "00000000-0000-0000-0000-000000000000",
|
questionId: "00000000-0000-0000-0000-000000000000",
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
});
|
});
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
expect(res.body.error).toContain("Question not found");
|
expect(body.error).toContain("Question not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
37
apps/api/src/controllers/lobbyController.ts
Normal file
37
apps/api/src/controllers/lobbyController.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { createLobby, joinLobby } from "../services/lobbyService.js";
|
||||||
|
|
||||||
|
export const createLobbyHandler = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session!.user.id;
|
||||||
|
const lobby = await createLobby(userId);
|
||||||
|
res.json({ success: true, data: lobby });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const joinLobbyHandler = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session!.user.id;
|
||||||
|
const code = req.params["code"];
|
||||||
|
if (!code) {
|
||||||
|
return next(new Error("Missing code param"));
|
||||||
|
}
|
||||||
|
if (typeof code !== "string") {
|
||||||
|
return next(new Error("Missing or invalid code param"));
|
||||||
|
}
|
||||||
|
const lobby = await joinLobby(code, userId);
|
||||||
|
res.json({ success: true, data: lobby });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -19,3 +19,9 @@ export class NotFoundError extends AppError {
|
||||||
super(message, 404);
|
super(message, 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,17 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
||||||
export class InMemoryGameSessionStore implements GameSessionStore {
|
export class InMemoryGameSessionStore implements GameSessionStore {
|
||||||
private sessions = new Map<string, GameSessionData>();
|
private sessions = new Map<string, GameSessionData>();
|
||||||
|
|
||||||
async create(sessionId: string, data: GameSessionData): Promise<void> {
|
create(sessionId: string, data: GameSessionData): Promise<void> {
|
||||||
this.sessions.set(sessionId, data);
|
this.sessions.set(sessionId, data);
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string): Promise<GameSessionData | null> {
|
get(sessionId: string): Promise<GameSessionData | null> {
|
||||||
return this.sessions.get(sessionId) ?? null;
|
return Promise.resolve(this.sessions.get(sessionId) ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(sessionId: string): Promise<void> {
|
delete(sessionId: string): Promise<void> {
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts
Normal file
27
apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
|
||||||
|
|
||||||
|
export class InMemoryLobbyGameStore implements LobbyGameStore {
|
||||||
|
private games = new Map<string, LobbyGameData>();
|
||||||
|
|
||||||
|
create(lobbyId: string, data: LobbyGameData): Promise<void> {
|
||||||
|
if (this.games.has(lobbyId)) {
|
||||||
|
throw new Error(`Game already exists for lobby: ${lobbyId}`);
|
||||||
|
}
|
||||||
|
this.games.set(lobbyId, data);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(lobbyId: string): Promise<LobbyGameData | null> {
|
||||||
|
return Promise.resolve(this.games.get(lobbyId) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(lobbyId: string, data: LobbyGameData): Promise<void> {
|
||||||
|
this.games.set(lobbyId, data);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(lobbyId: string): Promise<void> {
|
||||||
|
this.games.delete(lobbyId);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/api/src/lobbyGameStore/LobbyGameStore.ts
Normal file
18
apps/api/src/lobbyGameStore/LobbyGameStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { MultiplayerQuestion } from "../services/multiplayerGameService.js";
|
||||||
|
|
||||||
|
export type LobbyGameData = {
|
||||||
|
code: string;
|
||||||
|
questions: MultiplayerQuestion[];
|
||||||
|
currentIndex: number;
|
||||||
|
// NOTE: Map types are used here for O(1) lookups in-process.
|
||||||
|
// When migrating to Valkey, convert to plain objects for JSON serialization.
|
||||||
|
playerAnswers: Map<string, number | null>; // userId → selectedOptionId, null = timed out
|
||||||
|
scores: Map<string, number>; // userId → running total
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LobbyGameStore {
|
||||||
|
create(lobbyId: string, data: LobbyGameData): Promise<void>;
|
||||||
|
get(lobbyId: string): Promise<LobbyGameData | null>;
|
||||||
|
set(lobbyId: string, data: LobbyGameData): Promise<void>;
|
||||||
|
delete(lobbyId: string): Promise<void>;
|
||||||
|
}
|
||||||
2
apps/api/src/lobbyGameStore/index.ts
Normal file
2
apps/api/src/lobbyGameStore/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
|
||||||
|
export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js";
|
||||||
|
|
@ -16,5 +16,7 @@ export const requireAuth = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.session = session;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import express from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { healthRouter } from "./healthRouter.js";
|
import { healthRouter } from "./healthRouter.js";
|
||||||
import { gameRouter } from "./gameRouter.js";
|
import { gameRouter } from "./gameRouter.js";
|
||||||
|
import { lobbyRouter } from "./lobbyRouter.js";
|
||||||
|
|
||||||
export const apiRouter: Router = express.Router();
|
export const apiRouter: Router = express.Router();
|
||||||
|
|
||||||
apiRouter.use("/health", healthRouter);
|
apiRouter.use("/health", healthRouter);
|
||||||
apiRouter.use("/game", gameRouter);
|
apiRouter.use("/game", gameRouter);
|
||||||
|
apiRouter.use("/lobbies", lobbyRouter);
|
||||||
|
|
|
||||||
14
apps/api/src/routes/lobbyRouter.ts
Normal file
14
apps/api/src/routes/lobbyRouter.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import express from "express";
|
||||||
|
import type { Router } from "express";
|
||||||
|
import {
|
||||||
|
createLobbyHandler,
|
||||||
|
joinLobbyHandler,
|
||||||
|
} from "../controllers/lobbyController.js";
|
||||||
|
import { requireAuth } from "../middleware/authMiddleware.js";
|
||||||
|
|
||||||
|
export const lobbyRouter: Router = express.Router();
|
||||||
|
|
||||||
|
lobbyRouter.use(requireAuth);
|
||||||
|
|
||||||
|
lobbyRouter.post("/", createLobbyHandler);
|
||||||
|
lobbyRouter.post("/:code/join", joinLobbyHandler);
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
|
import { createServer } from "http";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
|
import { setupWebSocket } from "./ws/index.js";
|
||||||
|
|
||||||
const PORT = Number(process.env["PORT"] ?? 3000);
|
const PORT = Number(process.env["PORT"] ?? 3000);
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
|
setupWebSocket(server);
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server listening on port ${PORT}`);
|
console.log(`Server listening on port ${PORT}`);
|
||||||
|
|
|
||||||
70
apps/api/src/services/lobbyService.ts
Normal file
70
apps/api/src/services/lobbyService.ts
Normal file
|
|
@ -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<Lobby> => {
|
||||||
|
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<LobbyWithPlayers> => {
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(code);
|
||||||
|
if (!lobby) {
|
||||||
|
throw new NotFoundError(`Lobby not found: ${code}`);
|
||||||
|
}
|
||||||
|
if (lobby.status !== "waiting") {
|
||||||
|
throw new ConflictError("Game has already started");
|
||||||
|
}
|
||||||
|
if (lobby.players.some((p) => p.userId === userId)) {
|
||||||
|
return lobby; // idempotent: already in lobby
|
||||||
|
}
|
||||||
|
if (lobby.players.length >= MAX_LOBBY_PLAYERS) {
|
||||||
|
throw new ConflictError("Lobby is full");
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = await addPlayer(lobby.id, userId, MAX_LOBBY_PLAYERS);
|
||||||
|
if (!player) {
|
||||||
|
// Race fallback: another request filled the last slot, started the game,
|
||||||
|
// or the user joined concurrently. Pre-checks above handle the common cases.
|
||||||
|
throw new ConflictError("Lobby is no longer available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = await getLobbyByCodeWithPlayers(code);
|
||||||
|
if (!fresh) {
|
||||||
|
throw new AppError("Lobby disappeared during join", 500);
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
};
|
||||||
75
apps/api/src/services/multiplayerGameService.ts
Normal file
75
apps/api/src/services/multiplayerGameService.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { getGameTerms, getDistractors } from "@lila/db";
|
||||||
|
import type {
|
||||||
|
GameQuestion,
|
||||||
|
AnswerOption,
|
||||||
|
SupportedLanguageCode,
|
||||||
|
SupportedPos,
|
||||||
|
DifficultyLevel,
|
||||||
|
} from "@lila/shared";
|
||||||
|
|
||||||
|
// TODO(game-mode-slice): replace with lobby settings when mode selection lands
|
||||||
|
const MULTIPLAYER_DEFAULTS = {
|
||||||
|
sourceLanguage: "en" as SupportedLanguageCode,
|
||||||
|
targetLanguage: "it" as SupportedLanguageCode,
|
||||||
|
pos: "noun" as SupportedPos,
|
||||||
|
difficulty: "easy" as DifficultyLevel,
|
||||||
|
rounds: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const shuffle = <T>(array: T[]): T[] => {
|
||||||
|
const result = [...array];
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
const temp = result[i]!;
|
||||||
|
result[i] = result[j]!;
|
||||||
|
result[j] = temp;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MultiplayerQuestion = GameQuestion & { correctOptionId: number };
|
||||||
|
|
||||||
|
export const generateMultiplayerQuestions = async (): Promise<
|
||||||
|
MultiplayerQuestion[]
|
||||||
|
> => {
|
||||||
|
const correctAnswers = await getGameTerms(
|
||||||
|
MULTIPLAYER_DEFAULTS.sourceLanguage,
|
||||||
|
MULTIPLAYER_DEFAULTS.targetLanguage,
|
||||||
|
MULTIPLAYER_DEFAULTS.pos,
|
||||||
|
MULTIPLAYER_DEFAULTS.difficulty,
|
||||||
|
MULTIPLAYER_DEFAULTS.rounds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const questions: MultiplayerQuestion[] = await Promise.all(
|
||||||
|
correctAnswers.map(async (correctAnswer) => {
|
||||||
|
const distractorTexts = await getDistractors(
|
||||||
|
correctAnswer.termId,
|
||||||
|
correctAnswer.targetText,
|
||||||
|
MULTIPLAYER_DEFAULTS.targetLanguage,
|
||||||
|
MULTIPLAYER_DEFAULTS.pos,
|
||||||
|
MULTIPLAYER_DEFAULTS.difficulty,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
|
||||||
|
const shuffledTexts = shuffle(optionTexts);
|
||||||
|
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
|
||||||
|
|
||||||
|
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
|
||||||
|
optionId: index,
|
||||||
|
text,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
questionId: randomUUID(),
|
||||||
|
prompt: correctAnswer.sourceText,
|
||||||
|
gloss: correctAnswer.sourceGloss,
|
||||||
|
options,
|
||||||
|
correctOptionId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return questions;
|
||||||
|
};
|
||||||
17
apps/api/src/types/express.d.ts
vendored
Normal file
17
apps/api/src/types/express.d.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Session, User } from "better-auth";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
session?: { session: Session; user: User };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "ws" {
|
||||||
|
interface WebSocket {
|
||||||
|
lobbyId?: string | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
32
apps/api/src/ws/auth.ts
Normal file
32
apps/api/src/ws/auth.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
import type { Duplex } from "stream";
|
||||||
|
import type { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { fromNodeHeaders } from "better-auth/node";
|
||||||
|
import { auth } from "../lib/auth.js";
|
||||||
|
|
||||||
|
export const handleUpgrade = async (
|
||||||
|
request: IncomingMessage,
|
||||||
|
socket: Duplex,
|
||||||
|
head: Buffer,
|
||||||
|
wss: WebSocketServer,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: fromNodeHeaders(request.headers),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||||
|
wss.emit("connection", ws, request, session);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("WebSocket auth error:", err);
|
||||||
|
socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
44
apps/api/src/ws/connections.ts
Normal file
44
apps/api/src/ws/connections.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
|
||||||
|
// Map<lobbyId, Map<userId, WebSocket>>
|
||||||
|
const connections = new Map<string, Map<string, WebSocket>>();
|
||||||
|
|
||||||
|
export const addConnection = (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocket,
|
||||||
|
): void => {
|
||||||
|
if (!connections.has(lobbyId)) {
|
||||||
|
connections.set(lobbyId, new Map());
|
||||||
|
}
|
||||||
|
connections.get(lobbyId)!.set(userId, ws);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeConnection = (lobbyId: string, userId: string): void => {
|
||||||
|
const lobby = connections.get(lobbyId);
|
||||||
|
if (!lobby) return;
|
||||||
|
lobby.delete(userId);
|
||||||
|
if (lobby.size === 0) {
|
||||||
|
connections.delete(lobbyId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConnections = (lobbyId: string): Map<string, WebSocket> => {
|
||||||
|
return connections.get(lobbyId) ?? new Map<string, WebSocket>();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const broadcastToLobby = (
|
||||||
|
lobbyId: string,
|
||||||
|
message: unknown,
|
||||||
|
excludeUserId?: string,
|
||||||
|
): void => {
|
||||||
|
const lobby = connections.get(lobbyId);
|
||||||
|
if (!lobby) return;
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
for (const [userId, ws] of lobby) {
|
||||||
|
if (excludeUserId && userId === excludeUserId) continue;
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
4
apps/api/src/ws/gameState.ts
Normal file
4
apps/api/src/ws/gameState.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js";
|
||||||
|
|
||||||
|
export const lobbyGameStore = new InMemoryLobbyGameStore();
|
||||||
|
export const timers = new Map<string, NodeJS.Timeout>();
|
||||||
187
apps/api/src/ws/handlers/gameHandlers.ts
Normal file
187
apps/api/src/ws/handlers/gameHandlers.ts
Normal file
|
|
@ -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<void> => {
|
||||||
|
const state = await lobbyGameStore.get(msg.lobbyId);
|
||||||
|
if (!state) {
|
||||||
|
throw new NotFoundError("Game not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuestion = state.questions[state.currentIndex];
|
||||||
|
if (!currentQuestion) {
|
||||||
|
throw new ConflictError("No active question");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject stale answers
|
||||||
|
if (currentQuestion.questionId !== msg.questionId) {
|
||||||
|
throw new ConflictError("Answer is for wrong question");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject duplicate answers
|
||||||
|
if (state.playerAnswers.has(user.id)) {
|
||||||
|
throw new ConflictError("Already answered this question");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store answer
|
||||||
|
state.playerAnswers.set(user.id, msg.selectedOptionId);
|
||||||
|
await lobbyGameStore.set(msg.lobbyId, state);
|
||||||
|
|
||||||
|
// Check if all connected players have answered
|
||||||
|
const connected = getConnections(msg.lobbyId);
|
||||||
|
const allAnswered = [...connected.keys()].every((userId) =>
|
||||||
|
state.playerAnswers.has(userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allAnswered) {
|
||||||
|
// Clear timer — no need to wait
|
||||||
|
const timer = timers.get(msg.lobbyId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(msg.lobbyId);
|
||||||
|
}
|
||||||
|
await resolveRound(msg.lobbyId, state.currentIndex, state.questions.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveRound = async (
|
||||||
|
lobbyId: string,
|
||||||
|
questionIndex: number,
|
||||||
|
totalQuestions: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
const state = await lobbyGameStore.get(lobbyId);
|
||||||
|
if (!state) return; // lobby was deleted mid-round, nothing to do
|
||||||
|
|
||||||
|
const currentQuestion = state.questions[questionIndex];
|
||||||
|
if (!currentQuestion) return;
|
||||||
|
|
||||||
|
// Fill null for any players who didn't answer (timed out)
|
||||||
|
const connected = getConnections(lobbyId);
|
||||||
|
for (const userId of connected.keys()) {
|
||||||
|
if (!state.playerAnswers.has(userId)) {
|
||||||
|
state.playerAnswers.set(userId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate answers and update scores
|
||||||
|
const results: {
|
||||||
|
userId: string;
|
||||||
|
selectedOptionId: number | null;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const [userId, selectedOptionId] of state.playerAnswers) {
|
||||||
|
const isCorrect =
|
||||||
|
selectedOptionId !== null &&
|
||||||
|
selectedOptionId === currentQuestion.correctOptionId;
|
||||||
|
if (isCorrect) {
|
||||||
|
state.scores.set(userId, (state.scores.get(userId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
results.push({ userId, selectedOptionId, isCorrect });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build updated players array for broadcast
|
||||||
|
const players = [...state.scores.entries()].map(([userId, score]) => ({
|
||||||
|
userId,
|
||||||
|
score,
|
||||||
|
lobbyId,
|
||||||
|
user: { id: userId, name: userId }, // name resolved below
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Resolve user names from DB
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(state.code);
|
||||||
|
|
||||||
|
const namedPlayers = players.map((p) => {
|
||||||
|
const dbPlayer = lobby?.players.find((dp) => dp.userId === p.userId);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
user: { id: p.userId, name: dbPlayer?.user.name ?? p.userId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast answer result
|
||||||
|
broadcastToLobby(lobbyId, {
|
||||||
|
type: "game:answer_result",
|
||||||
|
correctOptionId: currentQuestion.correctOptionId,
|
||||||
|
results,
|
||||||
|
players: namedPlayers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save updated state
|
||||||
|
state.playerAnswers = new Map();
|
||||||
|
state.currentIndex = questionIndex + 1;
|
||||||
|
await lobbyGameStore.set(lobbyId, state);
|
||||||
|
|
||||||
|
const isLastRound = questionIndex + 1 >= totalQuestions;
|
||||||
|
|
||||||
|
if (isLastRound) {
|
||||||
|
await endGame(lobbyId, state);
|
||||||
|
} else {
|
||||||
|
// Wait 3s then broadcast next question
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const fresh = await lobbyGameStore.get(lobbyId);
|
||||||
|
if (!fresh) return;
|
||||||
|
const nextQuestion = fresh.questions[fresh.currentIndex];
|
||||||
|
if (!nextQuestion) return;
|
||||||
|
broadcastToLobby(lobbyId, {
|
||||||
|
type: "game:question",
|
||||||
|
question: {
|
||||||
|
questionId: nextQuestion.questionId,
|
||||||
|
prompt: nextQuestion.prompt,
|
||||||
|
gloss: nextQuestion.gloss,
|
||||||
|
options: nextQuestion.options,
|
||||||
|
},
|
||||||
|
questionNumber: fresh.currentIndex + 1,
|
||||||
|
totalQuestions,
|
||||||
|
});
|
||||||
|
// Restart timer for next round
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void resolveRound(lobbyId, fresh.currentIndex, totalQuestions);
|
||||||
|
}, 15000);
|
||||||
|
timers.set(lobbyId, timer);
|
||||||
|
})();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endGame = async (
|
||||||
|
lobbyId: string,
|
||||||
|
state: Awaited<ReturnType<typeof lobbyGameStore.get>> & {},
|
||||||
|
): Promise<void> => {
|
||||||
|
// Persist final scores to DB
|
||||||
|
await finishGame(lobbyId, state.scores);
|
||||||
|
|
||||||
|
// Determine winners (handle ties)
|
||||||
|
const maxScore = Math.max(...state.scores.values());
|
||||||
|
const winnerIds = [...state.scores.entries()]
|
||||||
|
.filter(([, score]) => score === maxScore)
|
||||||
|
.map(([userId]) => userId);
|
||||||
|
|
||||||
|
// Build final players array
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(state.code);
|
||||||
|
|
||||||
|
const players = [...state.scores.entries()].map(([userId, score]) => {
|
||||||
|
const dbPlayer = lobby?.players.find((p) => p.userId === userId);
|
||||||
|
return {
|
||||||
|
lobbyId,
|
||||||
|
userId,
|
||||||
|
score,
|
||||||
|
user: { id: userId, name: dbPlayer?.user.name ?? userId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastToLobby(lobbyId, { type: "game:finished", players, winnerIds });
|
||||||
|
|
||||||
|
// Clean up game state
|
||||||
|
await lobbyGameStore.delete(lobbyId);
|
||||||
|
timers.delete(lobbyId);
|
||||||
|
};
|
||||||
157
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
157
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
|
|
@ -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<void> => {
|
||||||
|
// Load lobby and validate membership
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(msg.code);
|
||||||
|
if (!lobby) {
|
||||||
|
throw new NotFoundError("Lobby not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lobby.status !== "waiting") {
|
||||||
|
throw new ConflictError("Lobby is not in waiting state");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lobby.players.some((p) => p.userId === user.id)) {
|
||||||
|
throw new ConflictError("You are not a member of this lobby");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register connection and tag the socket with lobbyId
|
||||||
|
addConnection(lobby.id, user.id, ws);
|
||||||
|
ws.lobbyId = lobby.id;
|
||||||
|
|
||||||
|
// Broadcast updated lobby state to all players
|
||||||
|
broadcastToLobby(lobby.id, { type: "lobby:state", lobby });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLobbyLeave = async (
|
||||||
|
ws: WebSocket,
|
||||||
|
msg: WsLobbyLeave,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId);
|
||||||
|
if (!lobby) return;
|
||||||
|
|
||||||
|
removeConnection(msg.lobbyId, user.id);
|
||||||
|
ws.lobbyId = undefined;
|
||||||
|
|
||||||
|
if (lobby.hostUserId === user.id) {
|
||||||
|
await deleteLobby(msg.lobbyId);
|
||||||
|
broadcastToLobby(msg.lobbyId, {
|
||||||
|
type: "error",
|
||||||
|
code: "LOBBY_CLOSED",
|
||||||
|
message: "Host left the lobby",
|
||||||
|
});
|
||||||
|
for (const player of lobby.players) {
|
||||||
|
removeConnection(msg.lobbyId, player.userId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await removePlayer(msg.lobbyId, user.id);
|
||||||
|
const updated = await getLobbyByCodeWithPlayers(lobby.code);
|
||||||
|
if (!updated) return;
|
||||||
|
broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated });
|
||||||
|
|
||||||
|
// TODO(reconnection-slice): if lobby.status === 'in_progress', the game
|
||||||
|
// continues with remaining players. If only one player remains after this
|
||||||
|
// leave, end the game immediately and declare them winner. Currently we
|
||||||
|
// broadcast updated lobby state and let the game resolve naturally via
|
||||||
|
// timeouts — the disconnected player's answers will be null each round.
|
||||||
|
// When reconnection handling is added, this is the place to change.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLobbyStart = async (
|
||||||
|
_ws: WebSocket,
|
||||||
|
msg: WsLobbyStart,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Load lobby and validate
|
||||||
|
const lobby = await 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<string, number>();
|
||||||
|
for (const userId of connected.keys()) {
|
||||||
|
scores.set(userId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize game state
|
||||||
|
await lobbyGameStore.create(msg.lobbyId, {
|
||||||
|
code: lobby.code,
|
||||||
|
questions,
|
||||||
|
currentIndex: 0,
|
||||||
|
playerAnswers: new Map(),
|
||||||
|
scores,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update lobby status in DB
|
||||||
|
await updateLobbyStatus(msg.lobbyId, "in_progress");
|
||||||
|
|
||||||
|
// Broadcast first question
|
||||||
|
const firstQuestion = questions[0]!;
|
||||||
|
broadcastToLobby(msg.lobbyId, {
|
||||||
|
type: "game:question",
|
||||||
|
question: {
|
||||||
|
questionId: firstQuestion.questionId,
|
||||||
|
prompt: firstQuestion.prompt,
|
||||||
|
gloss: firstQuestion.gloss,
|
||||||
|
options: firstQuestion.options,
|
||||||
|
},
|
||||||
|
questionNumber: 1,
|
||||||
|
totalQuestions: questions.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start 15s timer
|
||||||
|
startRoundTimer(msg.lobbyId, 0, questions.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRoundTimer = (
|
||||||
|
lobbyId: string,
|
||||||
|
questionIndex: number,
|
||||||
|
totalQuestions: number,
|
||||||
|
): void => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => {
|
||||||
|
console.error("Error resolving round after timeout:", err);
|
||||||
|
});
|
||||||
|
}, 15000);
|
||||||
|
timers.set(lobbyId, timer);
|
||||||
|
};
|
||||||
65
apps/api/src/ws/index.ts
Normal file
65
apps/api/src/ws/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import type { Server } from "http";
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
import { handleUpgrade } from "./auth.js";
|
||||||
|
import { handleMessage, type AuthenticatedUser } from "./router.js";
|
||||||
|
import { removeConnection } from "./connections.js";
|
||||||
|
import { handleLobbyLeave } from "./handlers/lobbyHandlers.js";
|
||||||
|
|
||||||
|
export const setupWebSocket = (server: Server): WebSocketServer => {
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on("upgrade", (request, socket, head) => {
|
||||||
|
if (request.url !== "/ws") {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleUpgrade(request, socket, head, wss).catch((err) => {
|
||||||
|
console.error("WebSocket upgrade error:", err);
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on(
|
||||||
|
"connection",
|
||||||
|
(ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => {
|
||||||
|
ws.on("message", (rawData) => {
|
||||||
|
void handleMessage(ws, rawData, auth).catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`WebSocket message error for user ${auth.user.id}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
void handleDisconnect(ws, auth).catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`WebSocket disconnect error for user ${auth.user.id}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
console.error(`WebSocket error for user ${auth.user.id}:`, err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async (
|
||||||
|
ws: WebSocket,
|
||||||
|
auth: AuthenticatedUser,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!ws.lobbyId) return; // user connected but never joined a lobby
|
||||||
|
removeConnection(ws.lobbyId, auth.user.id);
|
||||||
|
await handleLobbyLeave(
|
||||||
|
ws,
|
||||||
|
{ type: "lobby:leave", lobbyId: ws.lobbyId },
|
||||||
|
auth.user,
|
||||||
|
);
|
||||||
|
};
|
||||||
74
apps/api/src/ws/router.ts
Normal file
74
apps/api/src/ws/router.ts
Normal file
|
|
@ -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<void> => {
|
||||||
|
// Layer 1: parse and validate incoming message
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(
|
||||||
|
typeof rawData === "string" ? rawData : (rawData as Buffer).toString(),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = WsClientMessageSchema.safeParse(parsed);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "Invalid message format" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = result.data;
|
||||||
|
|
||||||
|
// Layer 2: dispatch to handler, catch and translate errors
|
||||||
|
try {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "lobby:join":
|
||||||
|
await handleLobbyJoin(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "lobby:leave":
|
||||||
|
await handleLobbyLeave(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "lobby:start":
|
||||||
|
await handleLobbyStart(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "game:answer":
|
||||||
|
await handleGameAnswer(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -24,9 +24,13 @@ const RootLayout = () => {
|
||||||
{session ? (
|
{session ? (
|
||||||
<button
|
<button
|
||||||
className="text-sm text-gray-600 hover:text-gray-900"
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
await signOut();
|
void (async () => {
|
||||||
navigate({ to: "/" });
|
await signOut();
|
||||||
|
void navigate({ to: "/" });
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("Sign out error:", err);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign out ({session.user.name})
|
Sign out ({session.user.name})
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const LoginPage = () => {
|
||||||
if (isPending) return <div className="p-4">Loading...</div>;
|
if (isPending) return <div className="p-4">Loading...</div>;
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
navigate({ to: "/" });
|
void navigate({ to: "/" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,23 +17,25 @@ const LoginPage = () => {
|
||||||
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
||||||
<button
|
<button
|
||||||
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
|
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
signIn.social({
|
void signIn
|
||||||
provider: "github",
|
.social({ provider: "github", callbackURL: window.location.origin })
|
||||||
callbackURL: window.location.origin,
|
.catch((err) => {
|
||||||
})
|
console.error("GitHub sign in error:", err);
|
||||||
}
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Continue with GitHub
|
Continue with GitHub
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
|
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
signIn.social({
|
void signIn
|
||||||
provider: "google",
|
.social({ provider: "google", callbackURL: window.location.origin })
|
||||||
callbackURL: window.location.origin,
|
.catch((err) => {
|
||||||
})
|
console.error("Google sign in error:", err);
|
||||||
}
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ import { ScoreScreen } from "../components/game/ScoreScreen";
|
||||||
import { GameSetup } from "../components/game/GameSetup";
|
import { GameSetup } from "../components/game/GameSetup";
|
||||||
import { authClient } from "../lib/auth-client";
|
import { authClient } from "../lib/auth-client";
|
||||||
|
|
||||||
function Play() {
|
type GameStartResponse = { success: true; data: GameSession };
|
||||||
const API_URL = import.meta.env["VITE_API_URL"] || "";
|
type GameAnswerResponse = { success: true; data: AnswerResult };
|
||||||
|
|
||||||
|
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
||||||
|
|
||||||
|
function Play() {
|
||||||
const [gameSession, setGameSession] = useState<GameSession | null>(null);
|
const [gameSession, setGameSession] = useState<GameSession | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
|
|
@ -17,13 +20,15 @@ function Play() {
|
||||||
|
|
||||||
const startGame = useCallback(async (settings: GameRequest) => {
|
const startGame = useCallback(async (settings: GameRequest) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/api/v1/game/start`, {
|
const response = await fetch(`${API_URL}/api/v1/game/start`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
|
||||||
|
const data = (await response.json()) as GameStartResponse;
|
||||||
setGameSession(data.data);
|
setGameSession(data.data);
|
||||||
setCurrentQuestionIndex(0);
|
setCurrentQuestionIndex(0);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
|
@ -55,7 +60,7 @@ function Play() {
|
||||||
selectedOptionId: optionId,
|
selectedOptionId: optionId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = (await response.json()) as GameAnswerResponse;
|
||||||
setCurrentResult(data.data);
|
setCurrentResult(data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -70,7 +75,13 @@ function Play() {
|
||||||
if (!gameSession && !isLoading) {
|
if (!gameSession && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<GameSetup onStart={startGame} />
|
<GameSetup
|
||||||
|
onStart={(settings) => {
|
||||||
|
void startGame(settings).catch((err) => {
|
||||||
|
console.error("Start game error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -99,11 +110,15 @@ function Play() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<QuestionCard
|
<QuestionCard
|
||||||
|
onAnswer={(optionId) => {
|
||||||
|
void handleAnswer(optionId).catch((err) => {
|
||||||
|
console.error("Answer error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
question={question}
|
question={question}
|
||||||
questionNumber={currentQuestionIndex + 1}
|
questionNumber={currentQuestionIndex + 1}
|
||||||
totalQuestions={gameSession.questions.length}
|
totalQuestions={gameSession.questions.length}
|
||||||
currentResult={currentResult}
|
currentResult={currentResult}
|
||||||
onAnswer={handleAnswer}
|
|
||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -42,11 +42,12 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/api:/app/apps/api # Hot reload API code
|
- ./apps/api:/app/apps/api # Hot reload API code
|
||||||
- ./packages/shared:/app/packages/shared # Hot reload shared
|
- ./packages/shared:/app/packages/shared # Hot reload shared
|
||||||
|
- ./packages/db:/app/packages/db
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
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
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
@ -66,6 +67,7 @@ services:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/web:/app/apps/web # Hot reload: local edits reflect immediately
|
- ./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
|
- /app/node_modules # Protect container's node_modules from being overwritten
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:3000
|
- VITE_API_URL=http://localhost:3000
|
||||||
|
|
|
||||||
|
|
@ -359,3 +359,71 @@ All deferred post-MVP, purely additive (new tables referencing existing `terms`)
|
||||||
- `noun_forms` — gender, singular, plural, articles per language (source: Wiktionary)
|
- `noun_forms` — gender, singular, plural, articles per language (source: Wiktionary)
|
||||||
- `verb_forms` — conjugation tables per language (source: Wiktionary)
|
- `verb_forms` — conjugation tables per language (source: Wiktionary)
|
||||||
- `term_pronunciations` — IPA and audio URLs per language (source: Wiktionary / Forvo)
|
- `term_pronunciations` — IPA and audio URLs per language (source: Wiktionary / Forvo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Reverse proxy: Caddy (not Nginx, not Traefik)
|
||||||
|
|
||||||
|
Caddy provides automatic HTTPS via Let's Encrypt with zero configuration beyond specifying domain names. The entire Caddyfile is ~10 lines. Nginx would require manual certbot setup and more verbose config. Traefik's auto-discovery of Docker containers (via labels) is powerful but overkill for a stable three-service stack where routing rules never change. Caddy runs as a Docker container alongside the app — no native install.
|
||||||
|
|
||||||
|
### Subdomain routing (not path-based)
|
||||||
|
|
||||||
|
`lilastudy.com` serves the frontend, `api.lilastudy.com` serves the API, `git.lilastudy.com` serves Forgejo. Cleaner separation than path-based routing — any service can be moved to a different server just by changing DNS. Requires CORS configuration since the browser sees different origins, and cross-subdomain cookies via `COOKIE_DOMAIN=.lilastudy.com`. Wildcard DNS (`*.lilastudy.com`) means new subdomains require no DNS changes.
|
||||||
|
|
||||||
|
### Frontend served by nginx:alpine (not Node, not Caddy)
|
||||||
|
|
||||||
|
Vite builds to static files. Serving them with nginx inside the container is lighter than running a Node process and keeps the container at ~7MB. Caddy could serve them directly, but using a separate container maintains the one-service-per-container principle and keeps Caddy's config purely about routing.
|
||||||
|
|
||||||
|
### SPA fallback via nginx `try_files`
|
||||||
|
|
||||||
|
Without `try_files $uri $uri/ /index.html`, refreshing on `/play` returns 404 because there's no actual `play` file. Nginx serves `index.html` for all routes and lets TanStack Router handle client-side routing.
|
||||||
|
|
||||||
|
### Forgejo as git server + container registry (not GitHub, not Docker Hub)
|
||||||
|
|
||||||
|
Keeps everything self-hosted on one VPS. Forgejo's built-in package registry doubles as a container registry, eliminating a separate service. Git push and image push go to the same server.
|
||||||
|
|
||||||
|
### Forgejo SSH on port 2222 (not 22)
|
||||||
|
|
||||||
|
Port 22 is the VPS's own SSH. Mapping Forgejo's SSH to 2222 avoids conflicts. Dev laptop `~/.ssh/config` maps `git.lilastudy.com` to port 2222 so git commands work without specifying the port every time.
|
||||||
|
|
||||||
|
### `packages/db` and `packages/shared` exports: compiled JS paths
|
||||||
|
|
||||||
|
Exports in both package.json files point to `./dist/src/index.js`, not TypeScript source. In dev, `tsx` can run TypeScript, but in production Node cannot. This means packages must be built before the API starts in dev — acceptable since these packages change infrequently. Alternative approaches (conditional exports, tsconfig paths) were considered but added complexity for no practical benefit.
|
||||||
|
|
||||||
|
### Environment-driven config for production vs dev
|
||||||
|
|
||||||
|
CORS origin, Better Auth base URL, cookie domain, API URL, and OAuth credentials are all read from environment variables with localhost fallbacks. The same code runs in both environments without changes. `VITE_API_URL` is the exception — it's baked in at build time via Docker build arg because Vite replaces `import.meta.env` at compile time, not runtime.
|
||||||
|
|
||||||
|
### Cross-subdomain cookies
|
||||||
|
|
||||||
|
Better Auth's `defaultCookieAttributes` sets `domain: .lilastudy.com` in production (from env var `COOKIE_DOMAIN`). Without this, the auth cookie scoped to `api.lilastudy.com` wouldn't be sent on requests from `lilastudy.com`. The leading dot makes the cookie valid across all subdomains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
### Forgejo Actions with SSH deploy (not webhooks, not manual)
|
||||||
|
|
||||||
|
CI builds images natively on the ARM64 VPS (no QEMU cross-compilation). The runner uses the host's Docker socket to build. After pushing to the registry, the workflow SSHs into the VPS to pull and restart containers. Webhooks were considered but add an extra listener service to maintain and secure. Manual deploy was the initial approach but doesn't scale with frequent pushes.
|
||||||
|
|
||||||
|
### Dedicated CI SSH key
|
||||||
|
|
||||||
|
A separate `ci-runner` SSH key pair (not the developer's personal key) is used for CI deploys. The private key is stored in Forgejo's secrets. If compromised, only this key needs to be revoked — the developer's access is unaffected.
|
||||||
|
|
||||||
|
### Runner config: `docker_host: "automount"` + `valid_volumes` + explicit config path
|
||||||
|
|
||||||
|
The Forgejo runner's `automount` setting mounts the host Docker socket into job containers. `valid_volumes` must include `/var/run/docker.sock` or the mount is blocked. The runner command must explicitly reference the config file (`-c /data/config.yml`) — without this flag, config changes are silently ignored. `--group-add 989` in container options adds the host's docker group so job containers can access the socket.
|
||||||
|
|
||||||
|
### Docker CLI installed per job (not baked into runner image)
|
||||||
|
|
||||||
|
The job container (`node:24-bookworm`) doesn't include Docker CLI. It's installed via `apt-get install docker.io` as the first workflow step. This adds ~20 seconds per run but avoids maintaining a custom runner image. The CLI sends commands through the mounted socket to the host's Docker engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
### pg_dump cron + dev laptop sync (not WAL archiving, not managed service)
|
||||||
|
|
||||||
|
Daily compressed SQL dumps with 7-day retention. Dev laptop auto-syncs new backups on login via rsync. Simple, portable, sufficient for current scale. WAL archiving gives point-in-time recovery but is complex to set up. Offsite storage (Hetzner Object Storage) is the planned next step — backups on the same VPS don't protect against VPS failure.
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,19 @@ This document describes the production deployment of the lila vocabulary trainer
|
||||||
|
|
||||||
### Subdomain Routing
|
### Subdomain Routing
|
||||||
|
|
||||||
| Subdomain | Service | Container port |
|
| Subdomain | Service | Container port |
|
||||||
|---|---|---|
|
| ------------------- | ------------------------------------- | -------------- |
|
||||||
| `lilastudy.com` | Frontend (nginx serving static files) | 80 |
|
| `lilastudy.com` | Frontend (nginx serving static files) | 80 |
|
||||||
| `api.lilastudy.com` | Express API | 3000 |
|
| `api.lilastudy.com` | Express API | 3000 |
|
||||||
| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 |
|
| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 |
|
||||||
|
|
||||||
### Ports Exposed to the Internet
|
### Ports Exposed to the Internet
|
||||||
|
|
||||||
| Port | Service |
|
| Port | Service |
|
||||||
|---|---|
|
| ---- | -------------------------------- |
|
||||||
| 80 | Caddy (HTTP, redirects to HTTPS) |
|
| 80 | Caddy (HTTP, redirects to HTTPS) |
|
||||||
| 443 | Caddy (HTTPS) |
|
| 443 | Caddy (HTTPS) |
|
||||||
| 2222 | Forgejo SSH (git clone/push) |
|
| 2222 | Forgejo SSH (git clone/push) |
|
||||||
|
|
||||||
All other services (Postgres, API, frontend) communicate only over the internal Docker network.
|
All other services (Postgres, API, frontend) communicate only over the internal Docker network.
|
||||||
|
|
||||||
|
|
|
||||||
83
documentation/game_modes.md
Normal file
83
documentation/game_modes.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Game Modes
|
||||||
|
|
||||||
|
This document describes the planned game modes for lila. Each mode uses the same lobby system and vocabulary data but differs in how answers are submitted, scored, and how a winner is determined.
|
||||||
|
|
||||||
|
The first multiplayer mode to implement is TBD. The lobby infrastructure (create, join, WebSocket connection) is mode-agnostic — adding a new mode means adding new game logic, not changing the lobby.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TV Quiz Show
|
||||||
|
|
||||||
|
**Type:** Multiplayer
|
||||||
|
**Answer model:** Buzzer — first to press gets to answer
|
||||||
|
**Rounds:** Fixed (e.g. 10)
|
||||||
|
|
||||||
|
A question appears for all players. The first player to buzz in gets to answer. If correct, they score a point. If wrong, other players may get a chance to answer (TBD: whether the question passes to the next buzzer or the round ends). The host or a timer controls the pace.
|
||||||
|
|
||||||
|
Key difference from other modes: only one player answers per question. Speed of reaction matters as much as knowledge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Race to the Top
|
||||||
|
|
||||||
|
**Type:** Multiplayer
|
||||||
|
**Answer model:** Simultaneous — all players answer independently
|
||||||
|
**Rounds:** None — play until target score reached
|
||||||
|
|
||||||
|
All players see the same question and answer independently. No fixed round count. The first player to reach a target number of correct answers wins (e.g. 20). Fast-paced and competitive.
|
||||||
|
|
||||||
|
Open questions: what happens if two players hit the target on the same question? Tiebreaker by speed? Shared win?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chain Link
|
||||||
|
|
||||||
|
**Type:** Multiplayer
|
||||||
|
**Answer model:** Turn-based — one player at a time, in rotation
|
||||||
|
**Rounds:** None — play until a player fails
|
||||||
|
|
||||||
|
Players answer in a fixed rotation: Player 1, Player 2, Player 3, then back to Player 1. Each player gets one question per turn. The game continues until a player answers incorrectly — that player is out (or the game ends). Last correct answerer wins, or the game simply ends on the first wrong answer.
|
||||||
|
|
||||||
|
Key difference from other modes: turn-based, not simultaneous. Pressure builds as you wait for your turn.
|
||||||
|
|
||||||
|
Open questions: does the player who answers wrong lose, or does the game just end? If the game continues, does it become elimination?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Elimination Round
|
||||||
|
|
||||||
|
**Type:** Multiplayer
|
||||||
|
**Answer model:** Simultaneous — all players answer independently
|
||||||
|
**Rounds:** Continue until one player remains
|
||||||
|
|
||||||
|
All players see the same question and answer simultaneously. Players who answer incorrectly are eliminated. Rounds continue until only one player is left standing.
|
||||||
|
|
||||||
|
Open questions: what if everyone gets it wrong in the same round? Reset that round? Eliminate nobody? What if it comes down to two players and both get it wrong repeatedly?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cooperative Challenge
|
||||||
|
|
||||||
|
**Type:** Multiplayer
|
||||||
|
**Answer model:** TBD
|
||||||
|
**Rounds:** TBD
|
||||||
|
|
||||||
|
Players work together rather than competing. Concept not yet defined. Possible ideas: shared team score with a target, each player contributes answers to a collective pool, or players take turns and the team survives as long as the chain doesn't break.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Single Player Extended
|
||||||
|
|
||||||
|
**Type:** Singleplayer
|
||||||
|
**Answer model:** TBD
|
||||||
|
**Rounds:** TBD
|
||||||
|
|
||||||
|
An expanded version of the current singleplayer quiz. Concept not yet defined. Possible ideas: longer sessions with increasing difficulty, mixed POS/language rounds, streak bonuses, progress tracking across sessions, or timed challenge mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Impact
|
||||||
|
|
||||||
|
The `lobbies` table includes a `game_mode` column (varchar) with values like `tv_quiz`, `race_to_top`, `chain_link`, `elimination`. Mode-specific settings (e.g. target score for Race to the Top) can be stored in a `settings` jsonb column if needed.
|
||||||
|
|
||||||
|
The singleplayer modes (Single Player Extended) don't require a lobby — they extend the existing singleplayer flow.
|
||||||
|
|
@ -11,24 +11,23 @@
|
||||||
|
|
||||||
verify if hetzner domain needs to be pushed, theres a change on hetzner and some domains need to be migrated
|
verify if hetzner domain needs to be pushed, theres a change on hetzner and some domains need to be migrated
|
||||||
|
|
||||||
|
### redirect or page not found
|
||||||
|
|
||||||
|
subdomains or pages that dont exist should have page not found or should redirect
|
||||||
|
|
||||||
### docker credential helper
|
### docker credential helper
|
||||||
|
|
||||||
WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/config.json'.
|
WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/config.json'.
|
||||||
Configure a credential helper to remove this warning. See
|
Configure a credential helper to remove this warning. See
|
||||||
https://docs.docker.com/go/credential-store/
|
https://docs.docker.com/go/credential-store/
|
||||||
|
|
||||||
|
### docker containers on startup?
|
||||||
|
|
||||||
|
laptop: verify if docker containers run on startup (they shouldnt)
|
||||||
|
|
||||||
### vps setup
|
### vps setup
|
||||||
|
|
||||||
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
|
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
|
||||||
- keep the vps clean (e.g. old docker images/containers)
|
|
||||||
|
|
||||||
### cd/ci pipeline
|
|
||||||
|
|
||||||
forgejo actions? smth else? where docker registry, also forgejo?
|
|
||||||
|
|
||||||
### postgres backups
|
|
||||||
|
|
||||||
how?
|
|
||||||
|
|
||||||
### try now option
|
### try now option
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ Each phase produces a working increment. Nothing is built speculatively.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — Auth
|
## Phase 3 — Auth ✅
|
||||||
|
|
||||||
**Goal:** Users can log in via Google or GitHub and stay logged in.
|
**Goal:** Users can log in via Google or GitHub and stay logged in.
|
||||||
**Done when:** Better Auth session is validated on protected routes; unauthenticated users are redirected to login; user row is created on first social login.
|
**Done when:** Better Auth session is validated on protected routes; unauthenticated users are redirected to login; user row is created on first social login.
|
||||||
|
|
@ -109,6 +109,68 @@ Each phase produces a working increment. Nothing is built speculatively.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Production Deployment ✅
|
||||||
|
|
||||||
|
**Goal:** App is live on Hetzner, accessible via HTTPS on all subdomains.
|
||||||
|
**Done when:** `https://lilastudy.com` loads; `https://api.lilastudy.com` responds; auth flow works end-to-end; CI/CD deploys on push to main.
|
||||||
|
|
||||||
|
_Note: Deployment was moved ahead of multiplayer — the app is useful without multiplayer but not without deployment._
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- [x] Hetzner VPS provisioned (Debian 13, ARM64, 4GB RAM)
|
||||||
|
- [x] SSH hardening, ufw firewall, fail2ban
|
||||||
|
- [x] Docker + Docker Compose installed
|
||||||
|
- [x] Domain DNS: A record + wildcard `*.lilastudy.com` pointing to VPS
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
- [x] Caddy container with automatic HTTPS (Let's Encrypt)
|
||||||
|
- [x] Subdomain routing: `lilastudy.com` → web, `api.lilastudy.com` → API, `git.lilastudy.com` → Forgejo
|
||||||
|
|
||||||
|
### Docker stack
|
||||||
|
|
||||||
|
- [x] Production `docker-compose.yml` with all services on shared network
|
||||||
|
- [x] No ports exposed on internal services — only Caddy (80/443) and Forgejo SSH (2222)
|
||||||
|
- [x] Production Dockerfile stages for API (runner) and frontend (nginx:alpine)
|
||||||
|
- [x] Monorepo package exports fixed for production (dist/src paths)
|
||||||
|
- [x] Production `.env` with env-driven CORS, auth URLs, cookie domain
|
||||||
|
|
||||||
|
### Git server + container registry
|
||||||
|
|
||||||
|
- [x] Forgejo running with built-in container registry
|
||||||
|
- [x] SSH on port 2222, dev laptop `~/.ssh/config` configured
|
||||||
|
- [x] Repository created, code pushed
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- [x] Forgejo Actions enabled
|
||||||
|
- [x] Forgejo Runner container on VPS with Docker socket access
|
||||||
|
- [x] `.forgejo/workflows/deploy.yml` — build, push, deploy via SSH on push to main
|
||||||
|
- [x] Registry and SSH secrets configured in Forgejo
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- [x] Initial seed via pg_dump from dev laptop
|
||||||
|
- [x] Seeding script is idempotent (onConflictDoNothing) for future data additions
|
||||||
|
- [x] Schema migrations via Drizzle (migrate first, deploy second)
|
||||||
|
|
||||||
|
### OAuth
|
||||||
|
|
||||||
|
- [x] Google and GitHub OAuth redirect URIs configured for production
|
||||||
|
- [x] Cross-subdomain cookies via COOKIE_DOMAIN=.lilastudy.com
|
||||||
|
|
||||||
|
### Backups
|
||||||
|
|
||||||
|
- [x] Daily cron job (3 AM) with pg_dump, 7-day retention
|
||||||
|
- [x] Dev laptop auto-syncs backups on login via rsync
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [x] `deployment.md` covering full infrastructure setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 4 — Multiplayer Lobby
|
## Phase 4 — Multiplayer Lobby
|
||||||
|
|
||||||
**Goal:** Players can create and join rooms; the host sees all joined players in real time.
|
**Goal:** Players can create and join rooms; the host sees all joined players in real time.
|
||||||
|
|
@ -148,32 +210,21 @@ Each phase produces a working increment. Nothing is built speculatively.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 — Production Deployment
|
|
||||||
|
|
||||||
**Goal:** App is live on Hetzner, accessible via HTTPS on all subdomains.
|
|
||||||
**Done when:** `https://app.yourdomain.com` loads; `wss://api.yourdomain.com` connects; auth flow works end-to-end.
|
|
||||||
|
|
||||||
- [ ] `docker-compose.prod.yml`: all services + `nginx-proxy` + `acme-companion`
|
|
||||||
- [ ] Nginx config per container: `VIRTUAL_HOST` + `LETSENCRYPT_HOST`
|
|
||||||
- [ ] Production `.env` files on VPS
|
|
||||||
- [ ] Drizzle migration runs on `api` container start
|
|
||||||
- [ ] Seed production DB
|
|
||||||
- [ ] Smoke test: login → solo game → multiplayer game end-to-end
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7 — Polish & Hardening
|
## Phase 7 — Polish & Hardening
|
||||||
|
|
||||||
**Goal:** Production-ready for real users.
|
**Goal:** Production-ready for real users.
|
||||||
|
|
||||||
|
- [x] CI/CD pipeline (Forgejo Actions → SSH deploy)
|
||||||
|
- [x] Database backups (cron → dev laptop sync)
|
||||||
- [ ] Rate limiting on API endpoints
|
- [ ] Rate limiting on API endpoints
|
||||||
- [ ] Graceful WS reconnect with exponential back-off
|
- [ ] Graceful WS reconnect with exponential back-off
|
||||||
- [ ] React error boundaries
|
- [ ] React error boundaries
|
||||||
- [ ] `GET /users/me/stats` endpoint + profile page
|
- [ ] `GET /users/me/stats` endpoint + profile page
|
||||||
- [ ] Accessibility pass (keyboard nav, ARIA on quiz buttons)
|
- [ ] Accessibility pass (keyboard nav, ARIA on quiz buttons)
|
||||||
- [ ] Favicon, page titles, Open Graph meta
|
- [ ] Favicon, page titles, Open Graph meta
|
||||||
- [ ] CI/CD pipeline (GitHub Actions → SSH deploy)
|
- [ ] Offsite backup storage (Hetzner Object Storage)
|
||||||
- [ ] Database backups (cron → Hetzner Object Storage)
|
- [ ] Monitoring/logging (uptime, centralized logs)
|
||||||
|
- [ ] Valkey for game session store (replace in-memory)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -183,9 +234,9 @@ Each phase produces a working increment. Nothing is built speculatively.
|
||||||
Phase 0 (Foundation) ✅
|
Phase 0 (Foundation) ✅
|
||||||
└── Phase 1 (Vocabulary Data + API) ✅
|
└── Phase 1 (Vocabulary Data + API) ✅
|
||||||
└── Phase 2 (Singleplayer UI) ✅
|
└── Phase 2 (Singleplayer UI) ✅
|
||||||
└── Phase 3 (Auth)
|
├── Phase 3 (Auth) ✅
|
||||||
├── Phase 4 (Multiplayer Lobby)
|
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||||||
│ └── Phase 5 (Multiplayer Game)
|
└── Phase 4 (Multiplayer Lobby)
|
||||||
│ └── Phase 6 (Deployment)
|
└── Phase 5 (Multiplayer Game)
|
||||||
└── Phase 7 (Hardening)
|
└── Phase 7 (Hardening)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,9 @@ These are not deleted from the plan — they are deferred. The architecture is a
|
||||||
|
|
||||||
## 4. Technology Stack
|
## 4. Technology Stack
|
||||||
|
|
||||||
The monorepo structure and tooling are already set up. This is the full stack — the MVP uses a subset of it.
|
The monorepo structure and tooling are already set up. This is the full stack.
|
||||||
|
|
||||||
| Layer | Technology | MVP? |
|
| Layer | Technology | Status |
|
||||||
| ------------ | ------------------------------ | ----------- |
|
| ------------ | ------------------------------ | ----------- |
|
||||||
| Monorepo | pnpm workspaces | ✅ |
|
| Monorepo | pnpm workspaces | ✅ |
|
||||||
| Frontend | React 18, Vite, TypeScript | ✅ |
|
| Frontend | React 18, Vite, TypeScript | ✅ |
|
||||||
|
|
@ -77,10 +77,11 @@ The monorepo structure and tooling are already set up. This is the full stack
|
||||||
| Database | PostgreSQL + Drizzle ORM | ✅ |
|
| Database | PostgreSQL + Drizzle ORM | ✅ |
|
||||||
| Validation | Zod (shared schemas) | ✅ |
|
| Validation | Zod (shared schemas) | ✅ |
|
||||||
| Testing | Vitest, supertest | ✅ |
|
| Testing | Vitest, supertest | ✅ |
|
||||||
| Auth | Better Auth (Google + GitHub) | ❌ post-MVP |
|
| Auth | Better Auth (Google + GitHub) | ✅ |
|
||||||
|
| Deployment | Docker Compose, Caddy, Hetzner | ✅ |
|
||||||
|
| CI/CD | Forgejo Actions | ✅ |
|
||||||
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
|
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
|
||||||
| Cache | Valkey | ❌ post-MVP |
|
| Cache | Valkey | ❌ post-MVP |
|
||||||
| Deployment | Docker Compose, Hetzner, Nginx | ❌ post-MVP |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -88,14 +89,20 @@ The monorepo structure and tooling are already set up. This is the full stack
|
||||||
|
|
||||||
```text
|
```text
|
||||||
vocab-trainer/
|
vocab-trainer/
|
||||||
|
├── .forgejo/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── deploy.yml — CI/CD pipeline (build, push, deploy)
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ └── src/
|
│ │ └── src/
|
||||||
│ │ ├── app.ts — createApp() factory, express.json(), error middleware
|
│ │ ├── app.ts — createApp() factory, CORS, auth handler, error middleware
|
||||||
│ │ ├── server.ts — starts server on PORT
|
│ │ ├── server.ts — starts server on PORT
|
||||||
│ │ ├── errors/
|
│ │ ├── errors/
|
||||||
│ │ │ └── AppError.ts — AppError, ValidationError, NotFoundError
|
│ │ │ └── AppError.ts — AppError, ValidationError, NotFoundError
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ └── auth.ts — Better Auth config (Google + GitHub providers)
|
||||||
│ │ ├── middleware/
|
│ │ ├── middleware/
|
||||||
|
│ │ │ ├── authMiddleware.ts — session validation for protected routes
|
||||||
│ │ │ └── errorHandler.ts — central error middleware
|
│ │ │ └── errorHandler.ts — central error middleware
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
│ │ │ ├── apiRouter.ts — mounts /health and /game routers
|
│ │ │ ├── apiRouter.ts — mounts /health and /game routers
|
||||||
|
|
@ -111,10 +118,17 @@ vocab-trainer/
|
||||||
│ │ ├── InMemoryGameSessionStore.ts
|
│ │ ├── InMemoryGameSessionStore.ts
|
||||||
│ │ └── index.ts
|
│ │ └── index.ts
|
||||||
│ └── web/
|
│ └── web/
|
||||||
|
│ ├── Dockerfile — multi-stage: dev + production (nginx:alpine)
|
||||||
|
│ ├── nginx.conf — SPA fallback routing
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── index.tsx — landing page
|
│ │ ├── index.tsx — landing page
|
||||||
│ │ └── play.tsx — the quiz
|
│ │ ├── play.tsx — the quiz
|
||||||
|
│ │ ├── login.tsx — Google + GitHub login buttons
|
||||||
|
│ │ ├── about.tsx
|
||||||
|
│ │ └── __root.tsx
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ └── auth-client.ts — Better Auth React client
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ └── game/
|
│ │ └── game/
|
||||||
│ │ ├── GameSetup.tsx — settings UI
|
│ │ ├── GameSetup.tsx — settings UI
|
||||||
|
|
@ -131,7 +145,7 @@ vocab-trainer/
|
||||||
│ └── db/
|
│ └── db/
|
||||||
│ ├── drizzle/ — migration SQL files
|
│ ├── drizzle/ — migration SQL files
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── db/schema.ts — Drizzle schema
|
│ ├── db/schema.ts — Drizzle schema (terms, translations, auth tables)
|
||||||
│ ├── models/termModel.ts — getGameTerms(), getDistractors()
|
│ ├── models/termModel.ts — getGameTerms(), getDistractors()
|
||||||
│ ├── seeding-datafiles.ts — seeds terms + translations from JSON
|
│ ├── seeding-datafiles.ts — seeds terms + translations from JSON
|
||||||
│ ├── seeding-cefr-levels.ts — enriches translations with CEFR data
|
│ ├── seeding-cefr-levels.ts — enriches translations with CEFR data
|
||||||
|
|
@ -139,7 +153,9 @@ vocab-trainer/
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── scripts/ — Python extraction/comparison/merge scripts
|
├── scripts/ — Python extraction/comparison/merge scripts
|
||||||
├── documentation/ — project docs
|
├── documentation/ — project docs
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml — local dev stack
|
||||||
|
├── docker-compose.prod.yml — production config reference
|
||||||
|
├── Caddyfile — reverse proxy routing
|
||||||
└── pnpm-workspace.yaml
|
└── pnpm-workspace.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,13 +194,28 @@ HTTP Request
|
||||||
|
|
||||||
**Key principle:** all database code lives in `packages/db`. `apps/api` never imports `drizzle-orm` for queries — it only calls functions exported from `packages/db`.
|
**Key principle:** all database code lives in `packages/db`. `apps/api` never imports `drizzle-orm` for queries — it only calls functions exported from `packages/db`.
|
||||||
|
|
||||||
|
### Production Infrastructure
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet → Caddy (HTTPS termination)
|
||||||
|
├── lilastudy.com → web container (nginx, static files)
|
||||||
|
├── api.lilastudy.com → api container (Express, port 3000)
|
||||||
|
└── git.lilastudy.com → forgejo container (git + registry, port 3000)
|
||||||
|
|
||||||
|
SSH (port 2222) → forgejo container (git push/pull)
|
||||||
|
```
|
||||||
|
|
||||||
|
All containers communicate over an internal Docker network. Only Caddy (80/443) and Forgejo SSH (2222) are exposed to the internet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Data Model (Current State)
|
## 7. Data Model (Current State)
|
||||||
|
|
||||||
Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks). Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`.
|
Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks). Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`.
|
||||||
|
|
||||||
**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `categories`, `term_categories`
|
**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `topics`, `term_topics`
|
||||||
|
|
||||||
|
**Auth tables (managed by Better Auth):** `user`, `session`, `account`, `verification`
|
||||||
|
|
||||||
Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports)
|
Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports)
|
||||||
|
|
||||||
|
|
@ -201,9 +232,10 @@ Full schema is in `packages/db/src/db/schema.ts`.
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/v1/game/start GameRequest → GameSession
|
POST /api/v1/game/start GameRequest → GameSession (requires auth)
|
||||||
POST /api/v1/game/answer AnswerSubmission → AnswerResult
|
POST /api/v1/game/answer AnswerSubmission → AnswerResult (requires auth)
|
||||||
GET /api/v1/health Health check
|
GET /api/v1/health Health check (public)
|
||||||
|
ALL /api/auth/* Better Auth handlers (public)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Schemas (packages/shared)
|
### Schemas (packages/shared)
|
||||||
|
|
@ -235,7 +267,7 @@ Typed error classes (`AppError` base, `ValidationError` 400, `NotFoundError` 404
|
||||||
- **Session length**: 3 or 10 questions (configurable)
|
- **Session length**: 3 or 10 questions (configurable)
|
||||||
- **Scoring**: +1 per correct answer (no speed bonus for MVP)
|
- **Scoring**: +1 per correct answer (no speed bonus for MVP)
|
||||||
- **Timer**: none in singleplayer MVP
|
- **Timer**: none in singleplayer MVP
|
||||||
- **No auth required**: anonymous users
|
- **Auth required**: users must log in via Google or GitHub
|
||||||
- **Submit-before-send**: user selects, then confirms (prevents misclicks)
|
- **Submit-before-send**: user selects, then confirms (prevents misclicks)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -258,14 +290,15 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
|
||||||
|
|
||||||
## 11. Post-MVP Ladder
|
## 11. Post-MVP Ladder
|
||||||
|
|
||||||
| Phase | What it adds |
|
| Phase | What it adds | Status |
|
||||||
| ----------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
||||||
| Auth | Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB |
|
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
||||||
| User Stats | Games played, score history, profile page |
|
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
||||||
| Multiplayer Lobby | Room creation, join by code, WebSocket connection |
|
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
||||||
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen |
|
| User Stats | Games played, score history, profile page | ❌ |
|
||||||
| Deployment | Docker Compose prod config, Nginx, Let's Encrypt, Hetzner VPS |
|
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
||||||
| Hardening | Rate limiting, error boundaries, CI/CD, DB backups |
|
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
||||||
|
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
||||||
|
|
||||||
### Future Data Model Extensions (deferred, additive)
|
### Future Data Model Extensions (deferred, additive)
|
||||||
|
|
||||||
|
|
@ -285,11 +318,16 @@ All are new tables referencing existing `terms` rows via FK. No existing schema
|
||||||
- Game mechanic: simultaneous answers, 15-second server timer, all players see same question
|
- Game mechanic: simultaneous answers, 15-second server timer, all players see same question
|
||||||
- Valkey for ephemeral room state, PostgreSQL for durable records
|
- Valkey for ephemeral room state, PostgreSQL for durable records
|
||||||
|
|
||||||
### Infrastructure (deferred)
|
### Infrastructure (current)
|
||||||
|
|
||||||
- `app.yourdomain.com` → React frontend
|
- `lilastudy.com` → React frontend (nginx serving static files)
|
||||||
- `api.yourdomain.com` → Express API + WebSocket + Better Auth
|
- `api.lilastudy.com` → Express API + Better Auth
|
||||||
- Docker Compose with `nginx-proxy` + `acme-companion` for automatic SSL
|
- `git.lilastudy.com` → Forgejo (git server + container registry)
|
||||||
|
- Docker Compose with Caddy for automatic HTTPS via Let's Encrypt
|
||||||
|
- CI/CD via Forgejo Actions (build on push to main, deploy via SSH)
|
||||||
|
- Daily DB backups with cron, synced to dev laptop
|
||||||
|
|
||||||
|
See `deployment.md` for full infrastructure documentation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -312,14 +350,14 @@ See `roadmap.md` for the full roadmap with task-level checkboxes.
|
||||||
### Dependency Graph
|
### Dependency Graph
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Phase 0 (Foundation)
|
Phase 0 (Foundation) ✅
|
||||||
└── Phase 1 (Vocabulary Data + API)
|
└── Phase 1 (Vocabulary Data + API) ✅
|
||||||
└── Phase 2 (Singleplayer UI)
|
└── Phase 2 (Singleplayer UI) ✅
|
||||||
└── Phase 3 (Auth)
|
├── Phase 3 (Auth) ✅
|
||||||
├── Phase 4 (Room Lobby)
|
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||||||
│ └── Phase 5 (Multiplayer Game)
|
└── Phase 4 (Multiplayer Lobby)
|
||||||
│ └── Phase 6 (Deployment)
|
└── Phase 5 (Multiplayer Game)
|
||||||
└── Phase 7 (Hardening)
|
└── Phase 7 (Hardening)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default defineConfig([
|
||||||
"eslint.config.mjs",
|
"eslint.config.mjs",
|
||||||
"**/*.config.ts",
|
"**/*.config.ts",
|
||||||
"routeTree.gen.ts",
|
"routeTree.gen.ts",
|
||||||
|
"scripts/**",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
|
|
@ -38,6 +39,27 @@ export default defineConfig([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["apps/web/src/routes/**/*.{ts,tsx}"],
|
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" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
21
packages/db/drizzle/0006_certain_adam_destine.sql
Normal file
21
packages/db/drizzle/0006_certain_adam_destine.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE TABLE "lobbies" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" varchar(10) NOT NULL,
|
||||||
|
"host_user_id" text NOT NULL,
|
||||||
|
"status" varchar(20) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "lobbies_code_unique" UNIQUE("code"),
|
||||||
|
CONSTRAINT "lobby_status_check" CHECK ("lobbies"."status" IN ('waiting', 'in_progress', 'finished'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lobby_players" (
|
||||||
|
"lobby_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "lobby_players_lobby_id_user_id_pk" PRIMARY KEY("lobby_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "lobbies" ADD CONSTRAINT "lobbies_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_lobby_id_lobbies_id_fk" FOREIGN KEY ("lobby_id") REFERENCES "public"."lobbies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
|
@ -110,12 +110,8 @@
|
||||||
"name": "account_user_id_user_id_fk",
|
"name": "account_user_id_user_id_fk",
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -149,12 +145,8 @@
|
||||||
"name": "deck_terms_deck_id_decks_id_fk",
|
"name": "deck_terms_deck_id_decks_id_fk",
|
||||||
"tableFrom": "deck_terms",
|
"tableFrom": "deck_terms",
|
||||||
"tableTo": "decks",
|
"tableTo": "decks",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["deck_id"],
|
||||||
"deck_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -162,12 +154,8 @@
|
||||||
"name": "deck_terms_term_id_terms_id_fk",
|
"name": "deck_terms_term_id_terms_id_fk",
|
||||||
"tableFrom": "deck_terms",
|
"tableFrom": "deck_terms",
|
||||||
"tableTo": "terms",
|
"tableTo": "terms",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["term_id"],
|
||||||
"term_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -175,10 +163,7 @@
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"deck_terms_deck_id_term_id_pk": {
|
"deck_terms_deck_id_term_id_pk": {
|
||||||
"name": "deck_terms_deck_id_term_id_pk",
|
"name": "deck_terms_deck_id_term_id_pk",
|
||||||
"columns": [
|
"columns": ["deck_id", "term_id"]
|
||||||
"deck_id",
|
|
||||||
"term_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
|
|
@ -265,10 +250,7 @@
|
||||||
"unique_deck_name": {
|
"unique_deck_name": {
|
||||||
"name": "unique_deck_name",
|
"name": "unique_deck_name",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["name", "source_language"]
|
||||||
"name",
|
|
||||||
"source_language"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -368,12 +350,8 @@
|
||||||
"name": "session_user_id_user_id_fk",
|
"name": "session_user_id_user_id_fk",
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -383,9 +361,7 @@
|
||||||
"session_token_unique": {
|
"session_token_unique": {
|
||||||
"name": "session_token_unique",
|
"name": "session_token_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["token"]
|
||||||
"token"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -435,12 +411,8 @@
|
||||||
"name": "term_glosses_term_id_terms_id_fk",
|
"name": "term_glosses_term_id_terms_id_fk",
|
||||||
"tableFrom": "term_glosses",
|
"tableFrom": "term_glosses",
|
||||||
"tableTo": "terms",
|
"tableTo": "terms",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["term_id"],
|
||||||
"term_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -450,10 +422,7 @@
|
||||||
"unique_term_gloss": {
|
"unique_term_gloss": {
|
||||||
"name": "unique_term_gloss",
|
"name": "unique_term_gloss",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["term_id", "language_code"]
|
||||||
"term_id",
|
|
||||||
"language_code"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -488,12 +457,8 @@
|
||||||
"name": "term_topics_term_id_terms_id_fk",
|
"name": "term_topics_term_id_terms_id_fk",
|
||||||
"tableFrom": "term_topics",
|
"tableFrom": "term_topics",
|
||||||
"tableTo": "terms",
|
"tableTo": "terms",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["term_id"],
|
||||||
"term_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -501,12 +466,8 @@
|
||||||
"name": "term_topics_topic_id_topics_id_fk",
|
"name": "term_topics_topic_id_topics_id_fk",
|
||||||
"tableFrom": "term_topics",
|
"tableFrom": "term_topics",
|
||||||
"tableTo": "topics",
|
"tableTo": "topics",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["topic_id"],
|
||||||
"topic_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -514,10 +475,7 @@
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"term_topics_term_id_topic_id_pk": {
|
"term_topics_term_id_topic_id_pk": {
|
||||||
"name": "term_topics_term_id_topic_id_pk",
|
"name": "term_topics_term_id_topic_id_pk",
|
||||||
"columns": [
|
"columns": ["term_id", "topic_id"]
|
||||||
"term_id",
|
|
||||||
"topic_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
|
|
@ -591,10 +549,7 @@
|
||||||
"unique_source_id": {
|
"unique_source_id": {
|
||||||
"name": "unique_source_id",
|
"name": "unique_source_id",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["source", "source_id"]
|
||||||
"source",
|
|
||||||
"source_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -650,9 +605,7 @@
|
||||||
"topics_slug_unique": {
|
"topics_slug_unique": {
|
||||||
"name": "topics_slug_unique",
|
"name": "topics_slug_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["slug"]
|
||||||
"slug"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -748,12 +701,8 @@
|
||||||
"name": "translations_term_id_terms_id_fk",
|
"name": "translations_term_id_terms_id_fk",
|
||||||
"tableFrom": "translations",
|
"tableFrom": "translations",
|
||||||
"tableTo": "terms",
|
"tableTo": "terms",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["term_id"],
|
||||||
"term_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -763,11 +712,7 @@
|
||||||
"unique_translations": {
|
"unique_translations": {
|
||||||
"name": "unique_translations",
|
"name": "unique_translations",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["term_id", "language_code", "text"]
|
||||||
"term_id",
|
|
||||||
"language_code",
|
|
||||||
"text"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -844,9 +789,7 @@
|
||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -927,9 +870,5 @@
|
||||||
"roles": {},
|
"roles": {},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
"views": {},
|
"views": {},
|
||||||
"_meta": {
|
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||||
"columns": {},
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
1003
packages/db/drizzle/meta/0006_snapshot.json
Normal file
1003
packages/db/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
||||||
"when": 1776154563168,
|
"when": 1776154563168,
|
||||||
"tag": "0005_broad_mariko_yashida",
|
"tag": "0005_broad_mariko_yashida",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776270391189,
|
||||||
|
"tag": "0006_certain_adam_destine",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
primaryKey,
|
primaryKey,
|
||||||
index,
|
index,
|
||||||
boolean,
|
boolean,
|
||||||
|
integer,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { sql, relations } from "drizzle-orm";
|
import { sql, relations } from "drizzle-orm";
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
CEFR_LEVELS,
|
CEFR_LEVELS,
|
||||||
SUPPORTED_DECK_TYPES,
|
SUPPORTED_DECK_TYPES,
|
||||||
DIFFICULTY_LEVELS,
|
DIFFICULTY_LEVELS,
|
||||||
|
LOBBY_STATUSES,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
|
|
||||||
export const terms = pgTable(
|
export const terms = pgTable(
|
||||||
|
|
@ -252,12 +254,53 @@ export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, { fields: [account.userId], references: [user.id] }),
|
user: one(user, { fields: [account.userId], references: [user.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/*
|
export const lobbies = pgTable(
|
||||||
* INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning
|
"lobbies",
|
||||||
*
|
{
|
||||||
* source + source_id (terms): idempotency key per import pipeline
|
id: uuid().primaryKey().defaultRandom(),
|
||||||
* display_name UNIQUE (users): multiplayer requires distinguishable names
|
code: varchar({ length: 10 }).notNull().unique(),
|
||||||
* UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates
|
hostUserId: text("host_user_id")
|
||||||
* updated_at omitted: misleading without a trigger to maintain it
|
.notNull()
|
||||||
* FK indexes: all FK columns covered, no sequential scans on joins
|
.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] }),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
|
import * as schema from "./db/schema.js";
|
||||||
|
|
||||||
config({
|
config({
|
||||||
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
|
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/termModel.js";
|
||||||
|
export * from "./models/lobbyModel.js";
|
||||||
|
|
|
||||||
122
packages/db/src/models/lobbyModel.ts
Normal file
122
packages/db/src/models/lobbyModel.ts
Normal file
|
|
@ -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<Lobby> => {
|
||||||
|
const [newLobby] = await db
|
||||||
|
.insert(lobbies)
|
||||||
|
.values({ code, hostUserId, status: "waiting" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!newLobby) {
|
||||||
|
throw new Error("Failed to create lobby");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLobby;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLobbyByCodeWithPlayers = async (
|
||||||
|
code: string,
|
||||||
|
): Promise<LobbyWithPlayers | undefined> => {
|
||||||
|
return db.query.lobbies.findFirst({
|
||||||
|
where: eq(lobbies.code, code),
|
||||||
|
with: {
|
||||||
|
players: { with: { user: { columns: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateLobbyStatus = async (
|
||||||
|
lobbyId: string,
|
||||||
|
status: LobbyStatus,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteLobby = async (lobbyId: string): Promise<void> => {
|
||||||
|
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically inserts a player into a lobby. Returns the new player row,
|
||||||
|
* or undefined if the insert was skipped because:
|
||||||
|
* - the lobby is at capacity, or
|
||||||
|
* - the lobby is not in 'waiting' status, or
|
||||||
|
* - the user is already in the lobby (PK conflict).
|
||||||
|
*
|
||||||
|
* Callers are expected to pre-check these conditions against a hydrated
|
||||||
|
* lobby state to produce specific error messages; the undefined return
|
||||||
|
* is a safety net for concurrent races.
|
||||||
|
*/
|
||||||
|
export const addPlayer = async (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
maxPlayers: number,
|
||||||
|
): Promise<LobbyPlayer | undefined> => {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO lobby_players (lobby_id, user_id)
|
||||||
|
SELECT ${lobbyId}::uuid, ${userId}
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid
|
||||||
|
) < ${maxPlayers}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting'
|
||||||
|
)
|
||||||
|
ON CONFLICT (lobby_id, user_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!result.rowCount) return undefined;
|
||||||
|
const [player] = await db
|
||||||
|
.select()
|
||||||
|
.from(lobby_players)
|
||||||
|
.where(
|
||||||
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePlayer = async (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.delete(lobby_players)
|
||||||
|
.where(
|
||||||
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finishGame = async (
|
||||||
|
lobbyId: string,
|
||||||
|
scoresByUser: Map<string, number>,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [userId, score] of scoresByUser) {
|
||||||
|
await tx
|
||||||
|
.update(lobby_players)
|
||||||
|
.set({ score })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lobby_players.lobbyId, lobbyId),
|
||||||
|
eq(lobby_players.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(lobbies)
|
||||||
|
.set({ status: "finished" })
|
||||||
|
.where(eq(lobbies.id, lobbyId));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -13,3 +13,8 @@ export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const;
|
||||||
|
|
||||||
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
|
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
|
||||||
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
|
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;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./constants.js";
|
export * from "./constants.js";
|
||||||
export * from "./schemas/game.js";
|
export * from "./schemas/game.js";
|
||||||
|
export * from "./schemas/lobby.js";
|
||||||
|
|
|
||||||
130
packages/shared/src/schemas/lobby.ts
Normal file
130
packages/shared/src/schemas/lobby.ts
Normal file
|
|
@ -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<typeof LobbyPlayerSchema>;
|
||||||
|
|
||||||
|
export const LobbySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
code: z.string().min(1).max(10),
|
||||||
|
hostUserId: z.string(),
|
||||||
|
status: z.enum(LOBBY_STATUSES),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Lobby = z.infer<typeof LobbySchema>;
|
||||||
|
|
||||||
|
export const JoinLobbyResponseSchema = LobbySchema;
|
||||||
|
|
||||||
|
export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// WebSocket: Client → Server
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const WsLobbyJoinSchema = z.object({
|
||||||
|
type: z.literal("lobby:join"),
|
||||||
|
code: z.string().min(1).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyJoin = z.infer<typeof WsLobbyJoinSchema>;
|
||||||
|
|
||||||
|
export const WsLobbyLeaveSchema = z.object({
|
||||||
|
type: z.literal("lobby:leave"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyLeave = z.infer<typeof WsLobbyLeaveSchema>;
|
||||||
|
|
||||||
|
export const WsLobbyStartSchema = z.object({
|
||||||
|
type: z.literal("lobby:start"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyStart = z.infer<typeof WsLobbyStartSchema>;
|
||||||
|
|
||||||
|
export const WsGameAnswerSchema = z.object({
|
||||||
|
type: z.literal("game:answer"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
questionId: z.uuid(),
|
||||||
|
selectedOptionId: z.number().int().min(0).max(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameAnswer = z.infer<typeof WsGameAnswerSchema>;
|
||||||
|
|
||||||
|
export const WsClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
WsLobbyJoinSchema,
|
||||||
|
WsLobbyLeaveSchema,
|
||||||
|
WsLobbyStartSchema,
|
||||||
|
WsGameAnswerSchema,
|
||||||
|
]);
|
||||||
|
export type WsClientMessage = z.infer<typeof WsClientMessageSchema>;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// WebSocket: Server → Client
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const WsLobbyStateSchema = z.object({
|
||||||
|
type: z.literal("lobby:state"),
|
||||||
|
lobby: LobbySchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyState = z.infer<typeof WsLobbyStateSchema>;
|
||||||
|
|
||||||
|
export const WsGameQuestionSchema = z.object({
|
||||||
|
type: z.literal("game:question"),
|
||||||
|
question: GameQuestionSchema,
|
||||||
|
questionNumber: z.number().int().min(1),
|
||||||
|
totalQuestions: z.number().int().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameQuestion = z.infer<typeof WsGameQuestionSchema>;
|
||||||
|
|
||||||
|
export const WsGameAnswerResultSchema = z.object({
|
||||||
|
type: z.literal("game:answer_result"),
|
||||||
|
correctOptionId: z.number().int().min(0).max(3),
|
||||||
|
results: z.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
selectedOptionId: z.number().int().min(0).max(3).nullable(),
|
||||||
|
isCorrect: z.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameAnswerResult = z.infer<typeof WsGameAnswerResultSchema>;
|
||||||
|
|
||||||
|
export const WsGameFinishedSchema = z.object({
|
||||||
|
type: z.literal("game:finished"),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
winnerIds: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameFinished = z.infer<typeof WsGameFinishedSchema>;
|
||||||
|
|
||||||
|
export const WsErrorSchema = z.object({
|
||||||
|
type: z.literal("error"),
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
export type WsError = z.infer<typeof WsErrorSchema>;
|
||||||
|
|
||||||
|
export const WsServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
WsLobbyStateSchema,
|
||||||
|
WsGameQuestionSchema,
|
||||||
|
WsGameAnswerResultSchema,
|
||||||
|
WsGameFinishedSchema,
|
||||||
|
WsErrorSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type WsServerMessage = z.infer<typeof WsServerMessageSchema>;
|
||||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
|
|
@ -62,6 +62,9 @@ importers:
|
||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
ws:
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/cors':
|
'@types/cors':
|
||||||
specifier: ^2.8.19
|
specifier: ^2.8.19
|
||||||
|
|
@ -72,6 +75,9 @@ importers:
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^7.2.0
|
specifier: ^7.2.0
|
||||||
version: 7.2.0
|
version: 7.2.0
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.18.1
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.2.2
|
specifier: ^7.2.2
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
|
|
@ -1311,6 +1317,9 @@ packages:
|
||||||
'@types/supertest@7.2.0':
|
'@types/supertest@7.2.0':
|
||||||
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.57.1':
|
||||||
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -2983,6 +2992,18 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
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:
|
xlsx@0.18.5:
|
||||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
@ -3915,6 +3936,10 @@ snapshots:
|
||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/superagent': 8.1.9
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
|
|
@ -5586,6 +5611,8 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.20.0: {}
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx@0.18.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
adler-32: 1.3.1
|
adler-32: 1.3.1
|
||||||
|
|
|
||||||
280
scripts/create-issues.sh
Normal file
280
scripts/create-issues.sh
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Forgejo batch issue creator for lila
|
||||||
|
# Usage: FORGEJO_TOKEN=your_token ./create-issues.sh
|
||||||
|
|
||||||
|
FORGEJO_URL="https://git.lilastudy.com"
|
||||||
|
OWNER="forgejo-lila"
|
||||||
|
REPO="lila"
|
||||||
|
TOKEN="${FORGEJO_TOKEN:?Set FORGEJO_TOKEN environment variable}"
|
||||||
|
|
||||||
|
API="${FORGEJO_URL}/api/v1/repos/${OWNER}/${REPO}"
|
||||||
|
|
||||||
|
# Helper: create a label (ignores if already exists)
|
||||||
|
create_label() {
|
||||||
|
local name="$1" color="$2" description="$3"
|
||||||
|
curl -s -X POST "${API}/labels" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"name\":\"${name}\",\"color\":\"${color}\",\"description\":\"${description}\"}" > /dev/null
|
||||||
|
echo "Label: ${name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: create an issue with labels
|
||||||
|
create_issue() {
|
||||||
|
local title="$1" body="$2"
|
||||||
|
shift 2
|
||||||
|
local labels="$*"
|
||||||
|
|
||||||
|
# Build labels JSON array
|
||||||
|
local label_ids=""
|
||||||
|
for label in $labels; do
|
||||||
|
local id
|
||||||
|
id=$(curl -s "${API}/labels" \
|
||||||
|
-H "Authorization: token ${TOKEN}" | \
|
||||||
|
python3 -c "import sys,json; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='${label}']")
|
||||||
|
if [ -n "$label_ids" ]; then
|
||||||
|
label_ids="${label_ids},${id}"
|
||||||
|
else
|
||||||
|
label_ids="${id}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -s -X POST "${API}/issues" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"title\":$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"body\":$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"labels\":[${label_ids}]}" > /dev/null
|
||||||
|
|
||||||
|
echo "Issue: ${title}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Creating labels ==="
|
||||||
|
create_label "feature" "#0075ca" "New user-facing functionality"
|
||||||
|
create_label "infra" "#e4e669" "Infrastructure, deployment, DevOps"
|
||||||
|
create_label "debt" "#d876e3" "Technical cleanup, refactoring"
|
||||||
|
create_label "security" "#b60205" "Security improvements"
|
||||||
|
create_label "ux" "#1d76db" "User experience, accessibility, polish"
|
||||||
|
create_label "multiplayer" "#0e8a16" "Multiplayer lobby and game features"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Creating issues ==="
|
||||||
|
|
||||||
|
# ── feature ──
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Add guest/try-now option — play without account" \
|
||||||
|
"Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a 'Try without account' button on the login/landing page." \
|
||||||
|
feature
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Add Apple login provider" \
|
||||||
|
"Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration." \
|
||||||
|
feature
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Add email+password login" \
|
||||||
|
"Add traditional email and password authentication as an alternative to social login. Configure via Better Auth." \
|
||||||
|
feature
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"User stats endpoint + profile page" \
|
||||||
|
"Add GET /users/me/stats endpoint returning games played, score history, etc. Build a frontend profile page displaying the stats." \
|
||||||
|
feature
|
||||||
|
|
||||||
|
# ── infra ──
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Google OAuth app verification and publishing" \
|
||||||
|
"Currently only test users can log in via Google. Publish the OAuth consent screen so any Google user can sign in. Requires branding verification through Google Cloud Console." \
|
||||||
|
infra
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Set up Docker credential helper on dev laptop" \
|
||||||
|
"Docker credentials are stored unencrypted in ~/.docker/config.json. Set up a credential helper to store them securely. See https://docs.docker.com/go/credential-store/" \
|
||||||
|
infra
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"VPS monitoring and logging" \
|
||||||
|
"Set up monitoring and centralized logging on the VPS. Options: chkrootkit/rkhunter for security, logwatch/monit for daily summaries, uptime monitoring for service health." \
|
||||||
|
infra
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Move to offsite backup storage" \
|
||||||
|
"Currently database backups live on the same VPS. Add offsite copies to Hetzner Object Storage or similar S3-compatible service to protect against VPS failure." \
|
||||||
|
infra
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Replace in-memory game session store with Valkey" \
|
||||||
|
"Add Valkey container to the production Docker stack. Implement ValkeyGameSessionStore using the existing GameSessionStore interface. Required before multiplayer." \
|
||||||
|
infra
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Modern env management approach" \
|
||||||
|
"Evaluate replacing .env files with a more robust approach (e.g. dotenvx, infisical, or similar). Current setup works but .env files are error-prone and not versioned." \
|
||||||
|
infra
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Pin dependencies in package.json files" \
|
||||||
|
"Pin all dependency versions in package.json files to exact versions to prevent unexpected updates from breaking builds." \
|
||||||
|
infra
|
||||||
|
|
||||||
|
# ── debt ──
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Rethink organization of datafiles and wordlists" \
|
||||||
|
"The current layout of data-sources/, scripts/datafiles/, scripts/data-sources/, and packages/db/src/data/ is confusing with overlapping content. Consolidate into a clear structure." \
|
||||||
|
debt
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Resolve eslint peer dependency warning" \
|
||||||
|
"eslint-plugin-react-hooks 7.0.1 expects eslint ^3.0.0-^9.0.0 but found 10.0.3. Resolve the peer dependency mismatch." \
|
||||||
|
debt
|
||||||
|
|
||||||
|
# ── security ──
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Rate limiting on API endpoints" \
|
||||||
|
"Add rate limiting to prevent abuse. At minimum: auth endpoints (brute force prevention), game endpoints (spam prevention). Consider express-rate-limit or similar." \
|
||||||
|
security
|
||||||
|
|
||||||
|
# ── ux ──
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"404/redirect handling for unknown routes and subdomains" \
|
||||||
|
"Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider Caddy fallback for unrecognized subdomains." \
|
||||||
|
ux
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"React error boundaries" \
|
||||||
|
"Add error boundaries to catch and display runtime errors gracefully instead of crashing the entire app." \
|
||||||
|
ux
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Accessibility pass" \
|
||||||
|
"Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow." \
|
||||||
|
ux
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Favicon, page titles, Open Graph meta" \
|
||||||
|
"Add favicon, set proper page titles per route, add Open Graph meta tags for link previews when sharing." \
|
||||||
|
ux
|
||||||
|
|
||||||
|
# ── multiplayer ──
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Drizzle schema: lobbies, lobby_players + migration" \
|
||||||
|
"Create lobbies table (id, code, host_user_id, status, is_private, game_mode, settings, created_at) and lobby_players table (lobby_id, user_id, score, joined_at). Run migration. See game-modes.md for game_mode values." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"REST endpoints: POST /lobbies, POST /lobbies/:code/join" \
|
||||||
|
"Create lobby (generates short code, sets host) and join lobby (validates code, adds player, enforces max limit)." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"LobbyService: create lobby, join lobby, enforce player limit" \
|
||||||
|
"Service layer for lobby management. Generate human-readable codes, validate join requests, track lobby state. Public lobbies are browsable, private lobbies require code." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"WebSocket server: attach ws upgrade to Express" \
|
||||||
|
"Attach ws library upgrade handler to the existing Express HTTP server. Handle connection lifecycle." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"WS auth middleware: validate session on upgrade" \
|
||||||
|
"Validate Better Auth session on WebSocket upgrade request. Reject unauthenticated connections." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"WS message router: dispatch by type" \
|
||||||
|
"Route incoming WebSocket messages by their type field to the appropriate handler. Use Zod discriminated union for type safety." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Lobby join/leave handlers + broadcast lobby state" \
|
||||||
|
"Handle lobby:join and lobby:leave WebSocket events. Broadcast updated player list to all connected players in the lobby." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Lobby state in Valkey (ephemeral) + PostgreSQL (durable)" \
|
||||||
|
"Store live lobby state (connected players, current question, timer) in Valkey. Store durable records (who played, final scores) in PostgreSQL." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"WS event Zod schemas in packages/shared" \
|
||||||
|
"Define all WebSocket message types as Zod discriminated unions in packages/shared. Covers lobby events (join, leave, start) and game events (question, answer, result, finished)." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: lobby browser + create/join lobby" \
|
||||||
|
"Lobby list showing public open lobbies. Create lobby form (game mode, public/private). Join-by-code input for private lobbies." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: lobby view (player list, code, start game)" \
|
||||||
|
"Show lobby code, connected players, game mode. Host sees Start Game button. Players see waiting state. Real-time updates via WebSocket." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: WS client singleton with reconnect" \
|
||||||
|
"WebSocket client that maintains a single connection, handles reconnection on disconnect, and dispatches incoming messages to the appropriate state handlers." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"GameService: question sequence + server timer" \
|
||||||
|
"Generate question sequence for a lobby game. Enforce per-question timer (e.g. 15s). Timer logic varies by game mode — see game-modes.md." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"lobby:start WS handler — broadcast first question" \
|
||||||
|
"When host starts the game, generate questions, change lobby status to in_progress, broadcast first question to all players." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"game:answer WS handler — collect answers" \
|
||||||
|
"Receive player answers via WebSocket. Track who has answered. Behavior varies by game mode (simultaneous vs turn-based vs buzzer)." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Answer evaluation + broadcast results" \
|
||||||
|
"On all-answered or timeout: evaluate answers, calculate scores, broadcast game:answer_result to all players. Then send next question or end game." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Game finished: broadcast results, update DB" \
|
||||||
|
"After final round: broadcast game:finished with final scores and winner. Write game results to PostgreSQL (transactional). Change lobby status to finished." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: multiplayer game route" \
|
||||||
|
"Route for active multiplayer games. Receives questions and results via WebSocket. Reuses QuestionCard and OptionButton components." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: countdown timer component" \
|
||||||
|
"Visual countdown timer synchronized with server timer. Shows remaining seconds per question." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: ScoreBoard component (live per-player scores)" \
|
||||||
|
"Displays live scores for all players during a multiplayer game. Updates in real-time via WebSocket." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Frontend: GameFinished screen" \
|
||||||
|
"Winner highlight, final scores, play again option. Returns to lobby on play again." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Multiplayer GameService unit tests" \
|
||||||
|
"Unit tests for round evaluation, scoring, tie-breaking, timeout handling across different game modes." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
create_issue \
|
||||||
|
"Graceful WS reconnect with exponential back-off" \
|
||||||
|
"Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if game is still in progress." \
|
||||||
|
multiplayer
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done ==="
|
||||||
Loading…
Add table
Add a link
Reference in a new issue