Files
elysium/apps/api/test/routes/vampireCraft.spec.ts
hikari e02827dbb6 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)
2026-04-16 14:01:50 -07:00

245 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
});
});