diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e04bda5..411cda0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,6 +20,7 @@ import { gameRouter } from "./routes/game.js"; import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; +import { timersRouter } from "./routes/timers.js"; import { transcendenceRouter } from "./routes/transcendence.js"; import { connectGateway } from "./services/gateway.js"; import { logger } from "./services/logger.js"; @@ -49,6 +50,7 @@ app.route("/transcendence", transcendenceRouter); app.route("/apotheosis", apotheosisRouter); app.route("/leaderboards", leaderboardRouter); app.route("/profile", profileRouter); +app.route("/timers", timersRouter); app.get("/health", (context) => { return context.json({ status: "ok" }); diff --git a/apps/api/src/routes/timers.ts b/apps/api/src/routes/timers.ts new file mode 100644 index 0000000..a409ea5 --- /dev/null +++ b/apps/api/src/routes/timers.ts @@ -0,0 +1,125 @@ +/** + * @file Public read-only timer API for external tooling (bots, automations, etc.). + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { Hono } from "hono"; +import { defaultExplorations } from "../data/explorations.js"; +import { prisma } from "../db/client.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { GameState } from "@elysium/types"; + +const timersRouter = new Hono(); + +const explorationNameMap = new Map( + defaultExplorations.map((area) => { + return [ area.id, area.name ]; + }), +); + +/** + * Extracts active quest timers from a game state. + * @param state - The player's game state. + * @param now - The current timestamp in milliseconds. + * @returns An array of active quest timer objects. + */ +const getQuestTimers = ( + state: GameState, + now: number, +): Array<{ + endsAt: number; + name: string; + questId: string; + timeLeft: number; +}> => { + return state.quests. + filter((quest) => { + return quest.status === "in_progress" && quest.startedAt !== undefined; + }). + map((quest) => { + const durationMs = quest.durationSeconds * 1000; + /* v8 ignore next -- @preserve */ + const endsAt = (quest.startedAt ?? 0) + durationMs; + return { + endsAt: endsAt, + name: quest.name, + questId: quest.id, + timeLeft: Math.max(0, endsAt - now), + }; + }); +}; + +/** + * Extracts active exploration timers from a game state. + * @param state - The player's game state. + * @param now - The current timestamp in milliseconds. + * @returns An array of active exploration timer objects. + */ +const getExplorationTimers = ( + state: GameState, + now: number, +): Array<{ + areaId: string; + endsAt: number; + name: string; + timeLeft: number; +}> => { + return (state.exploration?.areas ?? []). + filter((area) => { + return area.status === "in_progress" && area.endsAt !== undefined; + }). + map((area) => { + /* v8 ignore next -- @preserve */ + const endsAt = area.endsAt ?? 0; + return { + areaId: area.id, + endsAt: endsAt, + name: explorationNameMap.get(area.id) ?? area.id, + timeLeft: Math.max(0, endsAt - now), + }; + }); +}; + +/** + * Returns active quest and exploration timers for a given player. + * This endpoint is public and read-only — no authentication required. + * Rate limiting is enforced at the infrastructure level. + */ +timersRouter.get("/:userId", async(context) => { + try { + const { userId } = context.req.param(); + + if (userId.length === 0 || !/^\d+$/u.test(userId)) { + return context.json({ error: "Invalid user ID" }, 400); + } + + const record = await prisma.gameState.findUnique({ + where: { discordId: userId }, + }); + + if (record === null) { + return context.json({ error: "Player not found" }, 404); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + const now = Date.now(); + + return context.json({ + explorations: getExplorationTimers(state, now), + quests: getQuestTimers(state, now), + }); + } catch (error) { + void logger.error( + "timers", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { timersRouter }; diff --git a/apps/api/test/routes/timers.spec.ts b/apps/api/test/routes/timers.spec.ts new file mode 100644 index 0000000..fea395e --- /dev/null +++ b/apps/api/test/routes/timers.spec.ts @@ -0,0 +1,189 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn() }, + }, +})); + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + log: vi.fn().mockResolvedValue(undefined), + }, +})); + +const makeState = (overrides: Record = {}) => ({ + quests: [], + exploration: { areas: [] }, + ...overrides, +}); + +describe("timers route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { timersRouter } = await import("../../src/routes/timers.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/timers", timersRouter); + }); + + const get = (userId: string) => + app.fetch(new Request(`http://localhost/timers/${userId}`)); + + it("returns 400 for a non-numeric user ID", async () => { + const res = await get("not-a-number"); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Invalid user ID"); + }); + + it("returns 404 when player is not found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await get("123456789"); + expect(res.status).toBe(404); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Player not found"); + }); + + it("returns empty arrays when no active quests or explorations", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ + state: makeState(), + }); + const res = await get("123456789"); + expect(res.status).toBe(200); + const body = await res.json() as { quests: unknown[]; explorations: unknown[] }; + expect(body.quests).toEqual([]); + expect(body.explorations).toEqual([]); + }); + + it("returns active quest timers with endsAt computed from startedAt + duration", async () => { + const startedAt = Date.now() - 30_000; + const state = makeState({ + quests: [ + { + id: "q1", + name: "Forest Patrol", + status: "in_progress", + startedAt: startedAt, + durationSeconds: 600, + }, + ], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state }); + const res = await get("123456789"); + expect(res.status).toBe(200); + const body = await res.json() as { + quests: Array<{ questId: string; name: string; endsAt: number; timeLeft: number }>; + }; + expect(body.quests).toHaveLength(1); + expect(body.quests[0]?.questId).toBe("q1"); + expect(body.quests[0]?.name).toBe("Forest Patrol"); + expect(body.quests[0]?.endsAt).toBe(startedAt + 600_000); + expect(body.quests[0]?.timeLeft).toBeGreaterThan(0); + }); + + it("filters out quests that are not in_progress", async () => { + const state = makeState({ + quests: [ + { id: "q1", name: "Done Quest", status: "completed", startedAt: 0, durationSeconds: 60 }, + { id: "q2", name: "Idle Quest", status: "available", durationSeconds: 60 }, + ], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state }); + const res = await get("123456789"); + const body = await res.json() as { quests: unknown[] }; + expect(body.quests).toHaveLength(0); + }); + + it("returns timeLeft of 0 for already-completed quests still marked in_progress", async () => { + const startedAt = Date.now() - 700_000; + const state = makeState({ + quests: [ + { + id: "q1", + name: "Old Quest", + status: "in_progress", + startedAt: startedAt, + durationSeconds: 600, + }, + ], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state }); + const res = await get("123456789"); + const body = await res.json() as { + quests: Array<{ timeLeft: number }>; + }; + expect(body.quests[0]?.timeLeft).toBe(0); + }); + + it("returns active exploration timers", async () => { + const endsAt = Date.now() + 120_000; + const state = makeState({ + exploration: { + areas: [ + { id: "verdant_meadows", status: "in_progress", endsAt }, + { id: "unknown_area_xyz", status: "in_progress", endsAt }, + ], + }, + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state }); + const res = await get("123456789"); + const body = await res.json() as { + explorations: Array<{ areaId: string; name: string; endsAt: number; timeLeft: number }>; + }; + expect(body.explorations).toHaveLength(2); + expect(body.explorations[0]?.areaId).toBe("verdant_meadows"); + expect(body.explorations[0]?.endsAt).toBe(endsAt); + expect(body.explorations[0]?.timeLeft).toBeGreaterThan(0); + // Unknown area falls back to ID as name + expect(body.explorations[1]?.name).toBe("unknown_area_xyz"); + }); + + it("filters out explorations not in_progress", async () => { + const state = makeState({ + exploration: { + areas: [ + { id: "area1", status: "available" }, + { id: "area2", status: "completed" }, + ], + }, + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state }); + const res = await get("123456789"); + const body = await res.json() as { explorations: unknown[] }; + expect(body.explorations).toHaveLength(0); + }); + + it("handles missing exploration state gracefully", async () => { + const state = { quests: [] }; + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state }); + const res = await get("123456789"); + expect(res.status).toBe(200); + const body = await res.json() as { explorations: unknown[] }; + expect(body.explorations).toHaveLength(0); + }); + + it("returns 500 on database error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce( + new Error("DB failure"), + ); + const res = await get("123456789"); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + + it("returns 500 and logs non-Error throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error"); + const res = await get("123456789"); + expect(res.status).toBe(500); + }); +});