generated from nhcarrigan/template
feat: vampire tick engine, auto systems, and full test suite
- vampire blood production tick with thrall bloodPerSecond + multipliers - auto-quest and auto-thrall purchase in tick engine - computeVampireBloodPerSecond helper exposed for ResourceBar display - ResourceBar now shows blood/s and currency balances for vampire mode - vampire quests and thralls panels gain auto-toggle buttons - About page updated with vampire mode how-to-play entries - vampireEquipmentSets data file added to web - 100% test coverage across all API routes and services: - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade - debug route now covers grant-apotheosis endpoint - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
This commit is contained in:
@@ -0,0 +1,648 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
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<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
ichorCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
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<string>; equipmentRewards: Array<string>; bountyIchorClaimed: boolean }>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean; type: string; bonus: Record<string, unknown> }>,
|
||||
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<string>,
|
||||
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> = {}): 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<string, unknown> = {}, vampireOverrides: Record<string, unknown> = {}) =>
|
||||
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<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
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<string, unknown>) =>
|
||||
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<unknown> };
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user