/* 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) => { 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 => ({ 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; update: ReturnType } }; 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) => app.fetch(new Request("http://localhost/explore/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), })); const postCollect = (body: Record) => 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(); }); it("returns 500 when the database throws on collect", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); const res = await postCollect({ areaId: TEST_AREA_ID }); expect(res.status).toBe(500); }); it("returns 500 when the database throws a non-Error value on collect", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); const res = await postCollect({ areaId: TEST_AREA_ID }); expect(res.status).toBe(500); }); }); describe("POST /start error path", () => { it("returns 500 when the database throws on start", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); const res = await postStart({ areaId: TEST_AREA_ID }); expect(res.status).toBe(500); }); it("returns 500 when the database throws a non-Error value on start", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); const res = await postStart({ areaId: TEST_AREA_ID }); expect(res.status).toBe(500); }); }); });