update documentation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m23s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m23s
This commit is contained in:
commit
bbc9a3d630
60 changed files with 4261 additions and 276 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,7 +1,52 @@
|
||||||
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";
|
||||||
|
|
||||||
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
type SuccessResponse<T> = { success: true; data: T };
|
||||||
|
type ErrorResponse = { success: false; error: string };
|
||||||
|
type GameStartResponse = SuccessResponse<GameSession>;
|
||||||
|
type GameAnswerResponse = SuccessResponse<AnswerResult>;
|
||||||
|
|
||||||
|
vi.mock("@lila/db", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@lila/db")>();
|
||||||
|
return { ...actual, getGameTerms: vi.fn(), getDistractors: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../lib/auth.js", () => ({
|
||||||
|
auth: {
|
||||||
|
api: {
|
||||||
|
getSession: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "fake-token",
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@test.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("better-auth/node", () => ({
|
||||||
|
fromNodeHeaders: vi.fn().mockReturnValue({}),
|
||||||
|
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getGameTerms, getDistractors } from "@lila/db";
|
import { getGameTerms, getDistractors } from "@lila/db";
|
||||||
import { createApp } from "../app.js";
|
import { createApp } from "../app.js";
|
||||||
|
|
@ -33,49 +78,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 +128,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 +152,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 +172,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,9 +1,14 @@
|
||||||
|
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);
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
setupWebSocket(server);
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
console.log(`Server listening on port ${PORT}`);
|
console.log(`Server listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
176
apps/api/src/services/lobbyService.test.ts
Normal file
176
apps/api/src/services/lobbyService.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@lila/db", () => ({
|
||||||
|
createLobby: vi.fn(),
|
||||||
|
getLobbyByCodeWithPlayers: vi.fn(),
|
||||||
|
addPlayer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createLobby as createLobbyModel,
|
||||||
|
getLobbyByCodeWithPlayers,
|
||||||
|
addPlayer,
|
||||||
|
} from "@lila/db";
|
||||||
|
import { createLobby, joinLobby } from "./lobbyService.js";
|
||||||
|
|
||||||
|
const mockCreateLobby = vi.mocked(createLobbyModel);
|
||||||
|
const mockGetLobbyByCodeWithPlayers = vi.mocked(getLobbyByCodeWithPlayers);
|
||||||
|
const mockAddPlayer = vi.mocked(addPlayer);
|
||||||
|
|
||||||
|
const fakeLobby = {
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
code: "ABC123",
|
||||||
|
hostUserId: "user-1",
|
||||||
|
status: "waiting" as const,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeLobbyWithPlayers = {
|
||||||
|
...fakeLobby,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
lobbyId: fakeLobby.id,
|
||||||
|
userId: "user-1",
|
||||||
|
score: 0,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
user: { id: "user-1", name: "Alice" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateLobby.mockResolvedValue(fakeLobby);
|
||||||
|
mockAddPlayer.mockResolvedValue({
|
||||||
|
lobbyId: fakeLobby.id,
|
||||||
|
userId: "user-1",
|
||||||
|
score: 0,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue(fakeLobbyWithPlayers);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLobby", () => {
|
||||||
|
it("creates a lobby and adds the host as the first player", async () => {
|
||||||
|
const result = await createLobby("user-1");
|
||||||
|
|
||||||
|
expect(mockCreateLobby).toHaveBeenCalledOnce();
|
||||||
|
expect(mockAddPlayer).toHaveBeenCalledWith(
|
||||||
|
fakeLobby.id,
|
||||||
|
"user-1",
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(result.id).toBe(fakeLobby.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries on unique code collision", async () => {
|
||||||
|
const uniqueViolation = Object.assign(new Error("unique"), {
|
||||||
|
code: "23505",
|
||||||
|
});
|
||||||
|
mockCreateLobby
|
||||||
|
.mockRejectedValueOnce(uniqueViolation)
|
||||||
|
.mockResolvedValueOnce(fakeLobby);
|
||||||
|
|
||||||
|
const result = await createLobby("user-1");
|
||||||
|
|
||||||
|
expect(mockCreateLobby).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.id).toBe(fakeLobby.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws after max retry attempts", async () => {
|
||||||
|
const uniqueViolation = Object.assign(new Error("unique"), {
|
||||||
|
code: "23505",
|
||||||
|
});
|
||||||
|
mockCreateLobby.mockRejectedValue(uniqueViolation);
|
||||||
|
|
||||||
|
await expect(createLobby("user-1")).rejects.toThrow(
|
||||||
|
"Could not generate a unique lobby code",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("joinLobby", () => {
|
||||||
|
it("returns lobby with players when join succeeds", async () => {
|
||||||
|
const fullLobby = {
|
||||||
|
...fakeLobbyWithPlayers,
|
||||||
|
players: [
|
||||||
|
...fakeLobbyWithPlayers.players,
|
||||||
|
{
|
||||||
|
lobbyId: fakeLobby.id,
|
||||||
|
userId: "user-2",
|
||||||
|
score: 0,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
user: { id: "user-2", name: "Bob" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGetLobbyByCodeWithPlayers
|
||||||
|
.mockResolvedValueOnce(fakeLobbyWithPlayers)
|
||||||
|
.mockResolvedValueOnce(fullLobby);
|
||||||
|
|
||||||
|
const result = await joinLobby("ABC123", "user-2");
|
||||||
|
|
||||||
|
expect(mockAddPlayer).toHaveBeenCalledWith(
|
||||||
|
fakeLobby.id,
|
||||||
|
"user-2",
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(result.players).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NotFoundError when lobby does not exist", async () => {
|
||||||
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(joinLobby("XXXXXX", "user-2")).rejects.toThrow(
|
||||||
|
"Lobby not found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConflictError when lobby is not waiting", async () => {
|
||||||
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
||||||
|
...fakeLobbyWithPlayers,
|
||||||
|
status: "in_progress" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
|
||||||
|
"Game has already started",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns lobby idempotently when user already joined", async () => {
|
||||||
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
||||||
|
...fakeLobbyWithPlayers,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
lobbyId: fakeLobby.id,
|
||||||
|
userId: "user-1",
|
||||||
|
score: 0,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
user: { id: "user-1", name: "Alice" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await joinLobby("ABC123", "user-1");
|
||||||
|
|
||||||
|
expect(mockAddPlayer).not.toHaveBeenCalled();
|
||||||
|
expect(result.players).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConflictError when lobby is full", async () => {
|
||||||
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
||||||
|
...fakeLobbyWithPlayers,
|
||||||
|
players: Array.from({ length: 4 }, (_, i) => ({
|
||||||
|
lobbyId: fakeLobby.id,
|
||||||
|
userId: `user-${i}`,
|
||||||
|
score: 0,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
user: { id: `user-${i}`, name: `Player ${i}` },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(joinLobby("ABC123", "user-5")).rejects.toThrow(
|
||||||
|
"Lobby is full",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/api/src/services/lobbyService.ts
Normal file
72
apps/api/src/services/lobbyService.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { randomInt } from "crypto";
|
||||||
|
import {
|
||||||
|
createLobby as createLobbyModel,
|
||||||
|
getLobbyByCodeWithPlayers,
|
||||||
|
addPlayer,
|
||||||
|
} from "@lila/db";
|
||||||
|
import type { Lobby, LobbyWithPlayers } from "@lila/db";
|
||||||
|
import { MAX_LOBBY_PLAYERS } from "@lila/shared";
|
||||||
|
import { NotFoundError, ConflictError, AppError } from "../errors/AppError.js";
|
||||||
|
|
||||||
|
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford Base32
|
||||||
|
const CODE_LENGTH = 6;
|
||||||
|
const MAX_CODE_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
const generateLobbyCode = (): string => {
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||||
|
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUniqueViolation = (err: unknown): boolean => {
|
||||||
|
return (err as { code?: string })?.code === "23505";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createLobby = async (hostUserId: string): Promise<Lobby> => {
|
||||||
|
for (let i = 0; i < MAX_CODE_ATTEMPTS; i++) {
|
||||||
|
const code = generateLobbyCode();
|
||||||
|
try {
|
||||||
|
const lobby = await createLobbyModel(code, hostUserId);
|
||||||
|
await addPlayer(lobby.id, hostUserId, MAX_LOBBY_PLAYERS);
|
||||||
|
return lobby;
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AppError("Could not generate a unique lobby code", 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const joinLobby = async (
|
||||||
|
code: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<LobbyWithPlayers> => {
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(code);
|
||||||
|
if (!lobby) {
|
||||||
|
throw new NotFoundError(`Lobby not found: ${code}`);
|
||||||
|
}
|
||||||
|
if (lobby.status !== "waiting") {
|
||||||
|
throw new ConflictError("Game has already started");
|
||||||
|
}
|
||||||
|
if (lobby.players.some((p) => p.userId === userId)) {
|
||||||
|
return lobby; // idempotent: already in lobby
|
||||||
|
}
|
||||||
|
if (lobby.players.length >= MAX_LOBBY_PLAYERS) {
|
||||||
|
throw new ConflictError("Lobby is full");
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = await addPlayer(lobby.id, userId, MAX_LOBBY_PLAYERS);
|
||||||
|
if (!player) {
|
||||||
|
// Race fallback: another request filled the last slot, started the game,
|
||||||
|
// or the user joined concurrently. Pre-checks above handle the common cases.
|
||||||
|
throw new ConflictError("Lobby is no longer available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = await getLobbyByCodeWithPlayers(code);
|
||||||
|
if (!fresh) {
|
||||||
|
throw new AppError("Lobby disappeared during join", 500);
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
};
|
||||||
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 {};
|
||||||
106
apps/api/src/ws/auth.test.ts
Normal file
106
apps/api/src/ws/auth.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { IncomingMessage } from "http";
|
||||||
|
import { Duplex } from "stream";
|
||||||
|
|
||||||
|
vi.mock("better-auth/node", () => ({
|
||||||
|
fromNodeHeaders: vi.fn().mockReturnValue({}),
|
||||||
|
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/auth.js", () => ({
|
||||||
|
auth: { api: { getSession: vi.fn() }, handler: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { auth } from "../lib/auth.js";
|
||||||
|
import { handleUpgrade } from "./auth.js";
|
||||||
|
|
||||||
|
const mockGetSession = vi.mocked(auth.api.getSession);
|
||||||
|
|
||||||
|
const fakeSession = {
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "fake-token",
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@test.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockSocket = () => {
|
||||||
|
const socket = new Duplex();
|
||||||
|
socket._read = () => {};
|
||||||
|
socket._write = (_chunk, _encoding, callback) => callback();
|
||||||
|
const writeSpy = vi.spyOn(socket, "write").mockImplementation(() => true);
|
||||||
|
const destroySpy = vi
|
||||||
|
.spyOn(socket, "destroy")
|
||||||
|
.mockImplementation(() => socket);
|
||||||
|
return { socket, writeSpy, destroySpy };
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockRequest = () => {
|
||||||
|
const req = new IncomingMessage(null as never);
|
||||||
|
req.headers = {};
|
||||||
|
return req;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockWss = () => ({
|
||||||
|
handleUpgrade: vi.fn((_req, _socket, _head, cb: (ws: unknown) => void) => {
|
||||||
|
cb({ send: vi.fn(), on: vi.fn() });
|
||||||
|
}),
|
||||||
|
emit: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleUpgrade", () => {
|
||||||
|
it("rejects with 401 when no session exists", async () => {
|
||||||
|
mockGetSession.mockResolvedValue(null);
|
||||||
|
const { socket, writeSpy, destroySpy } = makeMockSocket();
|
||||||
|
const req = makeMockRequest();
|
||||||
|
const wss = makeMockWss();
|
||||||
|
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("401"));
|
||||||
|
expect(destroySpy).toHaveBeenCalled();
|
||||||
|
expect(wss.handleUpgrade).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades connection when session exists", async () => {
|
||||||
|
mockGetSession.mockResolvedValue(fakeSession);
|
||||||
|
const { socket, destroySpy } = makeMockSocket();
|
||||||
|
const req = makeMockRequest();
|
||||||
|
const wss = makeMockWss();
|
||||||
|
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
|
||||||
|
expect(wss.handleUpgrade).toHaveBeenCalled();
|
||||||
|
expect(wss.emit).toHaveBeenCalledWith(
|
||||||
|
"connection",
|
||||||
|
expect.anything(),
|
||||||
|
req,
|
||||||
|
fakeSession,
|
||||||
|
);
|
||||||
|
expect(destroySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 500 when getSession throws", async () => {
|
||||||
|
mockGetSession.mockRejectedValue(new Error("DB error"));
|
||||||
|
const { socket, writeSpy, destroySpy } = makeMockSocket();
|
||||||
|
const req = makeMockRequest();
|
||||||
|
const wss = makeMockWss();
|
||||||
|
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("500"));
|
||||||
|
expect(destroySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
32
apps/api/src/ws/auth.ts
Normal file
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>();
|
||||||
213
apps/api/src/ws/handlers/gameHandlers.ts
Normal file
213
apps/api/src/ws/handlers/gameHandlers.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import type { User } from "better-auth";
|
||||||
|
import type { WsGameAnswer, WsGameReady } from "@lila/shared";
|
||||||
|
import { finishGame, getLobbyByCodeWithPlayers } from "@lila/db";
|
||||||
|
import { broadcastToLobby, getConnections } from "../connections.js";
|
||||||
|
import { lobbyGameStore, timers } from "../gameState.js";
|
||||||
|
import { NotFoundError, ConflictError } from "../../errors/AppError.js";
|
||||||
|
|
||||||
|
export const handleGameAnswer = async (
|
||||||
|
_ws: WebSocket,
|
||||||
|
msg: WsGameAnswer,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
const state = await lobbyGameStore.get(msg.lobbyId);
|
||||||
|
if (!state) {
|
||||||
|
throw new NotFoundError("Game not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuestion = state.questions[state.currentIndex];
|
||||||
|
if (!currentQuestion) {
|
||||||
|
throw new ConflictError("No active question");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject stale answers
|
||||||
|
if (currentQuestion.questionId !== msg.questionId) {
|
||||||
|
throw new ConflictError("Answer is for wrong question");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject duplicate answers
|
||||||
|
if (state.playerAnswers.has(user.id)) {
|
||||||
|
throw new ConflictError("Already answered this question");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store answer
|
||||||
|
state.playerAnswers.set(user.id, msg.selectedOptionId);
|
||||||
|
await lobbyGameStore.set(msg.lobbyId, state);
|
||||||
|
|
||||||
|
// Check if all connected players have answered
|
||||||
|
const connected = getConnections(msg.lobbyId);
|
||||||
|
const allAnswered = [...connected.keys()].every((userId) =>
|
||||||
|
state.playerAnswers.has(userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allAnswered) {
|
||||||
|
// Clear timer — no need to wait
|
||||||
|
const timer = timers.get(msg.lobbyId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(msg.lobbyId);
|
||||||
|
}
|
||||||
|
await resolveRound(msg.lobbyId, state.currentIndex, state.questions.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleGameReady = async (
|
||||||
|
ws: WebSocket,
|
||||||
|
msg: WsGameReady,
|
||||||
|
_user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
const state = await lobbyGameStore.get(msg.lobbyId);
|
||||||
|
if (!state) throw new NotFoundError("Game not found");
|
||||||
|
|
||||||
|
const currentQuestion = state.questions[state.currentIndex];
|
||||||
|
if (!currentQuestion) throw new NotFoundError("No active question");
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "game:question",
|
||||||
|
question: {
|
||||||
|
questionId: currentQuestion.questionId,
|
||||||
|
prompt: currentQuestion.prompt,
|
||||||
|
gloss: currentQuestion.gloss,
|
||||||
|
options: currentQuestion.options,
|
||||||
|
},
|
||||||
|
questionNumber: state.currentIndex + 1,
|
||||||
|
totalQuestions: state.questions.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveRound = async (
|
||||||
|
lobbyId: string,
|
||||||
|
questionIndex: number,
|
||||||
|
totalQuestions: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
const state = await lobbyGameStore.get(lobbyId);
|
||||||
|
if (!state) return; // lobby was deleted mid-round, nothing to do
|
||||||
|
|
||||||
|
const currentQuestion = state.questions[questionIndex];
|
||||||
|
if (!currentQuestion) return;
|
||||||
|
|
||||||
|
// Fill null for any players who didn't answer (timed out)
|
||||||
|
const connected = getConnections(lobbyId);
|
||||||
|
for (const userId of connected.keys()) {
|
||||||
|
if (!state.playerAnswers.has(userId)) {
|
||||||
|
state.playerAnswers.set(userId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate answers and update scores
|
||||||
|
const results: {
|
||||||
|
userId: string;
|
||||||
|
selectedOptionId: number | null;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const [userId, selectedOptionId] of state.playerAnswers) {
|
||||||
|
const isCorrect =
|
||||||
|
selectedOptionId !== null &&
|
||||||
|
selectedOptionId === currentQuestion.correctOptionId;
|
||||||
|
if (isCorrect) {
|
||||||
|
state.scores.set(userId, (state.scores.get(userId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
results.push({ userId, selectedOptionId, isCorrect });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build updated players array for broadcast
|
||||||
|
const players = [...state.scores.entries()].map(([userId, score]) => ({
|
||||||
|
userId,
|
||||||
|
score,
|
||||||
|
lobbyId,
|
||||||
|
user: { id: userId, name: userId }, // name resolved below
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Resolve user names from DB
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(state.code);
|
||||||
|
|
||||||
|
const namedPlayers = players.map((p) => {
|
||||||
|
const dbPlayer = lobby?.players.find((dp) => dp.userId === p.userId);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
user: { id: p.userId, name: dbPlayer?.user.name ?? p.userId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast answer result
|
||||||
|
broadcastToLobby(lobbyId, {
|
||||||
|
type: "game:answer_result",
|
||||||
|
correctOptionId: currentQuestion.correctOptionId,
|
||||||
|
results,
|
||||||
|
players: namedPlayers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save updated state
|
||||||
|
state.playerAnswers = new Map();
|
||||||
|
state.currentIndex = questionIndex + 1;
|
||||||
|
await lobbyGameStore.set(lobbyId, state);
|
||||||
|
|
||||||
|
const isLastRound = questionIndex + 1 >= totalQuestions;
|
||||||
|
|
||||||
|
if (isLastRound) {
|
||||||
|
await endGame(lobbyId, state);
|
||||||
|
} else {
|
||||||
|
// Wait 3s then broadcast next question
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const fresh = await lobbyGameStore.get(lobbyId);
|
||||||
|
if (!fresh) return;
|
||||||
|
const nextQuestion = fresh.questions[fresh.currentIndex];
|
||||||
|
if (!nextQuestion) return;
|
||||||
|
broadcastToLobby(lobbyId, {
|
||||||
|
type: "game:question",
|
||||||
|
question: {
|
||||||
|
questionId: nextQuestion.questionId,
|
||||||
|
prompt: nextQuestion.prompt,
|
||||||
|
gloss: nextQuestion.gloss,
|
||||||
|
options: nextQuestion.options,
|
||||||
|
},
|
||||||
|
questionNumber: fresh.currentIndex + 1,
|
||||||
|
totalQuestions,
|
||||||
|
});
|
||||||
|
// Restart timer for next round
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void resolveRound(lobbyId, fresh.currentIndex, totalQuestions);
|
||||||
|
}, 15000);
|
||||||
|
timers.set(lobbyId, timer);
|
||||||
|
})();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endGame = async (
|
||||||
|
lobbyId: string,
|
||||||
|
state: Awaited<ReturnType<typeof lobbyGameStore.get>> & {},
|
||||||
|
): Promise<void> => {
|
||||||
|
// Persist final scores to DB
|
||||||
|
await finishGame(lobbyId, state.scores);
|
||||||
|
|
||||||
|
// Determine winners (handle ties)
|
||||||
|
const maxScore = Math.max(...state.scores.values());
|
||||||
|
const winnerIds = [...state.scores.entries()]
|
||||||
|
.filter(([, score]) => score === maxScore)
|
||||||
|
.map(([userId]) => userId);
|
||||||
|
|
||||||
|
// Build final players array
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(state.code);
|
||||||
|
|
||||||
|
const players = [...state.scores.entries()].map(([userId, score]) => {
|
||||||
|
const dbPlayer = lobby?.players.find((p) => p.userId === userId);
|
||||||
|
return {
|
||||||
|
lobbyId,
|
||||||
|
userId,
|
||||||
|
score,
|
||||||
|
user: { id: userId, name: dbPlayer?.user.name ?? userId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastToLobby(lobbyId, { type: "game:finished", players, winnerIds });
|
||||||
|
|
||||||
|
// Clean up game state
|
||||||
|
await lobbyGameStore.delete(lobbyId);
|
||||||
|
timers.delete(lobbyId);
|
||||||
|
};
|
||||||
158
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
158
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import type { User } from "better-auth";
|
||||||
|
import type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared";
|
||||||
|
import {
|
||||||
|
getLobbyByCodeWithPlayers,
|
||||||
|
deleteLobby,
|
||||||
|
removePlayer,
|
||||||
|
updateLobbyStatus,
|
||||||
|
getLobbyByIdWithPlayers,
|
||||||
|
} from "@lila/db";
|
||||||
|
import {
|
||||||
|
addConnection,
|
||||||
|
getConnections,
|
||||||
|
removeConnection,
|
||||||
|
broadcastToLobby,
|
||||||
|
} from "../connections.js";
|
||||||
|
import { NotFoundError, ConflictError } from "../../errors/AppError.js";
|
||||||
|
import { generateMultiplayerQuestions } from "../../services/multiplayerGameService.js";
|
||||||
|
import { lobbyGameStore, timers } from "../gameState.js";
|
||||||
|
import { resolveRound } from "./gameHandlers.js";
|
||||||
|
|
||||||
|
export const handleLobbyJoin = async (
|
||||||
|
ws: WebSocket,
|
||||||
|
msg: WsLobbyJoin,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Load lobby and validate membership
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(msg.code);
|
||||||
|
if (!lobby) {
|
||||||
|
throw new NotFoundError("Lobby not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lobby.status !== "waiting") {
|
||||||
|
throw new ConflictError("Lobby is not in waiting state");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lobby.players.some((p) => p.userId === user.id)) {
|
||||||
|
throw new ConflictError("You are not a member of this lobby");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register connection and tag the socket with lobbyId
|
||||||
|
addConnection(lobby.id, user.id, ws);
|
||||||
|
ws.lobbyId = lobby.id;
|
||||||
|
|
||||||
|
// Broadcast updated lobby state to all players
|
||||||
|
broadcastToLobby(lobby.id, { type: "lobby:state", lobby });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLobbyLeave = async (
|
||||||
|
ws: WebSocket,
|
||||||
|
msg: WsLobbyLeave,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId);
|
||||||
|
if (!lobby) return;
|
||||||
|
|
||||||
|
removeConnection(msg.lobbyId, user.id);
|
||||||
|
ws.lobbyId = undefined;
|
||||||
|
|
||||||
|
if (lobby.hostUserId === user.id) {
|
||||||
|
await deleteLobby(msg.lobbyId);
|
||||||
|
broadcastToLobby(msg.lobbyId, {
|
||||||
|
type: "error",
|
||||||
|
code: "LOBBY_CLOSED",
|
||||||
|
message: "Host left the lobby",
|
||||||
|
});
|
||||||
|
for (const player of lobby.players) {
|
||||||
|
removeConnection(msg.lobbyId, player.userId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await removePlayer(msg.lobbyId, user.id);
|
||||||
|
const updated = await getLobbyByCodeWithPlayers(lobby.code);
|
||||||
|
if (!updated) return;
|
||||||
|
broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated });
|
||||||
|
|
||||||
|
// TODO(reconnection-slice): if lobby.status === 'in_progress', the game
|
||||||
|
// continues with remaining players. If only one player remains after this
|
||||||
|
// leave, end the game immediately and declare them winner. Currently we
|
||||||
|
// broadcast updated lobby state and let the game resolve naturally via
|
||||||
|
// timeouts — the disconnected player's answers will be null each round.
|
||||||
|
// When reconnection handling is added, this is the place to change.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLobbyStart = async (
|
||||||
|
_ws: WebSocket,
|
||||||
|
msg: WsLobbyStart,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Load lobby and validate
|
||||||
|
const lobby = await getLobbyByIdWithPlayers(msg.lobbyId);
|
||||||
|
if (!lobby) {
|
||||||
|
throw new NotFoundError("Lobby not found");
|
||||||
|
}
|
||||||
|
if (lobby.hostUserId !== user.id) {
|
||||||
|
throw new ConflictError("Only the host can start the game");
|
||||||
|
}
|
||||||
|
if (lobby.status !== "waiting") {
|
||||||
|
throw new ConflictError("Game has already started");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connected players, not DB players
|
||||||
|
const connected = getConnections(msg.lobbyId);
|
||||||
|
if (connected.size < 2) {
|
||||||
|
throw new ConflictError("At least 2 players must be connected to start");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate questions
|
||||||
|
const questions = await generateMultiplayerQuestions();
|
||||||
|
|
||||||
|
// Initialize scores for all connected players
|
||||||
|
const scores = new Map<string, number>();
|
||||||
|
for (const userId of connected.keys()) {
|
||||||
|
scores.set(userId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize game state
|
||||||
|
await lobbyGameStore.create(msg.lobbyId, {
|
||||||
|
code: lobby.code,
|
||||||
|
questions,
|
||||||
|
currentIndex: 0,
|
||||||
|
playerAnswers: new Map(),
|
||||||
|
scores,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update lobby status in DB
|
||||||
|
await updateLobbyStatus(msg.lobbyId, "in_progress");
|
||||||
|
|
||||||
|
// Broadcast first question
|
||||||
|
const firstQuestion = questions[0]!;
|
||||||
|
broadcastToLobby(msg.lobbyId, {
|
||||||
|
type: "game:question",
|
||||||
|
question: {
|
||||||
|
questionId: firstQuestion.questionId,
|
||||||
|
prompt: firstQuestion.prompt,
|
||||||
|
gloss: firstQuestion.gloss,
|
||||||
|
options: firstQuestion.options,
|
||||||
|
},
|
||||||
|
questionNumber: 1,
|
||||||
|
totalQuestions: questions.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start 15s timer
|
||||||
|
startRoundTimer(msg.lobbyId, 0, questions.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRoundTimer = (
|
||||||
|
lobbyId: string,
|
||||||
|
questionIndex: number,
|
||||||
|
totalQuestions: number,
|
||||||
|
): void => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => {
|
||||||
|
console.error("Error resolving round after timeout:", err);
|
||||||
|
});
|
||||||
|
}, 15000);
|
||||||
|
timers.set(lobbyId, timer);
|
||||||
|
};
|
||||||
65
apps/api/src/ws/index.ts
Normal file
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,
|
||||||
|
);
|
||||||
|
};
|
||||||
125
apps/api/src/ws/router.test.ts
Normal file
125
apps/api/src/ws/router.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./handlers/lobbyHandlers.js", () => ({
|
||||||
|
handleLobbyJoin: vi.fn(),
|
||||||
|
handleLobbyLeave: vi.fn(),
|
||||||
|
handleLobbyStart: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./handlers/gameHandlers.js", () => ({
|
||||||
|
handleGameAnswer: vi.fn(),
|
||||||
|
handleGameReady: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { handleMessage } from "./router.js";
|
||||||
|
import {
|
||||||
|
handleLobbyJoin,
|
||||||
|
handleLobbyLeave,
|
||||||
|
handleLobbyStart,
|
||||||
|
} from "./handlers/lobbyHandlers.js";
|
||||||
|
import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js";
|
||||||
|
|
||||||
|
const mockHandleLobbyJoin = vi.mocked(handleLobbyJoin);
|
||||||
|
const mockHandleLobbyLeave = vi.mocked(handleLobbyLeave);
|
||||||
|
const mockHandleLobbyStart = vi.mocked(handleLobbyStart);
|
||||||
|
const mockHandleGameAnswer = vi.mocked(handleGameAnswer);
|
||||||
|
const mockHandleGameReady = vi.mocked(handleGameReady);
|
||||||
|
|
||||||
|
const fakeWs = { send: vi.fn(), readyState: 1, OPEN: 1 };
|
||||||
|
|
||||||
|
const FAKE_LOBBY_ID = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const FAKE_QUESTION_ID = "00000000-0000-4000-8000-000000000002";
|
||||||
|
|
||||||
|
const fakeAuth = {
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "fake-token",
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@test.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessage", () => {
|
||||||
|
it("dispatches lobby:join to handleLobbyJoin", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleLobbyJoin).toHaveBeenCalledWith(
|
||||||
|
fakeWs,
|
||||||
|
{ type: "lobby:join", code: "ABC123" },
|
||||||
|
fakeAuth.user,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches lobby:leave to handleLobbyLeave", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "lobby:leave", lobbyId: FAKE_LOBBY_ID });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleLobbyLeave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches lobby:start to handleLobbyStart", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "lobby:start", lobbyId: FAKE_LOBBY_ID });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleLobbyStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches game:answer to handleGameAnswer", async () => {
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
type: "game:answer",
|
||||||
|
lobbyId: FAKE_LOBBY_ID,
|
||||||
|
questionId: FAKE_QUESTION_ID,
|
||||||
|
selectedOptionId: 2,
|
||||||
|
});
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleGameAnswer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches game:ready to handleGameReady", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "game:ready", lobbyId: FAKE_LOBBY_ID });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleGameReady).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends error message for invalid JSON", async () => {
|
||||||
|
await handleMessage(fakeWs as never, "not json", fakeAuth);
|
||||||
|
expect(fakeWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Invalid JSON"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends error message for unknown message type", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "unknown:type" });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(fakeWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Invalid message format"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends error message when handler throws AppError", async () => {
|
||||||
|
const { AppError } = await import("../errors/AppError.js");
|
||||||
|
mockHandleLobbyJoin.mockRejectedValueOnce(
|
||||||
|
new AppError("Lobby not found", 404),
|
||||||
|
);
|
||||||
|
const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(fakeWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Lobby not found"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/api/src/ws/router.ts
Normal file
77
apps/api/src/ws/router.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import type { Session, User } from "better-auth";
|
||||||
|
import { WsClientMessageSchema } from "@lila/shared";
|
||||||
|
import {
|
||||||
|
handleLobbyJoin,
|
||||||
|
handleLobbyLeave,
|
||||||
|
handleLobbyStart,
|
||||||
|
} from "./handlers/lobbyHandlers.js";
|
||||||
|
import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js";
|
||||||
|
import { AppError } from "../errors/AppError.js";
|
||||||
|
|
||||||
|
export type AuthenticatedUser = { session: Session; user: User };
|
||||||
|
|
||||||
|
const sendError = (ws: WebSocket, code: string, message: string): void => {
|
||||||
|
ws.send(JSON.stringify({ type: "error", code, message }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertExhaustive = (_: never): never => {
|
||||||
|
throw new Error("Unhandled message type");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleMessage = async (
|
||||||
|
ws: WebSocket,
|
||||||
|
rawData: unknown,
|
||||||
|
auth: AuthenticatedUser,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Layer 1: parse and validate incoming message
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(
|
||||||
|
typeof rawData === "string" ? rawData : (rawData as Buffer).toString(),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = WsClientMessageSchema.safeParse(parsed);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "Invalid message format" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = result.data;
|
||||||
|
|
||||||
|
// Layer 2: dispatch to handler, catch and translate errors
|
||||||
|
try {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "lobby:join":
|
||||||
|
await handleLobbyJoin(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "lobby:leave":
|
||||||
|
await handleLobbyLeave(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "lobby:start":
|
||||||
|
await handleLobbyStart(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "game:answer":
|
||||||
|
await handleGameAnswer(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
case "game:ready":
|
||||||
|
await handleGameReady(ws, msg, auth.user);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assertExhaustive(msg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
sendError(ws, err.name, err.message);
|
||||||
|
} else {
|
||||||
|
console.error("Unhandled WS error:", err);
|
||||||
|
sendError(ws, "INTERNAL_ERROR", "An unexpected error occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({ test: { environment: "node", globals: true } });
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
exclude: ["**/dist/**", "**/node_modules/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
114
apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
Normal file
114
apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import type { LobbyPlayer } from "@lila/shared";
|
||||||
|
|
||||||
|
type MultiplayerScoreScreenProps = {
|
||||||
|
players: LobbyPlayer[];
|
||||||
|
winnerIds: string[];
|
||||||
|
currentUserId: string;
|
||||||
|
lobbyCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultiplayerScoreScreen = ({
|
||||||
|
players,
|
||||||
|
winnerIds,
|
||||||
|
currentUserId,
|
||||||
|
lobbyCode,
|
||||||
|
}: MultiplayerScoreScreenProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const sortedPlayers = [...players].sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const isWinner = winnerIds.includes(currentUserId);
|
||||||
|
const isTie = winnerIds.length > 1;
|
||||||
|
|
||||||
|
const winnerNames = winnerIds
|
||||||
|
.map((id) => players.find((p) => p.userId === id)?.user.name ?? id)
|
||||||
|
.join(" and ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
|
{/* Result header */}
|
||||||
|
<div className="text-center flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold text-purple-800">
|
||||||
|
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* Score list */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedPlayers.map((player, index) => {
|
||||||
|
const isCurrentUser = player.userId === currentUserId;
|
||||||
|
const isPlayerWinner = winnerIds.includes(player.userId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={player.userId}
|
||||||
|
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
|
||||||
|
isCurrentUser
|
||||||
|
? "bg-purple-50 border border-purple-200"
|
||||||
|
: "bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-gray-400 w-4">
|
||||||
|
{index + 1}.
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isCurrentUser ? "text-purple-800" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{player.user.name}
|
||||||
|
{isCurrentUser && (
|
||||||
|
<span className="text-xs text-purple-400 ml-1">
|
||||||
|
(you)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isPlayerWinner && (
|
||||||
|
<span className="text-xs text-yellow-500 font-medium">
|
||||||
|
👑
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-gray-700">
|
||||||
|
{player.score} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate({
|
||||||
|
to: "/multiplayer/lobby/$code",
|
||||||
|
params: { code: lobbyCode },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate({ to: "/multiplayer" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
apps/web/src/lib/ws-client.ts
Normal file
135
apps/web/src/lib/ws-client.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { WsServerMessageSchema } from "@lila/shared";
|
||||||
|
import type { WsClientMessage, WsServerMessage } from "@lila/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal WebSocket client for multiplayer communication.
|
||||||
|
*
|
||||||
|
* NOTE: Callbacks registered via `on()` are stored by reference.
|
||||||
|
* When using in React components, wrap callbacks in `useCallback`
|
||||||
|
* to ensure the same reference is passed to both `on()` and `off()`.
|
||||||
|
*/
|
||||||
|
export class WsClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private callbacks = new Map<string, Set<(msg: WsServerMessage) => void>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the WebSocket connection closes.
|
||||||
|
* Set by WsProvider — do not set directly in components.
|
||||||
|
*/
|
||||||
|
public onError: ((event: Event) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the WebSocket connection encounters an error.
|
||||||
|
* Set by WsProvider — do not set directly in components.
|
||||||
|
*/
|
||||||
|
public onClose: ((event: CloseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
connect(apiUrl: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (
|
||||||
|
this.ws &&
|
||||||
|
(this.ws.readyState === WebSocket.OPEN ||
|
||||||
|
this.ws.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wsUrl: string;
|
||||||
|
if (!apiUrl) {
|
||||||
|
wsUrl = "/ws";
|
||||||
|
} else {
|
||||||
|
wsUrl =
|
||||||
|
apiUrl
|
||||||
|
.replace(/^https:\/\//, "wss://")
|
||||||
|
.replace(/^http:\/\//, "ws://") + "/ws";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event: MessageEvent) => {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.data as string);
|
||||||
|
} catch {
|
||||||
|
console.error("WsClient: received invalid JSON", event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = WsServerMessageSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("WsClient: received unknown message shape", parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = result.data;
|
||||||
|
const handlers = this.callbacks.get(msg.type);
|
||||||
|
if (!handlers) return;
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (event: Event) => {
|
||||||
|
this.onError?.(event);
|
||||||
|
reject(new Error("WebSocket connection failed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event: CloseEvent) => {
|
||||||
|
this.ws = null;
|
||||||
|
this.onClose?.(event);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (!this.ws) return;
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: WsClientMessage): void {
|
||||||
|
if (!this.isConnected()) {
|
||||||
|
console.warn(
|
||||||
|
"WsClient: attempted to send message while disconnected",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ws!.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
on<T extends WsServerMessage["type"]>(
|
||||||
|
type: T,
|
||||||
|
callback: (msg: Extract<WsServerMessage, { type: T }>) => void,
|
||||||
|
): void {
|
||||||
|
if (!this.callbacks.has(type)) {
|
||||||
|
this.callbacks.set(type, new Set());
|
||||||
|
}
|
||||||
|
this.callbacks.get(type)!.add(callback as (msg: WsServerMessage) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<T extends WsServerMessage["type"]>(
|
||||||
|
type: T,
|
||||||
|
callback: (msg: Extract<WsServerMessage, { type: T }>) => void,
|
||||||
|
): void {
|
||||||
|
const handlers = this.callbacks.get(type);
|
||||||
|
if (!handlers) return;
|
||||||
|
handlers.delete(callback as (msg: WsServerMessage) => void);
|
||||||
|
if (handlers.size === 0) {
|
||||||
|
this.callbacks.delete(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCallbacks(): void {
|
||||||
|
this.callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/web/src/lib/ws-context.ts
Normal file
11
apps/web/src/lib/ws-context.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createContext } from "react";
|
||||||
|
import type { WsClient } from "./ws-client.js";
|
||||||
|
|
||||||
|
export type WsContextValue = {
|
||||||
|
client: WsClient;
|
||||||
|
isConnected: boolean;
|
||||||
|
connect: (url: string) => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WsContext = createContext<WsContextValue | null>(null);
|
||||||
35
apps/web/src/lib/ws-hooks.ts
Normal file
35
apps/web/src/lib/ws-hooks.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { WsContext } from "./ws-context.js";
|
||||||
|
import type { WsClient } from "./ws-client.js";
|
||||||
|
|
||||||
|
export const useWsClient = (): WsClient => {
|
||||||
|
const ctx = useContext(WsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useWsClient must be used within a WsProvider");
|
||||||
|
}
|
||||||
|
return ctx.client;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWsConnected = (): boolean => {
|
||||||
|
const ctx = useContext(WsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useWsConnected must be used within a WsProvider");
|
||||||
|
}
|
||||||
|
return ctx.isConnected;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWsConnect = (): ((url: string) => Promise<void>) => {
|
||||||
|
const ctx = useContext(WsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useWsConnect must be used within a WsProvider");
|
||||||
|
}
|
||||||
|
return ctx.connect;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWsDisconnect = (): (() => void) => {
|
||||||
|
const ctx = useContext(WsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useWsDisconnect must be used within a WsProvider");
|
||||||
|
}
|
||||||
|
return ctx.disconnect;
|
||||||
|
};
|
||||||
46
apps/web/src/lib/ws-provider.tsx
Normal file
46
apps/web/src/lib/ws-provider.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { WsClient } from "./ws-client.js";
|
||||||
|
import { WsContext } from "./ws-context.js";
|
||||||
|
|
||||||
|
const wsClient = new WsClient();
|
||||||
|
|
||||||
|
export const WsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const connect = useCallback(async (url: string): Promise<void> => {
|
||||||
|
if (wsClient.isConnected()) return;
|
||||||
|
|
||||||
|
wsClient.onClose = () => setIsConnected(false);
|
||||||
|
wsClient.onError = () => setIsConnected(false);
|
||||||
|
try {
|
||||||
|
await wsClient.connect(url);
|
||||||
|
setIsConnected(true);
|
||||||
|
} catch (err) {
|
||||||
|
setIsConnected(false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback((): void => {
|
||||||
|
wsClient.disconnect();
|
||||||
|
setIsConnected(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
wsClient.disconnect();
|
||||||
|
wsClient.clearCallbacks();
|
||||||
|
wsClient.onClose = null;
|
||||||
|
wsClient.onError = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WsContext.Provider
|
||||||
|
value={{ client: wsClient, isConnected, connect, disconnect }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { StrictMode } from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
@ -20,9 +19,5 @@ declare module "@tanstack/react-router" {
|
||||||
const rootElement = document.getElementById("root")!;
|
const rootElement = document.getElementById("root")!;
|
||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(<RouterProvider router={router} />);
|
||||||
<StrictMode>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,24 @@
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as PlayRouteImport } from './routes/play'
|
import { Route as PlayRouteImport } from './routes/play'
|
||||||
|
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AboutRouteImport } from './routes/about'
|
import { Route as AboutRouteImport } from './routes/about'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
|
||||||
|
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
|
||||||
|
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
|
||||||
|
|
||||||
const PlayRoute = PlayRouteImport.update({
|
const PlayRoute = PlayRouteImport.update({
|
||||||
id: '/play',
|
id: '/play',
|
||||||
path: '/play',
|
path: '/play',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const MultiplayerRoute = MultiplayerRouteImport.update({
|
||||||
|
id: '/multiplayer',
|
||||||
|
path: '/multiplayer',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -34,38 +43,89 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const MultiplayerIndexRoute = MultiplayerIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => MultiplayerRoute,
|
||||||
|
} as any)
|
||||||
|
const MultiplayerLobbyCodeRoute = MultiplayerLobbyCodeRouteImport.update({
|
||||||
|
id: '/lobby/$code',
|
||||||
|
path: '/lobby/$code',
|
||||||
|
getParentRoute: () => MultiplayerRoute,
|
||||||
|
} as any)
|
||||||
|
const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
|
||||||
|
id: '/game/$code',
|
||||||
|
path: '/game/$code',
|
||||||
|
getParentRoute: () => MultiplayerRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/multiplayer': typeof MultiplayerIndexRoute
|
||||||
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/about' | '/login' | '/play'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/about'
|
||||||
|
| '/login'
|
||||||
|
| '/multiplayer'
|
||||||
|
| '/play'
|
||||||
|
| '/multiplayer/'
|
||||||
|
| '/multiplayer/game/$code'
|
||||||
|
| '/multiplayer/lobby/$code'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/about' | '/login' | '/play'
|
to:
|
||||||
id: '__root__' | '/' | '/about' | '/login' | '/play'
|
| '/'
|
||||||
|
| '/about'
|
||||||
|
| '/login'
|
||||||
|
| '/play'
|
||||||
|
| '/multiplayer'
|
||||||
|
| '/multiplayer/game/$code'
|
||||||
|
| '/multiplayer/lobby/$code'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/about'
|
||||||
|
| '/login'
|
||||||
|
| '/multiplayer'
|
||||||
|
| '/play'
|
||||||
|
| '/multiplayer/'
|
||||||
|
| '/multiplayer/game/$code'
|
||||||
|
| '/multiplayer/lobby/$code'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
|
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
||||||
PlayRoute: typeof PlayRoute
|
PlayRoute: typeof PlayRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +138,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PlayRouteImport
|
preLoaderRoute: typeof PlayRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/multiplayer': {
|
||||||
|
id: '/multiplayer'
|
||||||
|
path: '/multiplayer'
|
||||||
|
fullPath: '/multiplayer'
|
||||||
|
preLoaderRoute: typeof MultiplayerRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
id: '/login'
|
id: '/login'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
|
|
@ -99,13 +166,51 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/multiplayer/': {
|
||||||
|
id: '/multiplayer/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/multiplayer/'
|
||||||
|
preLoaderRoute: typeof MultiplayerIndexRouteImport
|
||||||
|
parentRoute: typeof MultiplayerRoute
|
||||||
|
}
|
||||||
|
'/multiplayer/lobby/$code': {
|
||||||
|
id: '/multiplayer/lobby/$code'
|
||||||
|
path: '/lobby/$code'
|
||||||
|
fullPath: '/multiplayer/lobby/$code'
|
||||||
|
preLoaderRoute: typeof MultiplayerLobbyCodeRouteImport
|
||||||
|
parentRoute: typeof MultiplayerRoute
|
||||||
|
}
|
||||||
|
'/multiplayer/game/$code': {
|
||||||
|
id: '/multiplayer/game/$code'
|
||||||
|
path: '/game/$code'
|
||||||
|
fullPath: '/multiplayer/game/$code'
|
||||||
|
preLoaderRoute: typeof MultiplayerGameCodeRouteImport
|
||||||
|
parentRoute: typeof MultiplayerRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MultiplayerRouteChildren {
|
||||||
|
MultiplayerIndexRoute: typeof MultiplayerIndexRoute
|
||||||
|
MultiplayerGameCodeRoute: typeof MultiplayerGameCodeRoute
|
||||||
|
MultiplayerLobbyCodeRoute: typeof MultiplayerLobbyCodeRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiplayerRouteChildren: MultiplayerRouteChildren = {
|
||||||
|
MultiplayerIndexRoute: MultiplayerIndexRoute,
|
||||||
|
MultiplayerGameCodeRoute: MultiplayerGameCodeRoute,
|
||||||
|
MultiplayerLobbyCodeRoute: MultiplayerLobbyCodeRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
|
||||||
|
MultiplayerRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
|
MultiplayerRoute: MultiplayerRouteWithChildren,
|
||||||
PlayRoute: PlayRoute,
|
PlayRoute: PlayRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,24 @@ const RootLayout = () => {
|
||||||
<Link to="/" className="[&.active]:font-bold">
|
<Link to="/" className="[&.active]:font-bold">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/about" className="[&.active]:font-bold">
|
<Link to="/play" className="[&.active]:font-bold">
|
||||||
About
|
Play
|
||||||
|
</Link>
|
||||||
|
<Link to="/multiplayer" className="[&.active]:font-bold">
|
||||||
|
Multiplayer
|
||||||
</Link>
|
</Link>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{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 signOut()
|
||||||
navigate({ to: "/" });
|
.then(() => {
|
||||||
|
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>
|
||||||
|
|
|
||||||
43
apps/web/src/routes/multiplayer.tsx
Normal file
43
apps/web/src/routes/multiplayer.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { authClient } from "../lib/auth-client.js";
|
||||||
|
import { WsProvider } from "../lib/ws-provider.js";
|
||||||
|
import { useWsConnect } from "../lib/ws-hooks.js";
|
||||||
|
|
||||||
|
const wsBaseUrl =
|
||||||
|
(import.meta.env["VITE_WS_URL"] as string) ||
|
||||||
|
(import.meta.env["VITE_API_URL"] as string) ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/multiplayer")({
|
||||||
|
component: MultiplayerLayout,
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const { data: session } = await authClient.getSession();
|
||||||
|
if (!session) {
|
||||||
|
throw redirect({ to: "/login" });
|
||||||
|
}
|
||||||
|
return { session };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function WsConnector() {
|
||||||
|
const connect = useWsConnect();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void connect(wsBaseUrl).catch((err) => {
|
||||||
|
console.error("WebSocket connection failed:", err);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiplayerLayout() {
|
||||||
|
return (
|
||||||
|
<WsProvider>
|
||||||
|
<WsConnector />
|
||||||
|
<Outlet />
|
||||||
|
</WsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/web/src/routes/multiplayer/game.$code.tsx
Normal file
188
apps/web/src/routes/multiplayer/game.$code.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
|
||||||
|
import { QuestionCard } from "../../components/game/QuestionCard.js";
|
||||||
|
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
|
||||||
|
import { GameRouteSearchSchema } from "@lila/shared";
|
||||||
|
import type {
|
||||||
|
WsGameQuestion,
|
||||||
|
WsGameAnswerResult,
|
||||||
|
WsGameFinished,
|
||||||
|
WsError,
|
||||||
|
} from "@lila/shared";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/multiplayer/game/$code")({
|
||||||
|
component: GamePage,
|
||||||
|
validateSearch: GameRouteSearchSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function GamePage() {
|
||||||
|
const { code } = Route.useParams();
|
||||||
|
const { lobbyId } = Route.useSearch();
|
||||||
|
const { session } = Route.useRouteContext();
|
||||||
|
const currentUserId = session.user.id;
|
||||||
|
const client = useWsClient();
|
||||||
|
const isConnected = useWsConnected();
|
||||||
|
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [answerResult, setAnswerResult] = useState<WsGameAnswerResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasAnswered, setHasAnswered] = useState(false);
|
||||||
|
|
||||||
|
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
|
||||||
|
setCurrentQuestion(msg);
|
||||||
|
setAnswerResult(null);
|
||||||
|
setHasAnswered(false);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAnswerResult = useCallback((msg: WsGameAnswerResult) => {
|
||||||
|
setAnswerResult(msg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGameFinished = useCallback((msg: WsGameFinished) => {
|
||||||
|
setGameFinished(msg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWsError = useCallback((msg: WsError) => {
|
||||||
|
setError(msg.message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
|
client.on("game:question", handleGameQuestion);
|
||||||
|
client.on("game:answer_result", handleAnswerResult);
|
||||||
|
client.on("game:finished", handleGameFinished);
|
||||||
|
client.on("error", handleWsError);
|
||||||
|
|
||||||
|
client.send({ type: "game:ready", lobbyId });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.off("game:question", handleGameQuestion);
|
||||||
|
client.off("game:answer_result", handleAnswerResult);
|
||||||
|
client.off("game:finished", handleGameFinished);
|
||||||
|
client.off("error", handleWsError);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
const handleAnswer = useCallback(
|
||||||
|
(optionId: number) => {
|
||||||
|
if (hasAnswered || !currentQuestion) return;
|
||||||
|
setHasAnswered(true);
|
||||||
|
client.send({
|
||||||
|
type: "game:answer",
|
||||||
|
lobbyId,
|
||||||
|
questionId: currentQuestion.question.questionId,
|
||||||
|
selectedOptionId: optionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[hasAnswered, currentQuestion, client, lobbyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase: finished
|
||||||
|
if (gameFinished) {
|
||||||
|
return (
|
||||||
|
<MultiplayerScoreScreen
|
||||||
|
players={gameFinished.players}
|
||||||
|
winnerIds={gameFinished.winnerIds}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
lobbyCode={code}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: loading
|
||||||
|
if (!isConnected || !currentQuestion) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
||||||
|
<p className="text-purple-400 text-lg font-medium">
|
||||||
|
{error ?? (isConnected ? "Loading game..." : "Connecting...")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: playing
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
|
{/* Progress */}
|
||||||
|
<p className="text-sm text-gray-500 text-center">
|
||||||
|
Question {currentQuestion.questionNumber} of{" "}
|
||||||
|
{currentQuestion.totalQuestions}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Question */}
|
||||||
|
<QuestionCard
|
||||||
|
question={currentQuestion.question}
|
||||||
|
questionNumber={currentQuestion.questionNumber}
|
||||||
|
totalQuestions={currentQuestion.totalQuestions}
|
||||||
|
currentResult={
|
||||||
|
answerResult
|
||||||
|
? {
|
||||||
|
questionId: currentQuestion.question.questionId,
|
||||||
|
isCorrect:
|
||||||
|
answerResult.results.find((r) => r.userId === currentUserId)
|
||||||
|
?.isCorrect ?? false,
|
||||||
|
correctOptionId: answerResult.correctOptionId,
|
||||||
|
selectedOptionId:
|
||||||
|
answerResult.results.find((r) => r.userId === currentUserId)
|
||||||
|
?.selectedOptionId ?? 0,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
onNext={() => {
|
||||||
|
setAnswerResult(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
|
||||||
|
{/* Round results */}
|
||||||
|
{answerResult && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
|
Round results
|
||||||
|
</h3>
|
||||||
|
{answerResult.players.map((player) => {
|
||||||
|
const result = answerResult.results.find(
|
||||||
|
(r) => r.userId === player.userId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={player.userId}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-gray-700">{player.user.name}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
result?.isCorrect
|
||||||
|
? "text-green-600 font-medium"
|
||||||
|
: "text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result?.selectedOptionId === null
|
||||||
|
? "Timed out"
|
||||||
|
: result?.isCorrect
|
||||||
|
? "✓ Correct"
|
||||||
|
: "✗ Wrong"}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">{player.score} pts</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/web/src/routes/multiplayer/index.tsx
Normal file
145
apps/web/src/routes/multiplayer/index.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Lobby } from "@lila/shared";
|
||||||
|
|
||||||
|
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
||||||
|
|
||||||
|
type LobbySuccessResponse = { success: true; data: Lobby };
|
||||||
|
type LobbyErrorResponse = { success: false; error: string };
|
||||||
|
type LobbyApiResponse = LobbySuccessResponse | LobbyErrorResponse;
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/multiplayer/")({
|
||||||
|
component: MultiplayerPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function MultiplayerPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [joinCode, setJoinCode] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isJoining, setIsJoining] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async (): Promise<void> => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/v1/lobbies`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = (await response.json()) as LobbyApiResponse;
|
||||||
|
if (!data.success) {
|
||||||
|
setError(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void navigate({
|
||||||
|
to: "/multiplayer/lobby/$code",
|
||||||
|
params: { code: data.data.code },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError("Could not connect to server. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (): Promise<void> => {
|
||||||
|
const code = joinCode.trim().toUpperCase();
|
||||||
|
if (!code) {
|
||||||
|
setError("Please enter a lobby code.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsJoining(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/v1/lobbies/${code}/join`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = (await response.json()) as LobbyApiResponse;
|
||||||
|
if (!data.success) {
|
||||||
|
setError(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void navigate({
|
||||||
|
to: "/multiplayer/lobby/$code",
|
||||||
|
params: { code: data.data.code },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError("Could not connect to server. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsJoining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-purple-800">
|
||||||
|
Multiplayer
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
|
||||||
|
{/* Create lobby */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
|
Create a lobby
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Start a new game and invite friends with a code.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCreate().catch((err) => {
|
||||||
|
console.error("Create lobby error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating || isJoining}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating..." : "Create Lobby"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* Join lobby */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Enter the code shared by your host.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||||
|
placeholder="Enter code (e.g. WOLF42)"
|
||||||
|
value={joinCode}
|
||||||
|
onChange={(e) => setJoinCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
void handleJoin().catch((err) => {
|
||||||
|
console.error("Join lobby error:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={10}
|
||||||
|
disabled={isCreating || isJoining}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
void handleJoin().catch((err) => {
|
||||||
|
console.error("Join lobby error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating || isJoining || !joinCode.trim()}
|
||||||
|
>
|
||||||
|
{isJoining ? "Joining..." : "Join Lobby"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
apps/web/src/routes/multiplayer/lobby.$code.tsx
Normal file
159
apps/web/src/routes/multiplayer/lobby.$code.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
|
||||||
|
import type {
|
||||||
|
Lobby,
|
||||||
|
WsLobbyState,
|
||||||
|
WsError,
|
||||||
|
WsGameQuestion,
|
||||||
|
} from "@lila/shared";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/multiplayer/lobby/$code")({
|
||||||
|
component: LobbyPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LobbyPage() {
|
||||||
|
const { code } = Route.useParams();
|
||||||
|
const { session } = Route.useRouteContext();
|
||||||
|
const currentUserId = session.user.id;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const client = useWsClient();
|
||||||
|
const isConnected = useWsConnected();
|
||||||
|
|
||||||
|
const [lobby, setLobby] = useState<Lobby | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const lobbyIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleLobbyState = useCallback((msg: WsLobbyState) => {
|
||||||
|
setLobby(msg.lobby);
|
||||||
|
lobbyIdRef.current = msg.lobby.id;
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGameQuestion = useCallback(
|
||||||
|
(_msg: WsGameQuestion) => {
|
||||||
|
void navigate({
|
||||||
|
to: "/multiplayer/game/$code",
|
||||||
|
params: { code },
|
||||||
|
search: { lobbyId: lobbyIdRef.current ?? "" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate, code],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWsError = useCallback((msg: WsError) => {
|
||||||
|
setError(msg.message);
|
||||||
|
setIsStarting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
|
client.on("lobby:state", handleLobbyState);
|
||||||
|
client.on("game:question", handleGameQuestion);
|
||||||
|
client.on("error", handleWsError);
|
||||||
|
|
||||||
|
client.send({ type: "lobby:join", code });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.off("lobby:state", handleLobbyState);
|
||||||
|
client.off("game:question", handleGameQuestion);
|
||||||
|
client.off("error", handleWsError);
|
||||||
|
if (lobbyIdRef.current) {
|
||||||
|
client.send({ type: "lobby:leave", lobbyId: lobbyIdRef.current });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
const handleStart = useCallback(() => {
|
||||||
|
if (!lobby) return;
|
||||||
|
setIsStarting(true);
|
||||||
|
client.send({ type: "lobby:start", lobbyId: lobby.id });
|
||||||
|
}, [lobby, client]);
|
||||||
|
|
||||||
|
if (!isConnected || !lobby) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
||||||
|
<p className="text-purple-400 text-lg font-medium">
|
||||||
|
{error ?? (isConnected ? "Joining lobby..." : "Connecting...")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHost = lobby.hostUserId === currentUserId;
|
||||||
|
const canStart = isHost && lobby.players.length >= 2 && !isStarting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
|
{/* Lobby code */}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<p className="text-sm text-gray-500">Lobby code</p>
|
||||||
|
<button
|
||||||
|
className="text-4xl font-bold tracking-widest text-purple-800 hover:text-purple-600 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(code);
|
||||||
|
}}
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400">Click to copy</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* Player list */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
|
Players ({lobby.players.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{lobby.players.map((player) => (
|
||||||
|
<li
|
||||||
|
key={player.userId}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||||
|
{player.user.name}
|
||||||
|
{player.userId === lobby.hostUserId && (
|
||||||
|
<span className="text-xs text-purple-500 font-medium">
|
||||||
|
host
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
|
||||||
|
{/* Start button — host only */}
|
||||||
|
{isHost && (
|
||||||
|
<button
|
||||||
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={!canStart}
|
||||||
|
>
|
||||||
|
{isStarting
|
||||||
|
? "Starting..."
|
||||||
|
: lobby.players.length < 2
|
||||||
|
? "Waiting for players..."
|
||||||
|
: "Start Game"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-host waiting message */}
|
||||||
|
{!isHost && (
|
||||||
|
<p className="text-sm text-gray-500 text-center">
|
||||||
|
Waiting for host to start the game...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,22 @@ export default defineConfig({
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
server: { proxy: { "/api": "http://localhost:3000" } },
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/ws": {
|
||||||
|
target: "http://localhost:3000",
|
||||||
|
ws: true,
|
||||||
|
rewriteWsOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (err) => {
|
||||||
|
console.log("[ws proxy error]", err.message);
|
||||||
|
});
|
||||||
|
proxy.on("proxyReqWs", (_proxyReq, req) => {
|
||||||
|
console.log("[ws proxy] forwarding", req.url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api": { target: "http://localhost:3000", changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ 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 |
|
||||||
|
|
@ -21,7 +21,7 @@ This document describes the production deployment of the lila vocabulary trainer
|
||||||
### 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) |
|
||||||
|
|
|
||||||
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.
|
||||||
|
|
@ -21,9 +21,14 @@ WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/c
|
||||||
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)
|
||||||
|
<<<<<<< HEAD
|
||||||
- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy
|
- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy
|
||||||
|
|
||||||
### ~~cd/ci pipeline~~ ✅ RESOLVED
|
### ~~cd/ci pipeline~~ ✅ RESOLVED
|
||||||
|
|
@ -33,6 +38,8 @@ Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `de
|
||||||
### ~~postgres backups~~ ✅ RESOLVED
|
### ~~postgres backups~~ ✅ RESOLVED
|
||||||
|
|
||||||
Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`.
|
Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`.
|
||||||
|
=======
|
||||||
|
>>>>>>> dev
|
||||||
|
|
||||||
### try now option
|
### try now option
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,7 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
|
||||||
|
|
||||||
## 11. Post-MVP Ladder
|
## 11. Post-MVP Ladder
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
| Phase | What it adds | Status |
|
| Phase | What it adds | Status |
|
||||||
| ----------------- | ------------------------------------------------------------------------------- | ------ |
|
| ----------------- | ------------------------------------------------------------------------------- | ------ |
|
||||||
| 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 | ✅ |
|
||||||
|
|
@ -299,6 +300,17 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
|
||||||
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
||||||
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
||||||
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
||||||
|
=======
|
||||||
|
| Phase | What it adds | Status |
|
||||||
|
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
||||||
|
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
||||||
|
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
||||||
|
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
||||||
|
| User Stats | Games played, score history, profile page | ❌ |
|
||||||
|
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
||||||
|
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
||||||
|
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
||||||
|
>>>>>>> dev
|
||||||
|
|
||||||
### Future Data Model Extensions (deferred, additive)
|
### Future Data Model Extensions (deferred, additive)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
133
packages/db/src/models/lobbyModel.ts
Normal file
133
packages/db/src/models/lobbyModel.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { db } from "@lila/db";
|
||||||
|
import { lobbies, lobby_players } from "@lila/db/schema";
|
||||||
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { LobbyStatus } from "@lila/shared";
|
||||||
|
|
||||||
|
export type Lobby = typeof lobbies.$inferSelect;
|
||||||
|
export type LobbyPlayer = typeof lobby_players.$inferSelect;
|
||||||
|
export type LobbyWithPlayers = Lobby & {
|
||||||
|
players: (LobbyPlayer & { user: { id: string; name: string } })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createLobby = async (
|
||||||
|
code: string,
|
||||||
|
hostUserId: string,
|
||||||
|
): Promise<Lobby> => {
|
||||||
|
const [newLobby] = await db
|
||||||
|
.insert(lobbies)
|
||||||
|
.values({ code, hostUserId, status: "waiting" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!newLobby) {
|
||||||
|
throw new Error("Failed to create lobby");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLobby;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLobbyByCodeWithPlayers = async (
|
||||||
|
code: string,
|
||||||
|
): Promise<LobbyWithPlayers | undefined> => {
|
||||||
|
return db.query.lobbies.findFirst({
|
||||||
|
where: eq(lobbies.code, code),
|
||||||
|
with: {
|
||||||
|
players: { with: { user: { columns: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLobbyByIdWithPlayers = async (
|
||||||
|
lobbyId: string,
|
||||||
|
): Promise<LobbyWithPlayers | undefined> => {
|
||||||
|
return db.query.lobbies.findFirst({
|
||||||
|
where: eq(lobbies.id, lobbyId),
|
||||||
|
with: {
|
||||||
|
players: { with: { user: { columns: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateLobbyStatus = async (
|
||||||
|
lobbyId: string,
|
||||||
|
status: LobbyStatus,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteLobby = async (lobbyId: string): Promise<void> => {
|
||||||
|
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically inserts a player into a lobby. Returns the new player row,
|
||||||
|
* or undefined if the insert was skipped because:
|
||||||
|
* - the lobby is at capacity, or
|
||||||
|
* - the lobby is not in 'waiting' status, or
|
||||||
|
* - the user is already in the lobby (PK conflict).
|
||||||
|
*
|
||||||
|
* Callers are expected to pre-check these conditions against a hydrated
|
||||||
|
* lobby state to produce specific error messages; the undefined return
|
||||||
|
* is a safety net for concurrent races.
|
||||||
|
*/
|
||||||
|
export const addPlayer = async (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
maxPlayers: number,
|
||||||
|
): Promise<LobbyPlayer | undefined> => {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO lobby_players (lobby_id, user_id)
|
||||||
|
SELECT ${lobbyId}::uuid, ${userId}
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid
|
||||||
|
) < ${maxPlayers}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting'
|
||||||
|
)
|
||||||
|
ON CONFLICT (lobby_id, user_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!result.rowCount) return undefined;
|
||||||
|
const [player] = await db
|
||||||
|
.select()
|
||||||
|
.from(lobby_players)
|
||||||
|
.where(
|
||||||
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePlayer = async (
|
||||||
|
lobbyId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.delete(lobby_players)
|
||||||
|
.where(
|
||||||
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finishGame = async (
|
||||||
|
lobbyId: string,
|
||||||
|
scoresByUser: Map<string, number>,
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [userId, score] of scoresByUser) {
|
||||||
|
await tx
|
||||||
|
.update(lobby_players)
|
||||||
|
.set({ score })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lobby_players.lobbyId, lobbyId),
|
||||||
|
eq(lobby_players.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(lobbies)
|
||||||
|
.set({ status: "finished" })
|
||||||
|
.where(eq(lobbies.id, lobbyId));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
140
packages/shared/src/schemas/lobby.ts
Normal file
140
packages/shared/src/schemas/lobby.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { LOBBY_STATUSES } from "../constants.js";
|
||||||
|
import { GameQuestionSchema } from "./game.js";
|
||||||
|
|
||||||
|
export const LobbyPlayerSchema = z.object({
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
userId: z.string(),
|
||||||
|
score: z.number().int().min(0),
|
||||||
|
user: z.object({ id: z.string(), name: z.string() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LobbyPlayer = z.infer<typeof LobbyPlayerSchema>;
|
||||||
|
|
||||||
|
export const LobbySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
code: z.string().min(1).max(10),
|
||||||
|
hostUserId: z.string(),
|
||||||
|
status: z.enum(LOBBY_STATUSES),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Lobby = z.infer<typeof LobbySchema>;
|
||||||
|
|
||||||
|
export const JoinLobbyResponseSchema = LobbySchema;
|
||||||
|
|
||||||
|
export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>;
|
||||||
|
|
||||||
|
export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() });
|
||||||
|
export type GameRouteSearch = z.infer<typeof GameRouteSearchSchema>;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// WebSocket: Client → Server
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const WsLobbyJoinSchema = z.object({
|
||||||
|
type: z.literal("lobby:join"),
|
||||||
|
code: z.string().min(1).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyJoin = z.infer<typeof WsLobbyJoinSchema>;
|
||||||
|
|
||||||
|
export const WsLobbyLeaveSchema = z.object({
|
||||||
|
type: z.literal("lobby:leave"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyLeave = z.infer<typeof WsLobbyLeaveSchema>;
|
||||||
|
|
||||||
|
export const WsLobbyStartSchema = z.object({
|
||||||
|
type: z.literal("lobby:start"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyStart = z.infer<typeof WsLobbyStartSchema>;
|
||||||
|
|
||||||
|
export const WsGameReadySchema = z.object({
|
||||||
|
type: z.literal("game:ready"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
});
|
||||||
|
export type WsGameReady = z.infer<typeof WsGameReadySchema>;
|
||||||
|
|
||||||
|
export const WsGameAnswerSchema = z.object({
|
||||||
|
type: z.literal("game:answer"),
|
||||||
|
lobbyId: z.uuid(),
|
||||||
|
questionId: z.uuid(),
|
||||||
|
selectedOptionId: z.number().int().min(0).max(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameAnswer = z.infer<typeof WsGameAnswerSchema>;
|
||||||
|
|
||||||
|
export const WsClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
WsLobbyJoinSchema,
|
||||||
|
WsLobbyLeaveSchema,
|
||||||
|
WsLobbyStartSchema,
|
||||||
|
WsGameAnswerSchema,
|
||||||
|
WsGameReadySchema,
|
||||||
|
]);
|
||||||
|
export type WsClientMessage = z.infer<typeof WsClientMessageSchema>;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// WebSocket: Server → Client
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const WsLobbyStateSchema = z.object({
|
||||||
|
type: z.literal("lobby:state"),
|
||||||
|
lobby: LobbySchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsLobbyState = z.infer<typeof WsLobbyStateSchema>;
|
||||||
|
|
||||||
|
export const WsGameQuestionSchema = z.object({
|
||||||
|
type: z.literal("game:question"),
|
||||||
|
question: GameQuestionSchema,
|
||||||
|
questionNumber: z.number().int().min(1),
|
||||||
|
totalQuestions: z.number().int().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameQuestion = z.infer<typeof WsGameQuestionSchema>;
|
||||||
|
|
||||||
|
export const WsGameAnswerResultSchema = z.object({
|
||||||
|
type: z.literal("game:answer_result"),
|
||||||
|
correctOptionId: z.number().int().min(0).max(3),
|
||||||
|
results: z.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
selectedOptionId: z.number().int().min(0).max(3).nullable(),
|
||||||
|
isCorrect: z.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameAnswerResult = z.infer<typeof WsGameAnswerResultSchema>;
|
||||||
|
|
||||||
|
export const WsGameFinishedSchema = z.object({
|
||||||
|
type: z.literal("game:finished"),
|
||||||
|
players: z.array(LobbyPlayerSchema),
|
||||||
|
winnerIds: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WsGameFinished = z.infer<typeof WsGameFinishedSchema>;
|
||||||
|
|
||||||
|
export const WsErrorSchema = z.object({
|
||||||
|
type: z.literal("error"),
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
export type WsError = z.infer<typeof WsErrorSchema>;
|
||||||
|
|
||||||
|
export const WsServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
WsLobbyStateSchema,
|
||||||
|
WsGameQuestionSchema,
|
||||||
|
WsGameAnswerResultSchema,
|
||||||
|
WsGameFinishedSchema,
|
||||||
|
WsErrorSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type WsServerMessage = z.infer<typeof WsServerMessageSchema>;
|
||||||
27
pnpm-lock.yaml
generated
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