diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 9746328..a4eb176 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; +import type { GameSession, AnswerResult } from "@lila/shared"; + +type SuccessResponse = { success: true; data: T }; +type ErrorResponse = { success: false; error: string }; +type GameStartResponse = SuccessResponse; +type GameAnswerResponse = SuccessResponse; vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); @@ -33,49 +39,48 @@ beforeEach(() => { describe("POST /api/v1/game/start", () => { it("returns 200 with a valid game session", async () => { const res = await request(app).post("/api/v1/game/start").send(validBody); - + const body = res.body as GameStartResponse; expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.sessionId).toBeDefined(); - expect(res.body.data.questions).toHaveLength(3); + expect(body.success).toBe(true); + expect(body.data.sessionId).toBeDefined(); + expect(body.data.questions).toHaveLength(3); }); it("returns 400 when the body is empty", async () => { const res = await request(app).post("/api/v1/game/start").send({}); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - expect(res.body.error).toBeDefined(); + expect(body.success).toBe(false); + expect(body.error).toBeDefined(); }); it("returns 400 when required fields are missing", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ source_language: "en" }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + expect(body.success).toBe(false); }); it("returns 400 when a field has an invalid value", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ ...validBody, difficulty: "impossible" }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + expect(body.success).toBe(false); }); }); describe("POST /api/v1/game/answer", () => { it("returns 200 with an answer result for a valid submission", async () => { - // Start a game first const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); - - const { sessionId, questions } = startRes.body.data; - const question = questions[0]; + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + const question = questions[0]!; const res = await request(app) .post("/api/v1/game/answer") @@ -84,20 +89,20 @@ describe("POST /api/v1/game/answer", () => { questionId: question.questionId, selectedOptionId: 0, }); - + const body = res.body as GameAnswerResponse; expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.questionId).toBe(question.questionId); - expect(typeof res.body.data.isCorrect).toBe("boolean"); - expect(typeof res.body.data.correctOptionId).toBe("number"); - expect(res.body.data.selectedOptionId).toBe(0); + expect(body.success).toBe(true); + expect(body.data.questionId).toBe(question.questionId); + expect(typeof body.data.isCorrect).toBe("boolean"); + expect(typeof body.data.correctOptionId).toBe("number"); + expect(body.data.selectedOptionId).toBe(0); }); it("returns 400 when the body is empty", async () => { const res = await request(app).post("/api/v1/game/answer").send({}); - + const body = res.body as ErrorResponse; expect(res.status).toBe(400); - expect(res.body.success).toBe(false); + expect(body.success).toBe(false); }); it("returns 404 when the session does not exist", async () => { @@ -108,18 +113,18 @@ describe("POST /api/v1/game/answer", () => { questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(404); - expect(res.body.success).toBe(false); - expect(res.body.error).toContain("Game session not found"); + expect(body.success).toBe(false); + expect(body.error).toContain("Game session not found"); }); it("returns 404 when the question does not exist in the session", async () => { const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); - - const { sessionId } = startRes.body.data; + const startBody = startRes.body as GameStartResponse; + const { sessionId } = startBody.data; const res = await request(app) .post("/api/v1/game/answer") @@ -128,9 +133,9 @@ describe("POST /api/v1/game/answer", () => { questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }); - + const body = res.body as ErrorResponse; expect(res.status).toBe(404); - expect(res.body.success).toBe(false); - expect(res.body.error).toContain("Question not found"); + expect(body.success).toBe(false); + expect(body.error).toContain("Question not found"); }); }); diff --git a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts index d4a339c..f29ca59 100644 --- a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts @@ -3,15 +3,17 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js"; export class InMemoryGameSessionStore implements GameSessionStore { private sessions = new Map(); - async create(sessionId: string, data: GameSessionData): Promise { + create(sessionId: string, data: GameSessionData): Promise { this.sessions.set(sessionId, data); + return Promise.resolve(); } - async get(sessionId: string): Promise { - return this.sessions.get(sessionId) ?? null; + get(sessionId: string): Promise { + return Promise.resolve(this.sessions.get(sessionId) ?? null); } - async delete(sessionId: string): Promise { + delete(sessionId: string): Promise { this.sessions.delete(sessionId); + return Promise.resolve(); } } diff --git a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts index 7accbb9..d3d00f2 100644 --- a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts +++ b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts @@ -3,22 +3,25 @@ import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; export class InMemoryLobbyGameStore implements LobbyGameStore { private games = new Map(); - async create(lobbyId: string, data: LobbyGameData): Promise { + create(lobbyId: string, data: LobbyGameData): Promise { if (this.games.has(lobbyId)) { throw new Error(`Game already exists for lobby: ${lobbyId}`); } this.games.set(lobbyId, data); + return Promise.resolve(); } - async get(lobbyId: string): Promise { - return this.games.get(lobbyId) ?? null; + get(lobbyId: string): Promise { + return Promise.resolve(this.games.get(lobbyId) ?? null); } - async set(lobbyId: string, data: LobbyGameData): Promise { + set(lobbyId: string, data: LobbyGameData): Promise { this.games.set(lobbyId, data); + return Promise.resolve(); } - async delete(lobbyId: string): Promise { + delete(lobbyId: string): Promise { this.games.delete(lobbyId); + return Promise.resolve(); } } diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts index 5f2be8d..2fe1ac8 100644 --- a/apps/api/src/types/express.d.ts +++ b/apps/api/src/types/express.d.ts @@ -1,5 +1,4 @@ import type { Session, User } from "better-auth"; -import type { WebSocket } from "ws"; declare global { namespace Express { diff --git a/apps/api/src/ws/connections.ts b/apps/api/src/ws/connections.ts index e97e2e7..189be61 100644 --- a/apps/api/src/ws/connections.ts +++ b/apps/api/src/ws/connections.ts @@ -24,7 +24,7 @@ export const removeConnection = (lobbyId: string, userId: string): void => { }; export const getConnections = (lobbyId: string): Map => { - return connections.get(lobbyId) ?? new Map(); + return connections.get(lobbyId) ?? new Map(); }; export const broadcastToLobby = ( diff --git a/apps/api/src/ws/handlers/gameHandlers.ts b/apps/api/src/ws/handlers/gameHandlers.ts index 49a92ae..ee06ce8 100644 --- a/apps/api/src/ws/handlers/gameHandlers.ts +++ b/apps/api/src/ws/handlers/gameHandlers.ts @@ -126,27 +126,29 @@ export const resolveRound = async ( await endGame(lobbyId, state); } else { // Wait 3s then broadcast next question - setTimeout(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(async () => { - await resolveRound(lobbyId, fresh.currentIndex, totalQuestions); - }, 15000); - timers.set(lobbyId, timer); + 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); } }; diff --git a/apps/api/src/ws/handlers/lobbyHandlers.ts b/apps/api/src/ws/handlers/lobbyHandlers.ts index a02f2e2..12a59bf 100644 --- a/apps/api/src/ws/handlers/lobbyHandlers.ts +++ b/apps/api/src/ws/handlers/lobbyHandlers.ts @@ -148,8 +148,10 @@ const startRoundTimer = ( questionIndex: number, totalQuestions: number, ): void => { - const timer = setTimeout(async () => { - await resolveRound(lobbyId, questionIndex, totalQuestions); + const timer = setTimeout(() => { + void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => { + console.error("Error resolving round after timeout:", err); + }); }, 15000); timers.set(lobbyId, timer); }; diff --git a/apps/api/src/ws/index.ts b/apps/api/src/ws/index.ts index 4540ce0..048b734 100644 --- a/apps/api/src/ws/index.ts +++ b/apps/api/src/ws/index.ts @@ -15,18 +15,31 @@ export const setupWebSocket = (server: Server): WebSocketServer => { socket.destroy(); return; } - handleUpgrade(request, socket, head, wss); + 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) => { - handleMessage(ws, rawData, auth); + void handleMessage(ws, rawData, auth).catch((err) => { + console.error( + `WebSocket message error for user ${auth.user.id}:`, + err, + ); + }); }); ws.on("close", () => { - handleDisconnect(ws, auth); + void handleDisconnect(ws, auth).catch((err) => { + console.error( + `WebSocket disconnect error for user ${auth.user.id}:`, + err, + ); + }); }); ws.on("error", (err) => { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1448282..e7fe4b0 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -24,9 +24,13 @@ const RootLayout = () => { {session ? ( diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index d55bff0..32db5c4 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -6,9 +6,12 @@ import { ScoreScreen } from "../components/game/ScoreScreen"; import { GameSetup } from "../components/game/GameSetup"; import { authClient } from "../lib/auth-client"; -function Play() { - const API_URL = import.meta.env["VITE_API_URL"] || ""; +type GameStartResponse = { success: true; data: GameSession }; +type GameAnswerResponse = { success: true; data: AnswerResult }; +const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; + +function Play() { const [gameSession, setGameSession] = useState(null); const [isLoading, setIsLoading] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -17,13 +20,15 @@ function Play() { const startGame = useCallback(async (settings: GameRequest) => { setIsLoading(true); + const response = await fetch(`${API_URL}/api/v1/game/start`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(settings), }); - const data = await response.json(); + + const data = (await response.json()) as GameStartResponse; setGameSession(data.data); setCurrentQuestionIndex(0); setResults([]); @@ -55,7 +60,7 @@ function Play() { selectedOptionId: optionId, }), }); - const data = await response.json(); + const data = (await response.json()) as GameAnswerResponse; setCurrentResult(data.data); }; @@ -70,7 +75,13 @@ function Play() { if (!gameSession && !isLoading) { return (
- + { + void startGame(settings).catch((err) => { + console.error("Start game error:", err); + }); + }} + />
); } @@ -99,11 +110,15 @@ function Play() { return (
{ + void handleAnswer(optionId).catch((err) => { + console.error("Answer error:", err); + }); + }} question={question} questionNumber={currentQuestionIndex + 1} totalQuestions={gameSession.questions.length} currentResult={currentResult} - onAnswer={handleAnswer} onNext={handleNext} />
diff --git a/eslint.config.mjs b/eslint.config.mjs index f7b125e..31b3da2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default defineConfig([ "eslint.config.mjs", "**/*.config.ts", "routeTree.gen.ts", + "scripts/**", ]), eslint.configs.recommended, @@ -38,6 +39,27 @@ export default defineConfig([ }, { files: ["apps/web/src/routes/**/*.{ts,tsx}"], - rules: { "react-refresh/only-export-components": "off" }, + rules: { + "react-refresh/only-export-components": "off", + "@typescript-eslint/only-throw-error": "off", + }, + }, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, + { + // better-auth's createAuthClient return type is insufficiently typed upstream. + // This is a known issue: https://github.com/better-auth/better-auth/issues + files: ["apps/web/src/lib/auth-client.ts"], + rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, }, ]);