/* 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(); }), })); vi.mock("../../src/services/logger.js", () => ({ logger: { error: vi.fn().mockResolvedValue(undefined), metric: vi.fn().mockResolvedValue(undefined), }, })); const DISCORD_ID = "test_discord_id"; // First area from defaultVampireExplorationAreas const AREA_ID = "bone_chapel"; const AREA_ZONE_ID = "vampire_haunted_catacombs"; const AREA_DURATION_SECONDS = 30; const makeSiring = (overrides: Record = {}) => ({ count: 0, ichor: 0, productionMultiplier: 1, purchasedUpgradeIds: [] as Array, ichorCombatMultiplier: 1, ...overrides, }); const makeAwakening = (overrides: Record = {}) => ({ count: 0, purchasedUpgradeIds: [] as Array, soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1, ...overrides, }); const makeVampireState = (overrides: Record = {}) => ({ achievements: [] as Array<{ id: string; unlockedAt: number | null; reward: unknown }>, awakening: makeAwakening(), baseClickPower: 1, bosses: [] as Array<{ id: string; status: string; zoneId: string; maxHp: number; currentHp: number; damagePerSecond: number; siringRequirement: number; bloodReward: number; ichorReward: number; soulShardsReward: number; upgradeRewards: Array; equipmentRewards: Array; bountyIchorClaimed: boolean }>, equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean; type: string; bonus: Record }>, eternalSovereignty: { count: 0 }, exploration: { areas: [] as Array<{ id: string; status: string; startedAt?: number; endsAt?: number; completedOnce?: boolean }>, craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [] as Array, materials: [] as Array<{ materialId: string; quantity: number }>, }, lastTickAt: 0, lifetimeBloodEarned: 0, lifetimeBossesDefeated: 0, lifetimeQuestsCompleted: 0, quests: [] as Array<{ id: string; status: string; zoneId?: string; unlockQuestId?: string | null }>, siring: makeSiring(), thralls: [] as Array<{ id: string; count: number; combatPower: number; level: number; unlocked: boolean; bloodPerSecond: number; ichorPerSecond: number; baseCost: number; class: string; name: string }>, totalBloodEarned: 0, upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; thrallId?: string; unlocked?: boolean }>, zones: [] as Array<{ id: string; status: string; unlockBossId?: string; unlockQuestId?: string | null }>, ...overrides, }); 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: [], 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); // A vampire state with the zone unlocked and the area available const makeReadyVampireState = (areaOverrides: Record = {}, vampireOverrides: Record = {}) => makeVampireState({ exploration: { areas: [ { id: AREA_ID, status: "available", ...areaOverrides } ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ], ...vampireOverrides, }); describe("vampireExplore route", () => { let app: Hono; let prisma: { gameState: { findUnique: ReturnType; update: ReturnType }; }; beforeEach(async () => { vi.clearAllMocks(); vi.restoreAllMocks(); const { vampireExploreRouter } = await import("../../src/routes/vampireExplore.js"); const { prisma: p } = await import("../../src/db/client.js"); prisma = p as typeof prisma; app = new Hono(); app.route("/vampire-explore", vampireExploreRouter); }); const get = (path: string) => app.fetch(new Request(`http://localhost/vampire-explore${path}`, { method: "GET" })); const post = (path: string, body?: Record) => app.fetch(new Request(`http://localhost/vampire-explore${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), })); // ───────────────────────────────────────────────────────────────────────── // GET /claimable // ───────────────────────────────────────────────────────────────────────── describe("GET /claimable", () => { it("returns 400 when areaId is missing", async () => { const res = await get("/claimable"); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("areaId is required"); }); it("returns 404 when areaId is unknown", async () => { const res = await get("/claimable?areaId=not_a_real_area"); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("Unknown exploration area"); }); it("returns 404 when no save is found", async () => { vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("No save found"); }); it("returns claimable: false when vampire realm not unlocked", async () => { const state = makeState(); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(200); const body = await res.json() as { claimable: boolean }; expect(body.claimable).toBe(false); }); it("returns claimable: false when area not found in state", async () => { const vampire = makeVampireState({ exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] } }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(200); const body = await res.json() as { claimable: boolean }; expect(body.claimable).toBe(false); }); it("returns claimable: false when area is not in_progress", async () => { const vampire = makeReadyVampireState({ status: "available" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(200); const body = await res.json() as { claimable: boolean }; expect(body.claimable).toBe(false); }); it("returns claimable: false when exploration is still in progress (not yet expired)", async () => { const futureStart = Date.now() + 999_999; const vampire = makeReadyVampireState({ startedAt: futureStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(200); const body = await res.json() as { claimable: boolean }; expect(body.claimable).toBe(false); }); it("returns claimable: true when exploration duration has elapsed", async () => { const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000; const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(200); const body = await res.json() as { claimable: boolean }; expect(body.claimable).toBe(true); }); it("returns 500 when DB throws during claimable check", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure")); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(500); const body = await res.json() as { error: string }; expect(body.error).toContain("Internal server error"); }); it("returns 500 when claimable check throws a non-Error value", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error"); const res = await get(`/claimable?areaId=${AREA_ID}`); expect(res.status).toBe(500); const body = await res.json() as { error: string }; expect(body.error).toContain("Internal server error"); }); }); // ───────────────────────────────────────────────────────────────────────── // POST /start // ───────────────────────────────────────────────────────────────────────── describe("POST /start", () => { it("returns 400 when areaId is missing from body", async () => { const res = await post("/start", {}); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("areaId is required"); }); it("returns 404 when areaId is unknown", async () => { const res = await post("/start", { areaId: "not_a_real_area" }); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("Unknown exploration area"); }); it("returns 404 when no save is found", async () => { vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("No save found"); }); it("returns 400 when vampire realm is not unlocked", async () => { const state = makeState(); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("Vampire realm"); }); it("returns 400 when zone is not unlocked", async () => { const vampire = makeVampireState({ exploration: { areas: [ { id: AREA_ID, status: "available" } ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [ { id: AREA_ZONE_ID, status: "locked" } ], }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("Zone is not unlocked"); }); it("returns 400 when zone is missing entirely", async () => { const vampire = makeVampireState({ exploration: { areas: [ { id: AREA_ID, status: "available" } ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [], }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("Zone is not unlocked"); }); it("returns 404 when area not found in state", async () => { const vampire = makeVampireState({ exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ], }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("Exploration area not found in state"); }); it("returns 400 when an exploration is already in progress", async () => { const vampire = makeVampireState({ exploration: { areas: [ { id: AREA_ID, startedAt: Date.now(), status: "in_progress" }, { id: "dusty_crypts", status: "available" }, ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ], }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); // Try to start the second area while first is in_progress const res = await post("/start", { areaId: "dusty_crypts" }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("already in progress"); }); it("returns 400 when area is locked", async () => { const vampire = makeReadyVampireState({ status: "locked" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("locked"); }); it("returns 200 with areaId and endsAt on success", async () => { const vampire = makeReadyVampireState(); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(200); const body = await res.json() as { areaId: string; endsAt: number }; expect(body.areaId).toBe(AREA_ID); expect(body.endsAt).toBeGreaterThan(Date.now()); }); it("sets area status to in_progress in saved state", async () => { const vampire = makeReadyVampireState(); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); await post("/start", { areaId: AREA_ID }); const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]; const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state; const area = savedState.vampire?.exploration.areas.find((a) => a.id === AREA_ID); expect(area?.status).toBe("in_progress"); }); it("returns 500 on DB error during start", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure")); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(500); const body = await res.json() as { error: string }; expect(body.error).toContain("Internal server error"); }); it("returns 500 when start throws a non-Error value", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error"); const res = await post("/start", { areaId: AREA_ID }); expect(res.status).toBe(500); const body = await res.json() as { error: string }; expect(body.error).toContain("Internal server error"); }); }); // ───────────────────────────────────────────────────────────────────────── // POST /collect // ───────────────────────────────────────────────────────────────────────── describe("POST /collect", () => { it("returns 400 when areaId is missing from body", async () => { const res = await post("/collect", {}); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("areaId is required"); }); it("returns 404 when areaId is unknown", async () => { const res = await post("/collect", { areaId: "not_a_real_area" }); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("Unknown exploration area"); }); it("returns 404 when no save is found", async () => { vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("No save found"); }); it("returns 400 when vampire realm is not unlocked", async () => { const state = makeState(); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("Vampire realm"); }); it("returns 404 when area not found in state", async () => { const vampire = makeVampireState({ exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(404); const body = await res.json() as { error: string }; expect(body.error).toContain("Exploration area not found"); }); it("returns 400 when area is not in_progress", async () => { const vampire = makeReadyVampireState({ status: "available" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("not in progress"); }); it("returns 400 when exploration is not yet complete", async () => { const futureStart = Date.now() + 999_999; const vampire = makeReadyVampireState({ startedAt: futureStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain("not yet complete"); }); it("returns foundNothing: true when random roll is below nothing probability", async () => { vi.spyOn(Math, "random").mockReturnValue(0.15); const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000; const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(200); const body = await res.json() as { foundNothing: boolean; nothingMessage: string; event: null }; expect(body.foundNothing).toBe(true); expect(body.event).toBeNull(); expect(body.nothingMessage).toBeTruthy(); }); it("returns foundNothing: false with event when random roll is above nothing probability", async () => { // 0.5 is above the 0.2 nothing threshold, so an event fires vi.spyOn(Math, "random").mockReturnValue(0.5); const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000; const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(200); const body = await res.json() as { foundNothing: boolean; event: unknown; materialsFound: Array }; expect(body.foundNothing).toBe(false); expect(body.event).not.toBeNull(); expect(Array.isArray(body.materialsFound)).toBe(true); }); it("sets area status back to available after collecting", async () => { vi.spyOn(Math, "random").mockReturnValue(0.5); const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000; const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); await post("/collect", { areaId: AREA_ID }); const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]; const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state; const area = savedState.vampire?.exploration.areas.find((a) => a.id === AREA_ID); expect(area?.status).toBe("available"); expect(area?.completedOnce).toBe(true); }); it("returns 500 on DB error during collect", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure")); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(500); const body = await res.json() as { error: string }; expect(body.error).toContain("Internal server error"); }); it("returns 500 on non-Error throw", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("unexpected string"); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(500); const body = await res.json() as { error: string }; expect(body.error).toContain("Internal server error"); }); it("handles blood_gain event and updates totalBloodEarned", async () => { // bone_chapel event[0] is blood_gain — use mockReturnValueOnce to steer the random rolls // Call 1 (nothing check): 0.5 → not nothing; Call 2 (event index): 0.1 → index 0 (blood_gain) vi.spyOn(Math, "random"). mockReturnValueOnce(0.5). mockReturnValueOnce(0.1). mockReturnValue(0); const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000; const vampire = makeReadyVampireState({ endsAt: pastStart + (AREA_DURATION_SECONDS * 1000), startedAt: pastStart, status: "in_progress" }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/collect", { areaId: AREA_ID }); expect(res.status).toBe(200); const body = await res.json() as { event: { bloodChange: number } }; expect(body.event?.bloodChange).toBeGreaterThan(0); }); it("handles blood_loss event and reduces blood", async () => { // dusty_crypts event[1] is blood_loss — Math.random=0.7 → event index 1 (Math.floor(0.7*2)=1) const DUSTY_AREA_ID = "dusty_crypts"; const DUSTY_DURATION = 60; const pastStart = Date.now() - (DUSTY_DURATION * 1000) - 1000; vi.spyOn(Math, "random").mockReturnValue(0.7); const vampire = makeVampireState({ exploration: { areas: [ { id: DUSTY_AREA_ID, endsAt: pastStart + (DUSTY_DURATION * 1000), startedAt: pastStart, status: "in_progress" } ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ], }); const state = makeState({ resources: { blood: 1000, crystals: 0, essence: 0, gold: 0, runestones: 0 }, vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/collect", { areaId: DUSTY_AREA_ID }); expect(res.status).toBe(200); const body = await res.json() as { event: { bloodChange: number } }; expect(body.event?.bloodChange).toBeLessThan(0); }); it("handles dark_material_gain event and adds new material to state", async () => { // ossuary_hall has a dark_material_gain event as event[0] (grave_essence) const OSSUARY_AREA_ID = "ossuary_hall"; const OSSUARY_DURATION = 90; const pastStart = Date.now() - (OSSUARY_DURATION * 1000) - 1000; // Math.random = 0.3: not nothing (0.3 > 0.2), eventIndex=0 (dark_material_gain), material roll picks grave_essence vi.spyOn(Math, "random").mockReturnValue(0.3); const vampire = makeVampireState({ exploration: { areas: [ { id: OSSUARY_AREA_ID, status: "in_progress", startedAt: pastStart, endsAt: pastStart + (OSSUARY_DURATION * 1000) } ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [], }, zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ], }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/collect", { areaId: OSSUARY_AREA_ID }); expect(res.status).toBe(200); const body = await res.json() as { foundNothing: boolean; event: { text: string } }; expect(body.foundNothing).toBe(false); expect(body.event).not.toBeNull(); }); it("increments existing material quantity for dark_material_gain and possibleMaterials drop", async () => { // ossuary_hall area with grave_essence already in materials const OSSUARY_AREA_ID = "ossuary_hall"; const OSSUARY_DURATION = 90; const pastStart = Date.now() - (OSSUARY_DURATION * 1000) - 1000; vi.spyOn(Math, "random").mockReturnValue(0.3); const vampire = makeVampireState({ exploration: { areas: [ { id: OSSUARY_AREA_ID, status: "in_progress", startedAt: pastStart, endsAt: pastStart + (OSSUARY_DURATION * 1000) } ], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [ { materialId: "grave_essence", quantity: 5 } ], }, zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ], }); const state = makeState({ vampire: vampire as GameState["vampire"] }); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); const res = await post("/collect", { areaId: OSSUARY_AREA_ID }); expect(res.status).toBe(200); const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]; const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state; const graveEssence = savedState.vampire?.exploration.materials.find((m) => m.materialId === "grave_essence"); expect(graveEssence?.quantity).toBeGreaterThan(5); }); }); });