generated from nhcarrigan/template
a36c8e72a5
## Summary
- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override
## Test plan
- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected
✨ This issue was created with help from Hikari~ 🌸
Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
506 lines
27 KiB
TypeScript
506 lines
27 KiB
TypeScript
/* 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("falls back to state characterName when playerRecord is null", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
|
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);
|
|
});
|
|
|
|
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("GET /load error path", () => {
|
|
it("returns 500 when the database throws during load", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value during load", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("POST /save error path", () => {
|
|
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 500 when the database throws during save", async () => {
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value during save", async () => {
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("returns 500 when the database throws during reset", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await reset();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value during reset", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await reset();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
});
|