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:
2026-04-16 14:01:50 -07:00
committed by Naomi Carrigan
parent 1e0a7b142a
commit e02827dbb6
20 changed files with 3660 additions and 10 deletions
+61
View File
@@ -1206,6 +1206,67 @@ describe("debug route", () => {
});
});
describe("POST /grant-apotheosis", () => {
const grantApotheosis = () =>
app.fetch(new Request("http://localhost/debug/grant-apotheosis", { method: "POST" }));
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await grantApotheosis();
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toContain("No save found");
});
it("returns 200 with unchanged state when apotheosis count is already >= 1", async () => {
const state = makeState({ apotheosis: { count: 1 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await grantApotheosis();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.apotheosis?.count).toBe(1);
expect(vi.mocked(prisma.gameState.update)).not.toHaveBeenCalled();
});
it("returns 200 and grants apotheosis with goddess state when not yet granted", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await grantApotheosis();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.apotheosis?.count).toBe(1);
expect(body.state.goddess).toBeDefined();
});
it("returns 200 with HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState({ apotheosis: { count: 1 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await grantApotheosis();
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("returns 500 when DB throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await grantApotheosis();
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toContain("Internal server error");
});
it("returns 500 when DB throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
const res = await grantApotheosis();
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toContain("Internal server error");
});
});
describe("POST /hard-reset", () => {
it("returns 404 when no player found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
+249
View File
@@ -0,0 +1,249 @@
/* 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";
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
count: 0,
ichor: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as Array<string>,
...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 }>,
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
eternalSovereignty: { count: 0 },
exploration: {
areas: [] as Array<{ id: string; status: string }>,
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 }>,
siring: makeSiring(),
thralls: [] as Array<{ id: string; count: number }>,
totalBloodEarned: 0,
upgrades: [] as Array<{ id: string; purchased: boolean }>,
zones: [] as Array<{ id: string; status: string }>,
...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);
describe("siring route", () => {
let app: Hono;
let prisma: {
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { siringRouter } = await import("../../src/routes/siring.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/siring", siringRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/siring${path}`, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
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("");
expect(res.status).toBe(400);
});
it("returns 400 when not eligible (totalBloodEarned below threshold)", async () => {
const state = makeState({ vampire: makeVampireState() as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("Not eligible");
});
it("returns ichorEarned on successful siring", async () => {
const vampire = makeVampireState({ totalBloodEarned: 1_000_000 });
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("");
expect(res.status).toBe(200);
const body = await res.json() as { ichorEarned: number; newSiringCount: number };
expect(body.newSiringCount).toBe(1);
expect(body.ichorEarned).toBeGreaterThanOrEqual(1);
});
it("applies threshold multiplier when siring_threshold upgrade is purchased", async () => {
// siring_threshold_1 reduces threshold by 10% → 900_000 required instead of 1_000_000
const vampire = makeVampireState({
siring: makeSiring({ purchasedUpgradeIds: [ "siring_threshold_1" ] }),
totalBloodEarned: 900_000,
});
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("");
expect(res.status).toBe(200);
const body = await res.json() as { newSiringCount: number };
expect(body.newSiringCount).toBe(1);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
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 post("");
expect(res.status).toBe(500);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
});
it("returns 404 for an unknown upgrade id", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_siring_upgrade" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(404);
});
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("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(400);
});
it("returns 400 when the upgrade is already purchased", async () => {
const vampire = makeVampireState({
siring: makeSiring({ ichor: 10, purchasedUpgradeIds: [ "siring_blood_1" ] }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(400);
});
it("returns 400 when not enough ichor", async () => {
const vampire = makeVampireState({ siring: makeSiring({ ichor: 0 }) });
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
// siring_blood_1 costs 5 ichor, state has 0
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(400);
});
it("returns updated multipliers on a successful upgrade purchase", async () => {
const vampire = makeVampireState({ siring: makeSiring({ ichor: 10 }) });
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("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(200);
const body = await res.json() as { ichorRemaining: number; purchasedUpgradeIds: Array<string> };
expect(body.ichorRemaining).toBe(5); // 10 - 5 (siring_blood_1 costs 5)
expect(body.purchasedUpgradeIds).toContain("siring_blood_1");
});
it("returns 500 when the database throws an Error during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
expect(res.status).toBe(500);
});
});
});
@@ -0,0 +1,309 @@
/* 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";
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
count: 0,
ichor: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as Array<string>,
...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; bountyIchorClaimed?: boolean }>,
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
eternalSovereignty: { count: 0 },
exploration: {
areas: [] as Array<{ id: string; status: string }>,
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 }>,
siring: makeSiring(),
thralls: [] as Array<{ id: string; count: number }>,
totalBloodEarned: 0,
upgrades: [] as Array<{ id: string; purchased: boolean }>,
zones: [] as Array<{ id: string; status: string }>,
...overrides,
});
const makeEligibleVampireState = (overrides: Record<string, unknown> = {}) =>
makeVampireState({
bosses: [ { id: "eternal_darkness", status: "defeated" } ],
siring: makeSiring({ count: 4 }),
...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);
describe("vampireAwakening route", () => {
let app: Hono;
let prisma: {
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { vampireAwakeningRouter } = await import("../../src/routes/vampireAwakening.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/vampire-awakening", vampireAwakeningRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/vampire-awakening${path}`, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
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("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("Vampire realm");
});
it("returns 400 when not eligible for awakening", async () => {
const vampire = makeVampireState({ bosses: [] });
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("Not eligible");
});
it("returns 400 when eternal_darkness boss is present but not defeated", async () => {
const vampire = makeVampireState({
bosses: [ { id: "eternal_darkness", status: "available" } ],
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns newAwakeningCount and soulShardsEarned on success", async () => {
const vampire = makeEligibleVampireState();
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("");
expect(res.status).toBe(200);
const body = await res.json() as { newAwakeningCount: number; soulShardsEarned: number };
expect(body.newAwakeningCount).toBe(1);
expect(body.soulShardsEarned).toBeGreaterThanOrEqual(1);
});
it("increments awakening count from existing count", async () => {
const vampire = makeEligibleVampireState({
awakening: makeAwakening({ count: 3, soulShards: 10 }),
});
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("");
expect(res.status).toBe(200);
const body = await res.json() as { newAwakeningCount: number };
expect(body.newAwakeningCount).toBe(4);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
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 post("");
expect(res.status).toBe(500);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("upgradeId");
});
it("returns 404 for an unknown upgrade id", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_awakening_upgrade" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toContain("Unknown awakening upgrade");
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(404);
});
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("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("Vampire realm");
});
it("returns 400 when the upgrade is already purchased", async () => {
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: 100, purchasedUpgradeIds: [ "awakening_blood_1" ] }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("already purchased");
});
it("returns 400 when not enough soul shards", async () => {
// awakening_blood_1 costs 10 soul shards; state has 5
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: 5 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("soul shards");
});
it("returns updated multipliers and remaining soul shards on success", async () => {
// awakening_blood_1 costs 10 soul shards; state has 20
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: 20 }),
});
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("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(200);
const body = await res.json() as {
purchasedUpgradeIds: Array<string>;
soulShardsBloodMultiplier: number;
soulShardsCombatMultiplier: number;
soulShardsMetaMultiplier: number;
soulShardsRemaining: number;
soulShardsSiringIchorMultiplier: number;
soulShardsSiringThresholdMultiplier: number;
};
expect(body.soulShardsRemaining).toBe(10); // 20 - 10 (awakening_blood_1 costs 10)
expect(body.purchasedUpgradeIds).toContain("awakening_blood_1");
expect(body.soulShardsBloodMultiplier).toBe(1.5); // awakening_blood_1 multiplier
expect(body.soulShardsCombatMultiplier).toBe(1);
expect(body.soulShardsMetaMultiplier).toBe(1);
expect(body.soulShardsSiringIchorMultiplier).toBe(1);
expect(body.soulShardsSiringThresholdMultiplier).toBe(1);
});
it("deducts the exact upgrade cost from soul shards", async () => {
// awakening_combat_1 costs 15 soul shards; state has 15
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: 15 }),
});
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("/buy-upgrade", { upgradeId: "awakening_combat_1" });
expect(res.status).toBe(200);
const body = await res.json() as { soulShardsRemaining: number };
expect(body.soulShardsRemaining).toBe(0);
});
it("returns 500 when the database throws an Error during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
expect(res.status).toBe(500);
});
});
});
+581
View File
@@ -0,0 +1,581 @@
/* 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";
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);
const makeBoss = (overrides: Record<string, unknown> = {}) => ({
id: "test_boss",
status: "available",
zoneId: "test_zone",
maxHp: 100,
currentHp: 100,
damagePerSecond: 1,
siringRequirement: 0,
bloodReward: 100,
ichorReward: 0,
soulShardsReward: 0,
upgradeRewards: [] as Array<string>,
equipmentRewards: [] as Array<string>,
bountyIchorClaimed: false,
...overrides,
});
const makeStrongThrall = (overrides: Record<string, unknown> = {}) => ({
id: "test_thrall",
count: 10000,
combatPower: 1000,
level: 1,
unlocked: true,
bloodPerSecond: 0,
ichorPerSecond: 0,
baseCost: 0,
class: "fighter",
name: "Fighter",
...overrides,
});
describe("vampireBoss route", () => {
let app: Hono;
let prisma: {
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { vampireBossRouter } = await import("../../src/routes/vampireBoss.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/vampire-boss", vampireBossRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/vampire-boss${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
}));
describe("POST /challenge", () => {
it("returns 400 when bossId is missing from body", async () => {
const res = await post("/challenge", {});
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("Invalid request body");
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/challenge", { bossId: "test_boss" });
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("Vampire realm");
});
it("returns 404 when boss is not found in state", async () => {
const vampire = makeVampireState({ bosses: [] });
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/challenge", { bossId: "missing_boss" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toContain("Boss not found");
});
it("returns 400 when boss status is defeated", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ status: "defeated" }) ],
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("not currently available");
});
it("returns 400 when boss status is locked", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ status: "locked" }) ],
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("not currently available");
});
it("allows challenge when boss status is in_progress", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1, status: "in_progress" }) ],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
});
it("returns 403 when siring requirement is not met", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ siringRequirement: 10 }) ],
siring: makeSiring({ count: 0 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(403);
const body = await res.json() as { error: string };
expect(body.error).toContain("Siring requirement");
});
it("returns 400 when thralls have no combat power (empty thralls array)", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss() ],
thralls: [],
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("no combat power");
});
it("returns 400 when thrall count is zero", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss() ],
thralls: [ makeStrongThrall({ count: 0 }) ],
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toContain("no combat power");
});
it("returns won: true with rewards on a successful boss kill", async () => {
// Boss with 1 HP, party kills it before it kills party
const vampire = makeVampireState({
bosses: [ makeBoss({ bloodReward: 100, currentHp: 1, damagePerSecond: 1, maxHp: 1 }) ],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { blood: number } };
expect(body.won).toBe(true);
expect(body.rewards).toBeDefined();
expect(body.rewards.blood).toBe(100);
});
it("sets boss status to defeated on win", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const savedBoss = savedState.vampire?.bosses.find((b) => b.id === "test_boss");
expect(savedBoss?.status).toBe("defeated");
});
it("unlocks next boss in same zone after win when siring requirement met", async () => {
const vampire = makeVampireState({
bosses: [
makeBoss({ currentHp: 1, id: "boss_1", maxHp: 1, zoneId: "zone_a" }),
makeBoss({ id: "boss_2", siringRequirement: 0, status: "locked", zoneId: "zone_a" }),
],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "boss_1" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const nextBoss = savedState.vampire?.bosses.find((b) => b.id === "boss_2");
expect(nextBoss?.status).toBe("available");
});
it("returns won: false with casualties on loss", async () => {
// Boss with very high HP/DPS, weak thrall
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 999_999, damagePerSecond: 999_999, maxHp: 999_999 }) ],
thralls: [ makeStrongThrall({ combatPower: 1, count: 100, level: 1 }) ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; casualties: Array<unknown> };
expect(body.won).toBe(false);
expect(body.casualties).toBeDefined();
});
it("resets boss HP to maxHp on loss", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 999_999, damagePerSecond: 999_999, maxHp: 999_999 }) ],
thralls: [ makeStrongThrall({ combatPower: 1, count: 100 }) ],
});
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("/challenge", { bossId: "test_boss" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const savedBoss = savedState.vampire?.bosses.find((b) => b.id === "test_boss");
expect(savedBoss?.currentHp).toBe(999_999);
expect(savedBoss?.status).toBe("available");
});
it("returns 500 on DB Error throw", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure"));
const res = await post("/challenge", { bossId: "test_boss" });
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("string error");
const res = await post("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toContain("Internal server error");
});
it("grants ichor reward on win", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, ichorReward: 5, maxHp: 1 }) ],
siring: makeSiring({ ichor: 0 }),
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { rewards: { ichor: number } };
expect(body.rewards.ichor).toBe(5);
});
it("grants soulShards reward on win", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1, soulShardsReward: 3 }) ],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { rewards: { soulShards: number } };
expect(body.rewards.soulShards).toBe(3);
});
it("increments lifetimeBossesDefeated on win", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
lifetimeBossesDefeated: 2,
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
expect(savedState.vampire?.lifetimeBossesDefeated).toBe(3);
});
it("unlocks upgrade rewards on win", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1, upgradeRewards: [ "upgrade_1" ] }) ],
thralls: [ makeStrongThrall() ],
upgrades: [ { id: "upgrade_1", multiplier: 2, purchased: false, target: "global", unlocked: false } ],
});
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("/challenge", { bossId: "test_boss" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const upgrade = savedState.vampire?.upgrades.find((u) => u.id === "upgrade_1");
expect(upgrade?.unlocked).toBe(true);
});
it("response includes combat stats", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { partyDPS: number; partyMaxHp: number; bossDPS: number };
expect(body.partyDPS).toBeGreaterThan(0);
expect(body.partyMaxHp).toBeGreaterThan(0);
expect(body.bossDPS).toBe(1);
});
it("unlocks a zone when its unlock boss is defeated and quest condition is met", async () => {
const vampire = makeVampireState({
bosses: [
makeBoss({ currentHp: 1, id: "boss_for_zone", maxHp: 1, zoneId: "zone_a" }),
makeBoss({ id: "new_zone_first_boss", siringRequirement: 0, status: "locked", zoneId: "new_zone" }),
],
thralls: [ makeStrongThrall() ],
zones: [
{ id: "already_unlocked", status: "unlocked", unlockBossId: "boss_for_zone", unlockQuestId: null },
{ id: "wrong_boss_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null },
{ id: "new_zone", status: "locked", unlockBossId: "boss_for_zone", unlockQuestId: null },
],
});
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("/challenge", { bossId: "boss_for_zone" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const zone = savedState.vampire?.zones.find((z) => z.id === "new_zone");
expect(zone?.status).toBe("unlocked");
});
it("skips zone unlock when quest condition is not satisfied (quest exists but not completed)", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, id: "boss_for_quest_zone", maxHp: 1, zoneId: "zone_a" }) ],
quests: [ { id: "required_quest", status: "in_progress" } ],
thralls: [ makeStrongThrall() ],
zones: [ { id: "quest_locked_zone", status: "locked", unlockBossId: "boss_for_quest_zone", unlockQuestId: "required_quest" } ],
});
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("/challenge", { bossId: "boss_for_quest_zone" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const zone = savedState.vampire?.zones.find((z) => z.id === "quest_locked_zone");
expect(zone?.status).toBe("locked");
});
it("unlocks a zone when both boss and required quest conditions are satisfied", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, id: "quest_boss", maxHp: 1, zoneId: "zone_a" }) ],
quests: [ { id: "required_quest", status: "completed" } ],
thralls: [ makeStrongThrall() ],
zones: [ { id: "quest_zone", status: "locked", unlockBossId: "quest_boss", unlockQuestId: "required_quest" } ],
});
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("/challenge", { bossId: "quest_boss" });
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
const zone = savedState.vampire?.zones.find((z) => z.id === "quest_zone");
expect(zone?.status).toBe("unlocked");
});
it("skips thralls with count=0 when computing casualties on loss", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 999_999, damagePerSecond: 999_999, maxHp: 999_999 }) ],
thralls: [
makeStrongThrall({ combatPower: 1, count: 0, id: "dead_thrall" }),
makeStrongThrall({ combatPower: 1, count: 100, id: "alive_thrall" }),
],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; casualties: Array<unknown> };
expect(body.won).toBe(false);
expect(body.casualties).toBeDefined();
});
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
thralls: [ makeStrongThrall() ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("applies purchased global upgrade multiplier to party DPS", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
thralls: [ makeStrongThrall({ combatPower: 100 }) ],
upgrades: [ { id: "global_upgrade_1", multiplier: 2, purchased: true, target: "global", unlocked: true } ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("applies purchased thrall-specific upgrade multiplier to party DPS", async () => {
const vampire = makeVampireState({
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
thralls: [ makeStrongThrall({ combatPower: 100, id: "test_thrall" }) ],
upgrades: [ { id: "thrall_upgrade_1", multiplier: 2, purchased: true, target: "thrall", thrallId: "test_thrall", unlocked: true } ],
});
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("/challenge", { bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
});
});
+244
View File
@@ -0,0 +1,244 @@
/* 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";
// bone_dust_extract requires: bone_dust×3, grave_essence×2; bonus: gold_income 1.1
const TEST_RECIPE_ID = "bone_dust_extract";
const makeVampireExploration = (overrides: Partial<NonNullable<GameState["vampire"]>["exploration"]> = {}): NonNullable<GameState["vampire"]>["exploration"] => ({
areas: [],
craftedBloodMultiplier: 1,
craftedCombatMultiplier: 1,
craftedIchorMultiplier: 1,
craftedRecipeIds: [] as string[],
materials: [
{ materialId: "bone_dust", quantity: 5 },
{ materialId: "grave_essence", quantity: 5 },
],
...overrides,
});
const makeVampireState = (overrides: Partial<NonNullable<GameState["vampire"]>> = {}): NonNullable<GameState["vampire"]> => ({
achievements: [],
awakening: {
count: 0,
purchasedUpgradeIds: [],
soulShards: 0,
soulShardsBloodMultiplier: 1,
soulShardsCombatMultiplier: 1,
soulShardsMetaMultiplier: 1,
soulShardsSiringIchorMultiplier: 1,
soulShardsSiringThresholdMultiplier: 1,
},
baseClickPower: 1,
bosses: [],
equipment: [],
eternalSovereignty: { count: 0 },
exploration: makeVampireExploration(),
lastTickAt: 0,
lifetimeBloodEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
quests: [],
siring: {
count: 1,
ichor: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
thralls: [],
totalBloodEarned: 0,
upgrades: [],
zones: [],
...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);
describe("vampireCraft route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { vampireCraftRouter } = await import("../../src/routes/vampireCraft.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/vampire-craft", vampireCraftRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/vampire-craft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when recipeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown recipe", async () => {
const res = await post({ recipeId: "nonexistent_recipe" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when vampire state is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when recipe is already crafted", async () => {
const vampire = makeVampireState({
exploration: makeVampireExploration({ craftedRecipeIds: [TEST_RECIPE_ID] }),
});
const state = makeState({ vampire });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when first required material is insufficient", async () => {
const vampire = makeVampireState({
exploration: makeVampireExploration({
materials: [
{ materialId: "bone_dust", quantity: 1 }, // needs 3
{ materialId: "grave_essence", quantity: 5 },
],
}),
});
const state = makeState({ vampire });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when second required material is completely absent", async () => {
// bone_dust present with enough, but grave_essence entirely absent — quantity ?? 0 = 0
const vampire = makeVampireState({
exploration: makeVampireExploration({
materials: [{ materialId: "bone_dust", quantity: 5 }],
}),
});
const state = makeState({ vampire });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 200 with crafted result on success", async () => {
const vampire = makeVampireState();
const state = makeState({ vampire });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const body = await res.json() as {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedBloodMultiplier: number;
craftedCombatMultiplier: number;
craftedIchorMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
};
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
expect(body.bonusValue).toBe(1.1);
expect(body.craftedBloodMultiplier).toBeGreaterThan(1);
expect(body.craftedCombatMultiplier).toBe(1);
expect(body.craftedIchorMultiplier).toBe(1);
});
it("deducts required materials from the vampire exploration state on success", async () => {
const vampire = makeVampireState();
const state = makeState({ vampire });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
await post({ recipeId: TEST_RECIPE_ID });
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: GameState };
};
const updatedMaterials = updateArg.data.state.vampire?.exploration.materials ?? [];
const boneDust = updatedMaterials.find((m) => m.materialId === "bone_dust");
const graveEssence = updatedMaterials.find((m) => m.materialId === "grave_essence");
// started with 5 each; bone_dust costs 3, grave_essence costs 2
expect(boneDust?.quantity).toBe(2);
expect(graveEssence?.quantity).toBe(3);
});
it("adds the recipeId to craftedRecipeIds in the saved state", async () => {
const vampire = makeVampireState();
const state = makeState({ vampire });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
await post({ recipeId: TEST_RECIPE_ID });
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: GameState };
};
expect(updateArg.data.state.vampire?.exploration.craftedRecipeIds).toContain(TEST_RECIPE_ID);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_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 post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
});
+648
View File
@@ -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);
});
});
});
+329
View File
@@ -0,0 +1,329 @@
/* 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";
// blood_hunt_1: costBlood=50, costIchor=0, costSoulShards=0, unlocked=true
const UPGRADE_ID = "blood_hunt_1";
const COST_BLOOD = 50;
const COST_ICHOR = 0;
const COST_SOUL_SHARDS = 0;
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
count: 0,
ichor: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as Array<string>,
...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 }>,
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
eternalSovereignty: { count: 0 },
exploration: {
areas: [] as Array<{ id: string; status: string }>,
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 }>,
siring: makeSiring(),
thralls: [] as Array<{ id: string; count: number }>,
totalBloodEarned: 0,
upgrades: [] as Array<{ id: string; unlocked: boolean; purchased: boolean; target: string; multiplier: number; thrallId?: string }>,
zones: [] as Array<{ id: string; status: string }>,
...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);
describe("vampireUpgrade route", () => {
let app: Hono;
let prisma: {
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { vampireUpgradeRouter } = await import("../../src/routes/vampireUpgrade.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/vampire-upgrade", vampireUpgradeRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/vampire-upgrade${path}`, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
}));
describe("POST /buy", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy", {});
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("upgradeId is required");
});
it("returns 404 for an unknown upgradeId", async () => {
const res = await post("/buy", { upgradeId: "nonexistent_vampire_upgrade" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Unknown vampire upgrade");
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("No save found");
});
it("returns 400 when the vampire realm is not unlocked", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Vampire realm not unlocked");
});
it("returns 404 when the upgrade is not in vampire state upgrades", async () => {
const vampire = makeVampireState({ upgrades: [] });
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Upgrade not found in vampire state");
});
it("returns 400 when the upgrade is not yet unlocked", async () => {
const upgrades = [ { id: UPGRADE_ID, unlocked: false, purchased: false, target: "blood", multiplier: 1.25 } ];
const vampire = makeVampireState({ upgrades });
const state = makeState({ vampire: vampire as GameState["vampire"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Upgrade is not yet unlocked");
});
it("returns 400 when the upgrade is already purchased", async () => {
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: true, target: "blood", multiplier: 1.25 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
siring: makeSiring({ ichor: COST_ICHOR }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD },
vampire: vampire as GameState["vampire"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Upgrade already purchased");
});
it("returns 400 when not enough blood", async () => {
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
siring: makeSiring({ ichor: COST_ICHOR }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD - 1 },
vampire: vampire as GameState["vampire"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Not enough blood");
});
it("returns 400 when not enough ichor (upgrade with ichor cost)", async () => {
// blood_hunt_3: costBlood=1000, costIchor=1, costSoulShards=0
const upgrades = [ { id: "blood_hunt_3", unlocked: true, purchased: false, target: "blood", multiplier: 2 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: 0 }),
siring: makeSiring({ ichor: 0 }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: 1000 },
vampire: vampire as GameState["vampire"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: "blood_hunt_3" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Not enough ichor");
});
it("returns 400 when not enough soul shards (upgrade with soul shard cost)", async () => {
// blood_mastery_3: costBlood=2_500_000, costIchor=50, costSoulShards=1
const upgrades = [ { id: "blood_mastery_3", unlocked: true, purchased: false, target: "blood", multiplier: 5 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: 0 }),
siring: makeSiring({ ichor: 50 }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: 2_500_000 },
vampire: vampire as GameState["vampire"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: "blood_mastery_3" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Not enough soul shards");
});
it("returns 200 with deducted resources on successful purchase", async () => {
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
siring: makeSiring({ ichor: COST_ICHOR }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD + 10 },
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("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(200);
const body = await res.json() as { bloodRemaining: number; ichorRemaining: number; soulShardsRemaining: number };
expect(body.bloodRemaining).toBe(10);
expect(body.ichorRemaining).toBe(COST_ICHOR);
expect(body.soulShardsRemaining).toBe(COST_SOUL_SHARDS);
});
it("returns 200 and marks the upgrade as purchased in the saved state", async () => {
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
siring: makeSiring({ ichor: COST_ICHOR }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD },
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("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(200);
expect(prisma.gameState.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { discordId: DISCORD_ID },
}),
);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
it("treats missing blood as zero when resources.blood is undefined", async () => {
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
const vampire = makeVampireState({
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
siring: makeSiring({ ichor: COST_ICHOR }),
upgrades,
});
const state = makeState({
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
vampire: vampire as GameState["vampire"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy", { upgradeId: UPGRADE_ID });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Not enough blood");
});
});
});