feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## 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:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
describe("about route", () => {
const mockFetch = vi.fn();
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
mockFetch.mockReset();
});
const makeApp = async () => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
it("returns releases from a successful fetch", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual(releases);
});
it("returns empty releases when fetch is not ok", async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual([]);
});
it("returns empty releases when fetch throws", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual([]);
});
it("returns cached releases on second call within TTL", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
const app = await makeApp();
// First call populates cache
await app.fetch(new Request("http://localhost/about"));
// Second call should use cache, not call fetch again
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("includes apiVersion in response", async () => {
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { apiVersion: string };
expect(typeof body.apiVersion).toBe("string");
});
});
+107
View File
@@ -0,0 +1,107 @@
/* 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
vi.mock("../../src/services/webhook.js", () => ({
grantApotheosisRole: vi.fn().mockResolvedValue(undefined),
postMilestoneWebhook: vi.fn().mockResolvedValue(undefined),
}));
const DISCORD_ID = "test_discord_id";
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("apotheosis route", () => {
let app: Hono;
let prisma: { player: { update: ReturnType<typeof vi.fn> }; gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { apotheosisRouter } = await import("../../src/routes/apotheosis.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/apotheosis", apotheosisRouter);
});
const post = (path = "/apotheosis") =>
app.fetch(new Request(`http://localhost${path}`, { method: "POST" }));
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post();
expect(res.status).toBe(404);
});
it("returns 400 when not eligible for apotheosis", async () => {
// State without all transcendence upgrades purchased
const state = makeState({
transcendence: {
count: 1, echoes: 0, purchasedUpgradeIds: [],
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post();
expect(res.status).toBe(400);
});
it("returns apotheosis count on success", async () => {
// Need all 15 transcendence upgrades purchased for eligibility
const allUpgradeIds = [
"echo_income_1", "echo_income_2", "echo_income_3", "echo_income_4", "echo_income_5",
"echo_combat_1", "echo_combat_2", "echo_combat_3",
"echo_prestige_threshold_1", "echo_prestige_threshold_2",
"echo_prestige_runestones_1", "echo_prestige_runestones_2",
"echo_meta_1", "echo_meta_2", "echo_meta_3",
];
const state = makeState({
transcendence: {
count: 1, echoes: 0, purchasedUpgradeIds: allUpgradeIds,
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post();
expect(res.status).toBe(200);
const body = await res.json() as { apotheosisCount: number };
expect(body.apotheosisCount).toBe(1);
});
});
+117
View File
@@ -0,0 +1,117 @@
/* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
gameState: {
create: vi.fn(),
},
},
}));
vi.mock("../../src/services/discord.js", () => ({
buildOAuthUrl: vi.fn(),
exchangeCode: vi.fn(),
fetchDiscordUser: vi.fn(),
}));
vi.mock("../../src/services/jwt.js", () => ({
signToken: vi.fn().mockReturnValue("test_jwt"),
}));
describe("auth route", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env["CORS_ORIGIN"] = "http://localhost:5173";
});
afterEach(() => {
delete process.env["CORS_ORIGIN"];
});
const makeApp = async () => {
const { authRouter } = await import("../../src/routes/auth.js");
const { buildOAuthUrl, exchangeCode, fetchDiscordUser } = await import("../../src/services/discord.js");
const { prisma } = await import("../../src/db/client.js");
const app = new Hono();
app.route("/auth", authRouter);
return { app, buildOAuthUrl: vi.mocked(buildOAuthUrl), exchangeCode: vi.mocked(exchangeCode), fetchDiscordUser: vi.mocked(fetchDiscordUser), prisma };
};
describe("GET /url", () => {
it("returns the OAuth URL when buildOAuthUrl succeeds", async () => {
const { app, buildOAuthUrl } = await makeApp();
buildOAuthUrl.mockReturnValueOnce("https://discord.com/oauth2/authorize?...");
const res = await app.fetch(new Request("http://localhost/auth/url"));
expect(res.status).toBe(200);
const body = await res.json() as { url: string };
expect(body.url).toContain("discord.com");
});
it("returns 500 when buildOAuthUrl throws", async () => {
const { app, buildOAuthUrl } = await makeApp();
buildOAuthUrl.mockImplementationOnce(() => { throw new Error("Missing env"); });
const res = await app.fetch(new Request("http://localhost/auth/url"));
expect(res.status).toBe(500);
});
});
describe("GET /callback", () => {
it("returns 400 when code parameter is missing", async () => {
const { app } = await makeApp();
const res = await app.fetch(new Request("http://localhost/auth/callback"));
expect(res.status).toBe(400);
});
it("redirects with isNew=true for a new user", async () => {
const { app, exchangeCode, fetchDiscordUser, prisma } = await makeApp();
exchangeCode.mockResolvedValueOnce({ access_token: "token" });
fetchDiscordUser.mockResolvedValueOnce({ id: "new_user", username: "Newbie", discriminator: "0", avatar: null });
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
const createdPlayer = {
discordId: "new_user", username: "Newbie", discriminator: "0", avatar: null,
characterName: "Newbie", createdAt: 0, lastSavedAt: 0,
totalGoldEarned: 0, totalClicks: 0, lifetimeGoldEarned: 0, lifetimeClicks: 0,
lifetimeBossesDefeated: 0, lifetimeQuestsCompleted: 0,
lifetimeAdventurersRecruited: 0, lifetimeAchievementsUnlocked: 0,
};
vi.mocked(prisma.player.create).mockResolvedValueOnce(createdPlayer as never);
vi.mocked(prisma.gameState.create).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/auth/callback?code=auth_code"));
expect(res.status).toBe(302);
const location = res.headers.get("Location") ?? "";
expect(location).toContain("isNew=true");
expect(location).toContain("token=test_jwt");
});
it("redirects with isNew=false for an existing user", async () => {
const { app, exchangeCode, fetchDiscordUser, prisma } = await makeApp();
exchangeCode.mockResolvedValueOnce({ access_token: "token" });
fetchDiscordUser.mockResolvedValueOnce({ id: "existing_user", username: "OldTimer", discriminator: "0", avatar: null });
const existingPlayer = { discordId: "existing_user", username: "OldTimer", discriminator: "0", avatar: null, characterName: "OldTimer" };
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(existingPlayer as never);
const updatedPlayer = { ...existingPlayer, discordId: "existing_user" };
vi.mocked(prisma.player.update).mockResolvedValueOnce(updatedPlayer as never);
const res = await app.fetch(new Request("http://localhost/auth/callback?code=auth_code"));
expect(res.status).toBe(302);
const location = res.headers.get("Location") ?? "";
expect(location).toContain("isNew=false");
});
it("redirects with error when callback throws", async () => {
const { app, exchangeCode } = await makeApp();
exchangeCode.mockRejectedValueOnce(new Error("OAuth failed"));
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
expect(res.status).toBe(302);
const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed");
});
});
});
+296
View File
@@ -0,0 +1,296 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
const makeBoss = (overrides: Record<string, unknown> = {}) => ({
id: "test_boss",
zoneId: "test_zone",
status: "available",
prestigeRequirement: 0,
currentHp: 100,
maxHp: 100,
damagePerSecond: 1,
goldReward: 50,
essenceReward: 10,
crystalReward: 0,
upgradeRewards: [] as string[],
equipmentRewards: [] as string[],
...overrides,
});
const makeAdventurer = (overrides: Record<string, unknown> = {}) => ({
id: "test_adventurer",
count: 1,
combatPower: 10000, // Very high DPS to guarantee win
level: 10,
unlocked: true,
goldPerSecond: 1,
essencePerSecond: 0,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("boss route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { bossRouter } = await import("../../src/routes/boss.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/boss", bossRouter);
});
const challenge = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/boss/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when bossId is missing", async () => {
const res = await challenge({});
expect(res.status).toBe(400);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(404);
});
it("returns 404 when boss is not in state", async () => {
const state = makeState({ bosses: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(404);
});
it("returns 400 when boss is already defeated", async () => {
const state = makeState({ bosses: [makeBoss({ status: "defeated" })] as GameState["bosses"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(400);
});
it("returns 403 when prestige requirement is not met", async () => {
const state = makeState({
bosses: [makeBoss({ prestigeRequirement: 5 })] as GameState["bosses"],
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(403);
});
it("returns 400 when party has no adventurers", async () => {
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(400);
});
it("returns won=true when party defeats boss", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer({ combatPower: 10000, count: 1, level: 10 })] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { gold: number } };
expect(body.won).toBe(true);
expect(body.rewards.gold).toBe(50);
});
it("returns won=false when party is defeated", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 })] as GameState["bosses"],
// Include an adventurer with count=0 to cover the casualty-loop skip branch
adventurers: [
makeAdventurer({ combatPower: 1, count: 10, level: 1 }),
makeAdventurer({ id: "zero_count_adventurer", combatPower: 0, count: 0, level: 1 }),
] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; casualties: Array<{ adventurerId: string }> };
expect(body.won).toBe(false);
expect(Array.isArray(body.casualties)).toBe(true);
});
it("skips zone unlock when zone is already unlocked and bossId matches", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
// Zone is already unlocked — the loop should skip it via the status==="unlocked" continue
zones: [{ id: "test_zone", status: "unlocked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips zone unlock when quest condition is not satisfied", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
// Zone has unlockBossId matching but the required quest is not completed
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: "required_quest" }] as GameState["zones"],
quests: [{ id: "required_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks next zone boss when boss is defeated and zone condition is met", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0 });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("handles boss with upgrade and equipment rewards on win", async () => {
const state = makeState({
bosses: [makeBoss({
upgradeRewards: ["some_upgrade"],
equipmentRewards: ["some_equipment"],
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
upgrades: [{ id: "some_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 }] as GameState["upgrades"],
equipment: [{ id: "some_equipment", owned: false, equipped: false, type: "weapon", bonus: {} }] as GameState["equipment"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[]; equipmentIds: string[] } };
expect(body.won).toBe(true);
expect(body.rewards.upgradeIds).toContain("some_upgrade");
expect(body.rewards.equipmentIds).toContain("some_equipment");
});
it("updates daily challenge progress on boss defeat", async () => {
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
dailyChallenges: {
date: "2024-01-01",
challenges: [{ id: "boss_challenge", type: "bossesDefeated", target: 3, progress: 0, completed: false, crystalReward: 5 }],
} as GameState["dailyChallenges"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
});
it("applies adventurer-specific upgrade to party DPS", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer({ id: "test_adventurer" })] as GameState["adventurers"],
upgrades: [{ id: "adv_upgrade", purchased: true, unlocked: true, target: "adventurer", adventurerId: "test_adventurer", multiplier: 2 }] as GameState["upgrades"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("applies global upgrade multiplier to party DPS when global upgrade is purchased", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer({ combatPower: 10000, count: 1 })] as GameState["adventurers"],
upgrades: [{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 }] as GameState["upgrades"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks zone when boss defeated and quest condition is also satisfied", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: "test_quest" }] as GameState["zones"],
quests: [{ id: "test_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
});
+146
View File
@@ -0,0 +1,146 @@
/* 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
// heartwood_tincture requires 5 verdant_sap + 3 forest_crystal
const TEST_RECIPE_ID = "heartwood_tincture";
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: {
areas: [],
materials: [{ materialId: "verdant_sap", quantity: 10 }, { materialId: "forest_crystal", quantity: 5 }],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("craft route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { craftRouter } = await import("../../src/routes/craft.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/craft", craftRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/craft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when recipeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown recipe", async () => {
const res = await post({ recipeId: "nonexistent_recipe" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when no exploration state exists", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when recipe is already crafted", async () => {
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [TEST_RECIPE_ID], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough materials", async () => {
const state = makeState({
exploration: {
areas: [],
materials: [{ materialId: "verdant_sap", quantity: 1 }], // needs 5
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when second material is completely absent from list", async () => {
// verdant_sap present (enough), but forest_crystal absent entirely — quantity ?? 0 = 0
const state = makeState({
exploration: {
areas: [],
materials: [{ materialId: "verdant_sap", quantity: 10 }],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns craft result on success", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const body = await res.json() as { recipeId: string; bonusType: string };
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
});
});
+410
View File
@@ -0,0 +1,410 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
// verdant_meadow is the first area in verdant_vale zone
const TEST_AREA_ID = "verdant_meadow";
const TEST_ZONE_ID = "verdant_vale";
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [{ id: TEST_ZONE_ID, status: "unlocked" }] as GameState["zones"],
exploration: {
areas: [{ id: TEST_AREA_ID, status: "available", completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("explore route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { exploreRouter } = await import("../../src/routes/explore.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/explore", exploreRouter);
});
const postStart = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/explore/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
const postCollect = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/explore/collect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postStart({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await postStart({ areaId: "nonexistent_area" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when zone is not unlocked", async () => {
const state = makeState({ zones: [{ id: TEST_ZONE_ID, status: "locked" }] as GameState["zones"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 404 when area is not found in state", async () => {
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when another exploration is already in progress", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "available" }, { id: "other_area", status: "in_progress" }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when area is locked", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "locked" }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("starts exploration and returns endsAt on success", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { areaId: string; endsAt: number };
expect(body.areaId).toBe(TEST_AREA_ID);
expect(body.endsAt).toBeGreaterThan(Date.now());
});
it("backfills exploration state for old saves without exploration", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
// Even with backfilled state, verdant_meadow may not be available initially — just check the route runs
const res = await postStart({ areaId: TEST_AREA_ID });
// Should not be a 500; either 200 or a game-logic error
expect(res.status).not.toBe(500);
});
});
describe("POST /collect", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postCollect({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await postCollect({ areaId: "nonexistent_area" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when no exploration state exists", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 404 when area is not found in state", async () => {
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when area is not in progress", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when exploration is not yet complete", async () => {
const now = Date.now();
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: now, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("collects exploration results when complete", async () => {
// Set startedAt far in the past so it's definitely complete
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; materialsFound: unknown[] };
expect(typeof body.foundNothing).toBe("boolean");
expect(Array.isArray(body.materialsFound)).toBe(true);
});
it("returns foundNothing=true when random triggers the nothing path", async () => {
const mockRandom = vi.spyOn(Math, "random");
// First call: the nothing probability check (< 0.2 triggers nothing)
mockRandom.mockReturnValueOnce(0.1);
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; nothingMessage: string };
expect(body.foundNothing).toBe(true);
expect(typeof body.nothingMessage).toBe("string");
mockRandom.mockRestore();
});
it("applies gold_loss event and pushes new material from possibleMaterials", async () => {
const mockRandom = vi.spyOn(Math, "random");
// verdant_meadow events: [gold_gain(0), gold_loss(1), material_gain(2), essence_gain(3)]
mockRandom
.mockReturnValueOnce(0.5) // nothing check: 0.5 >= 0.2 → proceed
.mockReturnValueOnce(0.26) // event: Math.floor(0.26 * 4) = 1 → gold_loss
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 3 = 0, 0 - 3 = -3 ≤ 0 → verdant_sap
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 1 = 1
const state = makeState({
resources: { gold: 100, essence: 0, crystals: 0, runestones: 0 },
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; event: { goldChange: number }; materialsFound: Array<{ materialId: string }> };
expect(body.foundNothing).toBe(false);
expect(body.event.goldChange).toBeLessThan(0);
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
mockRandom.mockRestore();
});
it("applies essence_gain event during exploration collect", async () => {
const mockRandom = vi.spyOn(Math, "random");
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.76) // event: Math.floor(0.76 * 4) = 3 → essence_gain
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
.mockReturnValueOnce(0); // quantity → 1
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { essenceChange: number } };
expect(body.event.essenceChange).toBeGreaterThan(0);
mockRandom.mockRestore();
});
it("pushes new material via material_gain event when material not already in list", async () => {
const mockRandom = vi.spyOn(Math, "random");
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.51) // event: Math.floor(0.51 * 4) = 2 → material_gain (verdant_sap qty=2)
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
.mockReturnValueOnce(0); // quantity → 1
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
expect(body.event.materialGained?.materialId).toBe("verdant_sap");
mockRandom.mockRestore();
});
it("increments existing material quantity via material_gain event", async () => {
const mockRandom = vi.spyOn(Math, "random");
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.51) // event: Math.floor(0.51 * 4) = 2 → material_gain (verdant_sap qty=2)
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
.mockReturnValueOnce(0); // quantity → 1
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [{ materialId: "verdant_sap", quantity: 5 }],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
expect(body.event.materialGained?.materialId).toBe("verdant_sap");
mockRandom.mockRestore();
});
it("increments existing material quantity when material already in list", async () => {
const mockRandom = vi.spyOn(Math, "random");
// verdant_meadow has 4 events (indices 0-3), 1 possibleMaterial (verdant_sap, weight=3)
mockRandom
.mockReturnValueOnce(0.5) // nothing check: 0.5 >= 0.2 → proceed
.mockReturnValueOnce(0) // event selection: Math.floor(0 * 4) = 0 → gold_gain (index 0)
.mockReturnValueOnce(0) // material roll: 0 * 3 = 0, then 0 - 3 = -3 <= 0 → verdant_sap selected
.mockReturnValueOnce(0); // quantity roll: Math.floor(0 * 3) + 1 = 1
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [{ materialId: "verdant_sap", quantity: 5 }],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
mockRandom.mockRestore();
});
});
});
+444
View File
@@ -0,0 +1,444 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { findUnique: vi.fn(), update: vi.fn() },
gameState: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), upsert: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1;
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: Date.now() - 60_000, // 60 seconds ago
schemaVersion: CURRENT_SCHEMA_VERSION,
...overrides,
} as GameState);
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
discordId: DISCORD_ID,
characterName: "T",
username: "u",
discriminator: "0",
avatar: null,
createdAt: Date.now(),
lastSavedAt: 0,
totalGoldEarned: 0,
totalClicks: 0,
lifetimeGoldEarned: 0,
lifetimeClicks: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
lifetimeAdventurersRecruited: 0,
lifetimeAchievementsUnlocked: 0,
loginStreak: 1,
lastLoginDate: null,
unlockedTitles: null,
guildName: null,
...overrides,
});
describe("game route", () => {
let app: Hono;
let prisma: {
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
delete process.env["ANTI_CHEAT_SECRET"];
const { gameRouter } = await import("../../src/routes/game.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/game", gameRouter);
});
afterEach(() => {
delete process.env["ANTI_CHEAT_SECRET"];
});
describe("GET /load", () => {
it("returns 404 when neither game state nor player exists", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(404);
});
it("creates fresh state when game state is missing but player exists", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.create).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { offlineGold: number; schemaOutdated: boolean };
expect(body.offlineGold).toBe(0);
expect(body.schemaOutdated).toBe(false);
});
it("returns state with offline earnings when game state exists", async () => {
const state = makeState({ lastTickAt: Date.now() - 10_000 }); // 10 seconds ago
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never).mockRejectedValueOnce(Object.assign(new Error("conflict"), { code: "P2034" }));
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState; offlineSeconds: number; currentSchemaVersion: number };
expect(body.currentSchemaVersion).toBe(CURRENT_SCHEMA_VERSION);
expect(typeof body.offlineSeconds).toBe("number");
});
it("awards login bonus when player logs in on a new day", async () => {
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: yesterday, loginStreak: 3 }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { loginBonus: { streak: number; goldEarned: number } | null };
expect(body.loginBonus).not.toBeNull();
expect(body.loginBonus?.streak).toBe(4);
});
it("resets streak when login gap is more than one day", async () => {
const longAgo = "2020-01-01";
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: longAgo, loginStreak: 10 }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { loginBonus: { streak: number } | null };
expect(body.loginBonus?.streak).toBe(1);
});
it("does not award login bonus when already logged in today", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: todayUTC }) as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { loginBonus: null };
expect(body.loginBonus).toBeNull();
});
it("includes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(typeof body.signature).toBe("string");
});
it("marks schema as outdated when save has older schema version", async () => {
const state = makeState({ schemaVersion: 0 });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { schemaOutdated: boolean };
expect(body.schemaOutdated).toBe(true);
});
it("returns non-zero offline earnings when adventurers have production stats", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState({
adventurers: [{
id: "worker", count: 1, unlocked: true, level: 1,
goldPerSecond: 1, essencePerSecond: 1, combatPower: 0,
}] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { offlineGold: number; offlineEssence: number };
expect(body.offlineGold).toBeGreaterThan(0);
expect(body.offlineEssence).toBeGreaterThan(0);
});
});
describe("POST /save", () => {
const save = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/game/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when state is missing from body", async () => {
const res = await save({});
expect(res.status).toBe(400);
});
it("returns 409 when save schema version is outdated", async () => {
const state = makeState({ schemaVersion: 0 });
const res = await save({ state });
expect(res.status).toBe(409);
});
it("saves state when no previous record exists", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const state = makeState();
const res = await save({ state });
expect(res.status).toBe(200);
const body = await res.json() as { savedAt: number };
expect(body.savedAt).toBeGreaterThan(0);
});
it("validates and sanitizes state when previous record exists", async () => {
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
});
it("rejects save with wrong HMAC signature when secret is configured", async () => {
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
const prevState = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
const res = await save({ state: makeState(), signature: "wrong_signature" });
expect(res.status).toBe(400);
});
it("accepts save with correct HMAC signature", async () => {
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
const { createHmac } = await import("node:crypto");
const prevState = makeState();
const correctSig = createHmac("sha256", "my_secret").update(JSON.stringify(prevState)).digest("hex");
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: makeState(), signature: correctSig });
expect(res.status).toBe(200);
});
it("unlocks new titles and persists them", async () => {
const prevState = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ guildName: "My Guild" }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: makeState() });
expect(res.status).toBe(200);
// Just verifies the route completes without error when title checking runs
});
it("exercises all validateAndSanitize branches with rich state", async () => {
const now = Date.now();
const prevState = makeState({
resources: { gold: 1000, essence: 50, crystals: 5, runestones: 5 },
adventurers: [
{ id: "militia", count: 5, unlocked: true, level: 2, goldPerSecond: 0.5, essencePerSecond: 0, combatPower: 3 },
] as GameState["adventurers"],
upgrades: [
{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 },
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 1.5 },
] as GameState["upgrades"],
quests: [
// main path: active → completed (startedAt far in past → expired)
{ id: "first_steps", status: "active", startedAt: 1000 },
// defensive: prevQuest.status === "completed" → skip in computeQuestRewards
{ id: "goblin_camp", status: "completed", startedAt: 1000 },
// defensive: prevQuest.status !== "active" → skip
{ id: "haunted_mine", status: "locked", startedAt: null },
// defensive: startedAt == null → skip
{ id: "ancient_ruins", status: "active", startedAt: null },
// defensive: !questData → skip (not in DEFAULT_QUESTS)
{ id: "not_a_real_quest", status: "active", startedAt: 1000 },
// anti-rollback: completed in prev, active in incoming → quests.map restores completed
{ id: "rollback_quest", status: "completed", startedAt: 1000 },
] as GameState["quests"],
bosses: [
// main path in computeBossRewards: available → defeated
{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, damagePerSecond: 5, goldReward: 10000, essenceReward: 25, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
// defensive: prevBoss.status === "defeated" → skip
{ id: "lich_queen", status: "defeated", currentHp: 0, maxHp: 10000, damagePerSecond: 20, goldReward: 100000, essenceReward: 200, crystalReward: 10, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
// defensive: prevBoss.status === "locked" → skip
{ id: "forest_giant", status: "locked", currentHp: 35000, maxHp: 35000, damagePerSecond: 40, goldReward: 350000, essenceReward: 400, crystalReward: 20, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
// defensive: !bossData → skip (not in DEFAULT_BOSSES)
{ id: "not_a_real_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
// anti-rollback: defeated in prev, available in incoming → bosses.map restores defeated
{ id: "anti_rollback_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
] as GameState["bosses"],
achievements: [
{ id: "ach1", unlockedAt: 1000 }, // prev has unlockedAt → anti-rollback when incoming=null
{ id: "ach2", unlockedAt: null }, // prev null → future timestamp check → caught
{ id: "ach3", unlockedAt: null }, // prev null → legitimate past unlock → return a
] as GameState["achievements"],
exploration: {
areas: [],
materials: [{ materialId: "verdant_sap", quantity: 10 }],
craftedRecipeIds: ["haunted_mine_recipe"],
craftedGoldMultiplier: 2,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
transcendence: { count: 1, echoes: 10, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
apotheosis: { count: 2 },
story: { unlockedChapterIds: ["ch1"], completedChapters: [{ chapterId: "ch1", completedAt: 1000 }] },
});
const incomingState = makeState({
resources: { gold: 1e18, essence: 1e18, crystals: 5, runestones: 0 },
adventurers: [
{ id: "militia", count: 5, unlocked: true, level: 2, goldPerSecond: 0.5, essencePerSecond: 0, combatPower: 3 },
] as GameState["adventurers"],
upgrades: [
{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 },
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 1.5 },
] as GameState["upgrades"],
quests: [
{ id: "first_steps", status: "completed", startedAt: 1000 }, // was active → now completed
{ id: "goblin_camp", status: "completed", startedAt: 1000 }, // both completed → skip
{ id: "haunted_mine", status: "completed", startedAt: null }, // prevStatus=locked → skip
{ id: "ancient_ruins", status: "completed", startedAt: null }, // startedAt=null → skip
{ id: "not_a_real_quest", status: "completed", startedAt: 1000 }, // !questData → skip
{ id: "rollback_quest", status: "active", startedAt: 1000 }, // anti-rollback → restored
{ id: "orphan_quest", status: "completed", startedAt: 1000 }, // !prevQuest → skip
{ id: "still_active_quest", status: "active", startedAt: 1000 }, // status !== completed → skip
] as GameState["quests"],
bosses: [
{ id: "troll_king", status: "defeated", currentHp: 0, maxHp: 1000, damagePerSecond: 5, goldReward: 10000, essenceReward: 25, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
{ id: "lich_queen", status: "defeated", currentHp: 0, maxHp: 10000, damagePerSecond: 20, goldReward: 100000, essenceReward: 200, crystalReward: 10, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
{ id: "forest_giant", status: "defeated", currentHp: 0, maxHp: 35000, damagePerSecond: 40, goldReward: 350000, essenceReward: 400, crystalReward: 20, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
{ id: "not_a_real_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
{ id: "anti_rollback_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
{ id: "orphan_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
{ id: "still_available_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
] as GameState["bosses"],
achievements: [
{ id: "ach1", unlockedAt: null }, // prev had unlockedAt → anti-rollback restores it
{ id: "ach2", unlockedAt: now + 99999 }, // future timestamp → cheat caught
{ id: "ach3", unlockedAt: 1000 }, // past timestamp → legitimate unlock
{ id: "ach4", unlockedAt: null }, // not in prev → !prev → return a
] as GameState["achievements"],
exploration: {
areas: [],
materials: [{ materialId: "verdant_sap", quantity: 1000 }], // inflated → capped at 10
craftedRecipeIds: ["haunted_mine_recipe", "fake_recipe"], // fake_recipe filtered out
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
apotheosis: { count: 5 },
story: {
unlockedChapterIds: ["ch1", "ch2"],
completedChapters: [{ chapterId: "ch1", completedAt: 1000 }, { chapterId: "ch2", completedAt: now }],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const body = await res.json() as { savedAt: number };
expect(body.savedAt).toBeGreaterThan(0);
});
it("validates companion when active companion is legitimately unlocked", async () => {
const prevState = makeState();
const stateWithCompanion = makeState({
companions: { unlockedCompanionIds: [], activeCompanionId: "lyra" },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lifetimeBossesDefeated: 100 }) as never,
);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: stateWithCompanion });
expect(res.status).toBe(200);
});
});
describe("POST /reset", () => {
const reset = () =>
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
it("returns 404 when player is not found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
const res = await reset();
expect(res.status).toBe(404);
});
it("creates fresh state and returns it on success", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await reset();
expect(res.status).toBe(200);
const body = await res.json() as { offlineGold: number; schemaOutdated: boolean; loginBonus: null };
expect(body.offlineGold).toBe(0);
expect(body.schemaOutdated).toBe(false);
expect(body.loginBonus).toBeNull();
});
it("includes HMAC signature in reset response when secret is configured", async () => {
process.env["ANTI_CHEAT_SECRET"] = "reset_secret";
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await reset();
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(typeof body.signature).toBe("string");
});
});
});
+198
View File
@@ -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);
});
});
+156
View File
@@ -0,0 +1,156 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
vi.mock("../../src/services/webhook.js", () => ({
postMilestoneWebhook: vi.fn().mockResolvedValue(undefined),
}));
const DISCORD_ID = "test_discord_id";
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 1_000_000, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("prestige route", () => {
let app: Hono;
let prisma: {
player: { update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { prestigeRouter } = await import("../../src/routes/prestige.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/prestige", prestigeRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/prestige${path}`, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
it("returns 400 when not eligible (not enough gold)", async () => {
const state = makeState({ player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns runestones on successful prestige", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { runestones: number; newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
expect(body.runestones).toBeGreaterThanOrEqual(0);
});
it("updates daily challenge progress when dailyChallenges are set", async () => {
const state = makeState({
dailyChallenges: {
date: "2024-01-01",
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
} as GameState["dailyChallenges"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { runestones: number; newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
});
it("returns 404 for unknown upgrade", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(404);
});
it("returns 400 when upgrade is already purchased", async () => {
const state = makeState({ prestige: { count: 0, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: ["income_1"] } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(400);
});
it("returns 400 when not enough runestones", async () => {
const state = makeState({ prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
// income_1 costs 10 runestones but state has 0
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(400);
});
it("returns updated multipliers on successful purchase", async () => {
const state = makeState({ prestige: { count: 0, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(200);
const body = await res.json() as { runestonesRemaining: number; purchasedUpgradeIds: string[] };
expect(body.runestonesRemaining).toBe(90); // 100 - 10
expect(body.purchasedUpgradeIds).toContain("income_1");
});
});
});
+242
View File
@@ -0,0 +1,242 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { findUnique: vi.fn(), update: vi.fn() },
gameState: { findUnique: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
discordId: DISCORD_ID,
characterName: "Hero",
username: "hero",
discriminator: "0",
avatar: null,
pronouns: "she/her",
characterRace: "Elf",
characterClass: "Mage",
bio: "A brave hero",
guildName: "Brave Guild",
guildDescription: "We are brave",
profileSettings: null,
createdAt: 1000,
lastSavedAt: 2000,
lifetimeGoldEarned: 500,
lifetimeClicks: 100,
lifetimeBossesDefeated: 5,
lifetimeQuestsCompleted: 10,
lifetimeAdventurersRecruited: 20,
lifetimeAchievementsUnlocked: 3,
unlockedTitles: null,
activeTitle: null,
loginStreak: 1,
lastLoginDate: null,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "hero", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "Hero" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("profile route", () => {
let app: Hono;
let prisma: {
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { profileRouter } = await import("../../src/routes/profile.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/profile", profileRouter);
});
describe("GET /:discordId", () => {
it("returns 404 when player is not found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request("http://localhost/profile/unknown_id"));
expect(res.status).toBe(404);
});
it("returns player profile with game state data", async () => {
const state = makeState({
prestige: { count: 3, runestones: 10, productionMultiplier: 1.45, purchasedUpgradeIds: [] },
bosses: [{ id: "b1", status: "defeated" }] as GameState["bosses"],
quests: [{ id: "q1", status: "completed" }] as GameState["quests"],
achievements: [{ id: "a1", unlockedAt: 1000 }] as GameState["achievements"],
transcendence: { count: 1, echoes: 10, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
apotheosis: { count: 1 },
});
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as {
characterName: string;
prestigeCount: number;
bossesDefeated: number;
questsCompleted: number;
achievementsUnlocked: number;
transcendenceCount: number;
apotheosisCount: number;
};
expect(body.characterName).toBe("Hero");
expect(body.prestigeCount).toBe(3);
expect(body.bossesDefeated).toBe(1);
expect(body.questsCompleted).toBe(1);
expect(body.achievementsUnlocked).toBe(1);
expect(body.transcendenceCount).toBe(1);
expect(body.apotheosisCount).toBe(1);
});
it("returns empty strings for null nullable player fields", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ pronouns: null, characterRace: null, characterClass: null, bio: null, guildName: null, guildDescription: null }) as never,
);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { pronouns: string; characterRace: string; bio: string };
expect(body.pronouns).toBe("");
expect(body.characterRace).toBe("");
expect(body.bio).toBe("");
});
it("returns defaults when no game state exists", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { prestigeCount: number; bossesDefeated: number };
expect(body.prestigeCount).toBe(0);
expect(body.bossesDefeated).toBe(0);
});
it("parses profileSettings when it is a valid object", async () => {
const settings = { showTotalGold: false, showOnLeaderboards: false, numberFormat: "scientific" };
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ profileSettings: settings }) as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string; showTotalGold: boolean } };
expect(body.profileSettings.numberFormat).toBe("scientific");
expect(body.profileSettings.showTotalGold).toBe(false);
});
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix");
});
it("maps known and unknown unlocked title IDs to name and fallback id", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ unlockedTitles: ["the_adventurous", "unknown_title_id"] }) as never,
);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { unlockedTitles: Array<{ id: string; name: string }> };
const known = body.unlockedTitles.find((t) => t.id === "the_adventurous");
expect(known?.name).toBe("The Adventurous");
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
expect(unknown?.name).toBe("unknown_title_id");
});
});
describe("PUT /", () => {
const put = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when character name is empty after trim", async () => {
const res = await put({ characterName: " " });
expect(res.status).toBe(400);
});
it("returns 400 when characterName is absent from request body", async () => {
const res = await put({});
expect(res.status).toBe(400);
});
it("updates profile and returns updated data", async () => {
const updatedPlayer = {
characterName: "NewName", pronouns: "they/them", characterRace: "Human", characterClass: "Rogue",
bio: "Updated bio", guildName: "New Guild", guildDescription: "Desc",
profileSettings: null, activeTitle: "the_first",
};
vi.mocked(prisma.player.update).mockResolvedValueOnce(updatedPlayer as never);
const res = await put({
characterName: "NewName",
pronouns: "they/them",
characterRace: "Human",
characterClass: "Rogue",
bio: "Updated bio",
guildName: "New Guild",
guildDescription: "Desc",
profileSettings: { numberFormat: "engineering", showTotalGold: true, showOnLeaderboards: true },
activeTitle: "the_first",
});
expect(res.status).toBe(200);
const body = await res.json() as { characterName: string; activeTitle: string };
expect(body.characterName).toBe("NewName");
expect(body.activeTitle).toBe("the_first");
});
it("uses suffix numberFormat when invalid value is provided", async () => {
vi.mocked(prisma.player.update).mockResolvedValueOnce({
characterName: "Hero", pronouns: null, characterRace: null, characterClass: null,
bio: null, guildName: null, guildDescription: null, profileSettings: null, activeTitle: null,
} as never);
const res = await put({
characterName: "Hero",
profileSettings: { numberFormat: "invalid_format" },
});
expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix");
});
});
});
+153
View File
@@ -0,0 +1,153 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- 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";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
vi.mock("../../src/services/webhook.js", () => ({
postMilestoneWebhook: vi.fn().mockResolvedValue(undefined),
}));
const DISCORD_ID = "test_discord_id";
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [{ id: "the_absolute_one", status: "defeated" }] as GameState["bosses"],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("transcendence route", () => {
let app: Hono;
let prisma: {
player: { update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { transcendenceRouter } = await import("../../src/routes/transcendence.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/transcendence", transcendenceRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/transcendence${path}`, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
it("returns 400 when the absolute one is not defeated", async () => {
const state = makeState({ bosses: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns echoes and count on successful transcendence", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { echoes: number; newTranscendenceCount: number };
expect(body.newTranscendenceCount).toBe(1);
expect(body.echoes).toBeGreaterThanOrEqual(0);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
});
it("returns 404 for unknown upgrade", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_echo" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(404);
});
it("returns 400 when transcendence data is missing from state", async () => {
const state = makeState({ transcendence: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(400);
});
it("returns 400 when upgrade is already purchased", async () => {
const state = makeState({
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: ["echo_income_1"], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(400);
});
it("returns 400 when not enough echoes", async () => {
const state = makeState({
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
// echo_income_1 costs 5 echoes but state has 0
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(400);
});
it("returns updated data on successful echo upgrade purchase", async () => {
const state = makeState({
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(200);
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
expect(body.echoesRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
});
});
});