generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
/* 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: {
|
||||
player: { findMany: vi.fn() },
|
||||
gameState: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
||||
discordId: "player_1",
|
||||
characterName: "Hero",
|
||||
username: "hero",
|
||||
avatar: null,
|
||||
profileSettings: null,
|
||||
activeTitle: null,
|
||||
lifetimeGoldEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
lifetimeAchievementsUnlocked: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("leaderboards route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { player: { findMany: ReturnType<typeof vi.fn> }; gameState: { findMany: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { leaderboardRouter } = await import("../../src/routes/leaderboards.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
});
|
||||
|
||||
const get = (query = "") =>
|
||||
app.fetch(new Request(`http://localhost/leaderboards${query ? `?${query}` : ""}`));
|
||||
|
||||
it("returns 400 for an invalid category", async () => {
|
||||
const res = await get("category=invalid");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns totalGold leaderboard by default", async () => {
|
||||
const players = [
|
||||
makePlayer({ discordId: "p1", lifetimeGoldEarned: 1000 }),
|
||||
makePlayer({ discordId: "p2", lifetimeGoldEarned: 500 }),
|
||||
];
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce(players as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { category: string; entries: Array<{ rank: number; value: number }> };
|
||||
expect(body.category).toBe("totalGold");
|
||||
expect(body.entries[0]?.value).toBe(1000);
|
||||
expect(body.entries[0]?.rank).toBe(1);
|
||||
});
|
||||
|
||||
it("returns bossesDefeated leaderboard", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ lifetimeBossesDefeated: 42 })] as never);
|
||||
const res = await get("category=bossesDefeated");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(42);
|
||||
});
|
||||
|
||||
it("returns questsCompleted leaderboard", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ lifetimeQuestsCompleted: 7 })] as never);
|
||||
const res = await get("category=questsCompleted");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(7);
|
||||
});
|
||||
|
||||
it("returns achievementsUnlocked leaderboard", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ lifetimeAchievementsUnlocked: 3 })] as never);
|
||||
const res = await get("category=achievementsUnlocked");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(3);
|
||||
});
|
||||
|
||||
it("returns prestigeCount from game state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 5 }, transcendence: null, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=prestigeCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(5);
|
||||
});
|
||||
|
||||
it("returns transcendenceCount from game state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: { count: 2 }, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=transcendenceCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(2);
|
||||
});
|
||||
|
||||
it("returns apotheosisCount from game state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: null, apotheosis: { count: 1 } },
|
||||
}] as never);
|
||||
const res = await get("category=apotheosisCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("filters out players with showOnLeaderboards=false", async () => {
|
||||
const players = [
|
||||
makePlayer({ discordId: "visible", lifetimeGoldEarned: 100 }),
|
||||
makePlayer({ discordId: "hidden", lifetimeGoldEarned: 200, profileSettings: { showOnLeaderboards: false } }),
|
||||
];
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce(players as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ discordId: string }> };
|
||||
expect(body.entries).toHaveLength(1);
|
||||
expect(body.entries[0]?.discordId).toBe("visible");
|
||||
});
|
||||
|
||||
it("respects the limit parameter", async () => {
|
||||
const players = Array.from({ length: 5 }, (_, i) => makePlayer({ discordId: `p${String(i)}`, lifetimeGoldEarned: i }));
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce(players as never);
|
||||
const res = await get("limit=2");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: unknown[] };
|
||||
expect(body.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses active title name in entries", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([
|
||||
makePlayer({ discordId: "p1", activeTitle: "the_first" }),
|
||||
] as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ activeTitle: string }> };
|
||||
// title may or may not be found — just verify the field exists
|
||||
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
||||
});
|
||||
|
||||
it("defaults to 0 for game-state categories when state is missing", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||
const res = await get("category=prestigeCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves title name when active title ID is found in TITLES", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([
|
||||
makePlayer({ discordId: "p1", activeTitle: "the_adventurous" }),
|
||||
] as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ activeTitle: string }> };
|
||||
// "the_adventurous" has name "The Adventurous" in TITLES — should differ from raw ID
|
||||
expect(body.entries[0]?.activeTitle).toBe("The Adventurous");
|
||||
});
|
||||
|
||||
it("defaults to 0 for transcendenceCount when transcendence is null in state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: null, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=transcendenceCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults to 0 for apotheosisCount when apotheosis is null in state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: null, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=apotheosisCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user