generated from nhcarrigan/template
feat: public read-only timers API for quest and exploration timers
Closes #229
This commit is contained in:
@@ -20,6 +20,7 @@ import { gameRouter } from "./routes/game.js";
|
|||||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||||
import { prestigeRouter } from "./routes/prestige.js";
|
import { prestigeRouter } from "./routes/prestige.js";
|
||||||
import { profileRouter } from "./routes/profile.js";
|
import { profileRouter } from "./routes/profile.js";
|
||||||
|
import { timersRouter } from "./routes/timers.js";
|
||||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||||
import { connectGateway } from "./services/gateway.js";
|
import { connectGateway } from "./services/gateway.js";
|
||||||
import { logger } from "./services/logger.js";
|
import { logger } from "./services/logger.js";
|
||||||
@@ -49,6 +50,7 @@ app.route("/transcendence", transcendenceRouter);
|
|||||||
app.route("/apotheosis", apotheosisRouter);
|
app.route("/apotheosis", apotheosisRouter);
|
||||||
app.route("/leaderboards", leaderboardRouter);
|
app.route("/leaderboards", leaderboardRouter);
|
||||||
app.route("/profile", profileRouter);
|
app.route("/profile", profileRouter);
|
||||||
|
app.route("/timers", timersRouter);
|
||||||
|
|
||||||
app.get("/health", (context) => {
|
app.get("/health", (context) => {
|
||||||
return context.json({ status: "ok" });
|
return context.json({ status: "ok" });
|
||||||
|
|||||||
@@ -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<HonoEnvironment>();
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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<string, unknown> = {}) => ({
|
||||||
|
quests: [],
|
||||||
|
exploration: { areas: [] },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("timers route", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user