Files
elysium/apps/api/test/routes/goddessExplore.spec.ts
T
hikari 0d36b255ee feat: goddess API routes, services, and tests (chunk 4)
Add six new goddess-mode API routes (boss fight, consecration,
enlightenment, upgrade purchase, crafting, exploration) alongside
matching service modules and full test suites at 100% coverage.
2026-04-13 15:48:35 -07:00

620 lines
28 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 { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState, GoddessExplorationArea } 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();
}),
}));
// Custom test areas exercising event types not present in the real data
const PRAYERS_LOSS_AREA: GoddessExplorationArea = {
description: "Test area for prayers_loss events",
durationSeconds: 1,
events: [
{ effect: { amount: 100, type: "prayers_loss" }, id: "test_prayers_loss", text: "You lost some prayers." },
],
id: "test_prayers_loss_area",
name: "Test Prayers Loss Area",
possibleMaterials: [],
zoneId: "goddess_celestial_garden",
};
const DIVINITY_GAIN_AREA: GoddessExplorationArea = {
description: "Test area for divinity_gain events",
durationSeconds: 1,
events: [
{ effect: { amount: 10, type: "divinity_gain" }, id: "test_divinity_gain", text: "You gained divinity." },
],
id: "test_divinity_gain_area",
name: "Test Divinity Gain Area",
possibleMaterials: [],
zoneId: "goddess_celestial_garden",
};
vi.mock("../../src/data/goddessExplorations.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../../src/data/goddessExplorations.js")>();
return {
defaultGoddessExplorationAreas: [
...original.defaultGoddessExplorationAreas,
PRAYERS_LOSS_AREA,
DIVINITY_GAIN_AREA,
],
};
});
const DISCORD_ID = "test_discord_id";
// garden_glade: zoneId=goddess_celestial_garden, durationSeconds=30
// events[0]: prayers_gain 50; events[1]: disciple_loss 0.05
// possibleMaterials: divine_petal(weight 5), prayer_crystal(weight 3) — total 8
const TEST_AREA_ID = "garden_glade";
const TEST_ZONE_ID = "goddess_celestial_garden";
// celestial_meadow: durationSeconds=60
// events[0]: prayers_gain 200; events[1]: sacred_material_gain celestial_dust qty 2
const MATERIAL_AREA_ID = "celestial_meadow";
const makeGoddessState = (areaId: string, zoneStatus: "unlocked" | "locked" = "unlocked"): NonNullable<GameState["goddess"]> => ({
zones: [
{
id: TEST_ZONE_ID,
name: "Celestial Garden",
description: "",
emoji: "🌸",
status: zoneStatus,
unlockBossId: null,
unlockQuestId: null,
},
],
bosses: [],
quests: [],
disciples: [
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 50,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 0,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [
{ id: areaId, status: "available" },
],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
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);
/** Builds a state with the area in_progress and startedAt in the past so it's complete. */
const makeCompletedAreaState = (
areaId: string,
extraMaterials: Array<{ materialId: string; quantity: number }> = [],
extraPrayers = 0,
): GameState => {
const goddess = makeGoddessState(areaId);
goddess.exploration.areas = [{ id: areaId, status: "in_progress", startedAt: 0 }];
goddess.exploration.materials = extraMaterials;
return makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: extraPrayers } });
};
describe("goddessExplore route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessExploreRouter } = await import("../../src/routes/goddessExplore.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-explore", goddessExploreRouter);
});
afterEach(() => {
vi.restoreAllMocks();
});
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/goddess-explore/claimable"
: `http://localhost/goddess-explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
const postStart = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-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/goddess-explore/collect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("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 getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when goddess is undefined", async () => {
const state = makeState(); // no goddess
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_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 goddess = makeGoddessState(TEST_AREA_ID);
// area status is "available" (not in_progress)
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_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 goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = []; // area missing entirely
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_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 not yet complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = now → not complete yet (duration is 30s)
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_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 is complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = 0 → expired long ago
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
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 goddess is undefined", async () => {
const state = makeState();
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 zone is not unlocked", async () => {
const goddess = makeGoddessState(TEST_AREA_ID, "locked");
const state = makeState({ goddess });
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 zone is not found in goddess state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.zones = []; // zone missing
const state = makeState({ goddess });
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 goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = []; // area missing
const state = makeState({ goddess });
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 goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [
{ id: TEST_AREA_ID, status: "available" },
{ id: "other_area", status: "in_progress" },
];
const state = makeState({ goddess });
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 goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "locked" }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 200 with areaId and endsAt on success", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
const state = makeState({ goddess });
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("returns 500 when the database throws an Error", 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", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).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 goddess is undefined", 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 404 when area is not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [];
const state = makeState({ goddess });
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 goddess = makeGoddessState(TEST_AREA_ID);
// area is "available", not "in_progress"
const state = makeState({ goddess });
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 goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = now → still in progress for 30s
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns foundNothing=true when Math.random is below 0.2 (nothing path)", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.2 → nothing
const state = makeCompletedAreaState(TEST_AREA_ID);
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; materialsFound: unknown[] };
expect(body.foundNothing).toBe(true);
expect(typeof body.nothingMessage).toBe("string");
});
it("applies prayers_gain event and returns prayersChange > 0", async () => {
const mockRandom = vi.spyOn(Math, "random");
// garden_glade has 2 events: [prayers_gain(0), disciple_loss(1)]
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 2) = 0 → prayers_gain
// Call 3: possibleMaterials roll (total weight 8): 0 * 8 = 0, 0 - 5 = -5 ≤ 0 → divine_petal
// Call 4: quantity Math.floor(0 * (3-1+1)) + 1 = 0 + 1 = 1
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0);
const state = makeCompletedAreaState(TEST_AREA_ID);
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: { prayersChange: number }; materialsFound: Array<{ materialId: string }> };
expect(body.foundNothing).toBe(false);
expect(body.event.prayersChange).toBeGreaterThan(0);
});
it("applies sacred_material_gain event and pushes new material", async () => {
const mockRandom = vi.spyOn(Math, "random");
// celestial_meadow: events[1] = sacred_material_gain celestial_dust qty 2
// index 1: Math.floor(0.6 * 2) = 1
// possibleMaterials: [divine_petal(4), celestial_dust(3)] total 7
// Call 3: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal (new material)
// Call 4: Math.floor(0 * (4-2+1)) + 2 = 0 + 2 = 2
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event index: Math.floor(0.6 * 2) = 1 → sacred_material_gain
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 2 = 2
const state = makeCompletedAreaState(MATERIAL_AREA_ID); // no materials in state
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } }; materialsFound: Array<{ materialId: string }> };
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
});
it("increments existing material quantity on sacred_material_gain event", async () => {
const mockRandom = vi.spyOn(Math, "random");
// Same as above — celestial_meadow events[1] = sacred_material_gain celestial_dust
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event: sacred_material_gain
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
.mockReturnValueOnce(0); // quantity → 2
const state = makeCompletedAreaState(MATERIAL_AREA_ID, [
{ materialId: "celestial_dust", quantity: 5 }, // pre-existing
]);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: MATERIAL_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("celestial_dust");
expect(body.event.materialGained?.quantity).toBeGreaterThan(0);
});
it("returns materialsFound with new material from possibleMaterials when none pre-existing", async () => {
const mockRandom = vi.spyOn(Math, "random");
// prayers_gain event, then roll for divine_petal (new)
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal (first, weight 5)
.mockReturnValueOnce(0); // quantity → min (1)
const state = makeCompletedAreaState(TEST_AREA_ID); // no materials
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; quantity: number }> };
expect(body.materialsFound.length).toBeGreaterThan(0);
expect(body.materialsFound[0]?.materialId).toBe("divine_petal");
});
it("increments existing possibleMaterial quantity when material is already in state", async () => {
const mockRandom = vi.spyOn(Math, "random");
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
.mockReturnValueOnce(0); // quantity → 1
const state = makeCompletedAreaState(TEST_AREA_ID, [
{ materialId: "divine_petal", quantity: 10 }, // pre-existing
]);
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) => {
return m.materialId === "divine_petal";
})).toBe(true);
});
it("applies disciple_loss event and reduces disciple counts", async () => {
const mockRandom = vi.spyOn(Math, "random");
// garden_glade events[1] = disciple_loss fraction 0.05
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.6 * 2) = 1 → disciple_loss
// possibleMaterials: total weight 8; call 3: 0.9 * 8 = 7.2; 7.2 - 5 = 2.2 > 0; 2.2 - 3 = -0.8 ≤ 0 → prayer_crystal
// Call 4: quantity for prayer_crystal: Math.floor(0 * (2-1+1)) + 1 = 1
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event: Math.floor(0.6 * 2) = 1 → disciple_loss
.mockReturnValueOnce(0.9) // possibleMaterials roll → prayer_crystal
.mockReturnValueOnce(0); // quantity → 1
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
// Need disciples with non-zero count so lost > 0 triggers
goddess.disciples = [
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
];
const state = makeState({ goddess });
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);
});
it("applies prayers_loss event and returns negative prayersChange", async () => {
const mockRandom = vi.spyOn(Math, "random");
// test_prayers_loss_area has 1 event: prayers_loss 100
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 1) = 0 → prayers_loss
// No possibleMaterials, so no further calls needed
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1);
const goddess = makeGoddessState(PRAYERS_LOSS_AREA.id);
goddess.exploration.areas = [{ id: PRAYERS_LOSS_AREA.id, status: "in_progress", startedAt: 0 }];
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: PRAYERS_LOSS_AREA.id });
expect(res.status).toBe(200);
const body = await res.json() as { event: { prayersChange: number }; foundNothing: boolean };
expect(body.foundNothing).toBe(false);
expect(body.event.prayersChange).toBeLessThanOrEqual(0);
});
it("applies divinity_gain event and increases divinity", async () => {
const mockRandom = vi.spyOn(Math, "random");
// test_divinity_gain_area has 1 event: divinity_gain 10
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 1) = 0 → divinity_gain
// No possibleMaterials
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1);
const goddess = makeGoddessState(DIVINITY_GAIN_AREA.id);
goddess.exploration.areas = [{ id: DIVINITY_GAIN_AREA.id, status: "in_progress", startedAt: 0 }];
const initialDivinity = goddess.consecration.divinity;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: DIVINITY_GAIN_AREA.id });
expect(res.status).toBe(200);
// Verify state was saved with updated divinity
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: { goddess: { consecration: { divinity: number } } } };
};
expect(updateArg.data.state.goddess.consecration.divinity).toBe(initialDivinity + 10);
});
it("returns 500 when the database throws an Error", 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", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
});