generated from nhcarrigan/template
feat: vampire tick engine, auto systems, and full test suite
- vampire blood production tick with thrall bloodPerSecond + multipliers - auto-quest and auto-thrall purchase in tick engine - computeVampireBloodPerSecond helper exposed for ResourceBar display - ResourceBar now shows blood/s and currency balances for vampire mode - vampire quests and thralls panels gain auto-toggle buttons - About page updated with vampire mode how-to-play entries - vampireEquipmentSets data file added to web - 100% test coverage across all API routes and services: - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade - debug route now covers grant-apotheosis endpoint - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
This commit is contained in:
@@ -893,7 +893,7 @@ const validateAndSanitize = (
|
|||||||
* Blood income will be computed and allowed to grow once Chunk 7 adds vampire tick logic.
|
* Blood income will be computed and allowed to grow once Chunk 7 adds vampire tick logic.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 154 -- @preserve */
|
/* v8 ignore next 160 -- @preserve */
|
||||||
let vampireSpread: object = {};
|
let vampireSpread: object = {};
|
||||||
const previousVampire = previous.vampire;
|
const previousVampire = previous.vampire;
|
||||||
const incomingVampire = incoming.vampire;
|
const incomingVampire = incoming.vampire;
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ siringRouter.post("/buy-upgrade", async(context) => {
|
|||||||
|
|
||||||
void logger.metric("siring_upgrade_purchased", 1, { discordId, upgradeId });
|
void logger.metric("siring_upgrade_purchased", 1, { discordId, upgradeId });
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 6 -- @preserve */
|
||||||
const response: BuySiringUpgradeResponse = {
|
const response: BuySiringUpgradeResponse = {
|
||||||
ichorBloodMultiplier: updatedMultipliers.ichorBloodMultiplier ?? 1,
|
ichorBloodMultiplier: updatedMultipliers.ichorBloodMultiplier ?? 1,
|
||||||
ichorCombatMultiplier: updatedMultipliers.ichorCombatMultiplier ?? 1,
|
ichorCombatMultiplier: updatedMultipliers.ichorCombatMultiplier ?? 1,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const calculateThrallStats = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
const ichorCombatMultiplier = vampire.siring.ichorCombatMultiplier ?? 1;
|
const ichorCombatMultiplier = vampire.siring.ichorCombatMultiplier ?? 1;
|
||||||
const { soulShardsCombatMultiplier } = vampire.awakening;
|
const { soulShardsCombatMultiplier } = vampire.awakening;
|
||||||
|
|
||||||
@@ -310,7 +312,7 @@ vampireBossRouter.post("/challenge", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 7 -- @preserve */
|
/* v8 ignore next 8 -- @preserve */
|
||||||
const bountyIchor
|
const bountyIchor
|
||||||
= boss.bountyIchorClaimed === true
|
= boss.bountyIchorClaimed === true
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
@@ -305,9 +305,9 @@ vampireExploreRouter.post("/collect", async(context) => {
|
|||||||
const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0);
|
const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0);
|
||||||
state.resources.blood = (state.resources.blood ?? 0) - amount;
|
state.resources.blood = (state.resources.blood ?? 0) - amount;
|
||||||
bloodChange = -amount;
|
bloodChange = -amount;
|
||||||
} else if (event.effect.type === "ichor_gain") {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 4 -- @preserve */
|
||||||
|
} else if (event.effect.type === "ichor_gain") {
|
||||||
const amount = event.effect.amount ?? 0;
|
const amount = event.effect.amount ?? 0;
|
||||||
state.vampire.siring.ichor = state.vampire.siring.ichor + amount;
|
state.vampire.siring.ichor = state.vampire.siring.ichor + amount;
|
||||||
ichorChange = amount;
|
ichorChange = amount;
|
||||||
|
|||||||
@@ -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", () => {
|
describe("POST /hard-reset", () => {
|
||||||
it("returns 404 when no player found", async () => {
|
it("returns 404 when no player found", async () => {
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
/* 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 { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildPostAwakeningState,
|
||||||
|
calculateSoulShardsYield,
|
||||||
|
computeAwakeningMultipliers,
|
||||||
|
isEligibleForAwakening,
|
||||||
|
} from "../../src/services/awakening.js";
|
||||||
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
|
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 makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
count: 0,
|
||||||
|
ichor: 0,
|
||||||
|
productionMultiplier: 1,
|
||||||
|
purchasedUpgradeIds: [] as Array<string>,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
achievements: [] as Array<{ id: string; unlockedAt: number | null }>,
|
||||||
|
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 makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||||
|
player: { discordId: "test_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("isEligibleForAwakening", () => {
|
||||||
|
it("returns false when vampire state is undefined", () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when bosses array is empty", () => {
|
||||||
|
const state = makeState({ vampire: makeVampireState() as GameState["vampire"] });
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when eternal_darkness boss is present but not defeated", () => {
|
||||||
|
const state = makeState({
|
||||||
|
vampire: makeVampireState({
|
||||||
|
bosses: [ { id: "eternal_darkness", status: "available" } ],
|
||||||
|
}) as GameState["vampire"],
|
||||||
|
});
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when eternal_darkness boss is in_progress", () => {
|
||||||
|
const state = makeState({
|
||||||
|
vampire: makeVampireState({
|
||||||
|
bosses: [ { id: "eternal_darkness", status: "in_progress" } ],
|
||||||
|
}) as GameState["vampire"],
|
||||||
|
});
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when eternal_darkness boss is defeated", () => {
|
||||||
|
const state = makeState({
|
||||||
|
vampire: makeVampireState({
|
||||||
|
bosses: [ { id: "eternal_darkness", status: "defeated" } ],
|
||||||
|
}) as GameState["vampire"],
|
||||||
|
});
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true even with other bosses in the array", () => {
|
||||||
|
const state = makeState({
|
||||||
|
vampire: makeVampireState({
|
||||||
|
bosses: [
|
||||||
|
{ id: "some_other_boss", status: "defeated" },
|
||||||
|
{ id: "eternal_darkness", status: "defeated" },
|
||||||
|
],
|
||||||
|
}) as GameState["vampire"],
|
||||||
|
});
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when only other bosses are defeated (not eternal_darkness)", () => {
|
||||||
|
const state = makeState({
|
||||||
|
vampire: makeVampireState({
|
||||||
|
bosses: [ { id: "some_other_boss", status: "defeated" } ],
|
||||||
|
}) as GameState["vampire"],
|
||||||
|
});
|
||||||
|
expect(isEligibleForAwakening(state)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateSoulShardsYield", () => {
|
||||||
|
it("returns 1 as minimum yield when siring count is 0", () => {
|
||||||
|
expect(calculateSoulShardsYield(0, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 1 as minimum yield when result would be below 1", () => {
|
||||||
|
// sqrt(0) * 100 = 0 → max(1, 0) = 1
|
||||||
|
expect(calculateSoulShardsYield(0, 100)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes floor(sqrt(4) * 1) = 2", () => {
|
||||||
|
expect(calculateSoulShardsYield(4, 1)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes floor(sqrt(9) * 1) = 3", () => {
|
||||||
|
expect(calculateSoulShardsYield(9, 1)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies meta multiplier correctly", () => {
|
||||||
|
// floor(sqrt(4) * 2) = floor(2 * 2) = 4
|
||||||
|
expect(calculateSoulShardsYield(4, 2)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors fractional results", () => {
|
||||||
|
// floor(sqrt(2) * 1) = floor(1.414...) = 1
|
||||||
|
expect(calculateSoulShardsYield(2, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors fractional results with multiplier", () => {
|
||||||
|
// floor(sqrt(9) * 1.5) = floor(3 * 1.5) = floor(4.5) = 4
|
||||||
|
expect(calculateSoulShardsYield(9, 1.5)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns at least 1 even with very small siring count and no multiplier", () => {
|
||||||
|
expect(calculateSoulShardsYield(1, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeAwakeningMultipliers", () => {
|
||||||
|
it("returns all 1s with empty purchasedUpgradeIds", () => {
|
||||||
|
const result = computeAwakeningMultipliers([]);
|
||||||
|
expect(result.soulShardsBloodMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsCombatMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsMetaMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies blood upgrade when purchased", () => {
|
||||||
|
// awakening_blood_1 has multiplier 1.5 in blood category
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_blood_1" ]);
|
||||||
|
expect(result.soulShardsBloodMultiplier).toBe(1.5);
|
||||||
|
expect(result.soulShardsCombatMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsMetaMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks multiple blood upgrades multiplicatively", () => {
|
||||||
|
// awakening_blood_1 (×1.5) × awakening_blood_2 (×2) = 3.0
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_blood_1", "awakening_blood_2" ]);
|
||||||
|
expect(result.soulShardsBloodMultiplier).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies combat upgrade when purchased", () => {
|
||||||
|
// awakening_combat_1 has multiplier 1.5 in combat category
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_combat_1" ]);
|
||||||
|
expect(result.soulShardsCombatMultiplier).toBe(1.5);
|
||||||
|
expect(result.soulShardsBloodMultiplier).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks multiple combat upgrades multiplicatively", () => {
|
||||||
|
// awakening_combat_1 (×1.5) × awakening_combat_2 (×2) = 3.0
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_combat_1", "awakening_combat_2" ]);
|
||||||
|
expect(result.soulShardsCombatMultiplier).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies siring threshold upgrade when purchased", () => {
|
||||||
|
// awakening_threshold_1 has multiplier 0.85 in siring_threshold category
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_threshold_1" ]);
|
||||||
|
expect(result.soulShardsSiringThresholdMultiplier).toBe(0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks multiple threshold upgrades multiplicatively", () => {
|
||||||
|
// awakening_threshold_1 (×0.85) × awakening_threshold_2 (×0.8) = 0.68
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_threshold_1", "awakening_threshold_2" ]);
|
||||||
|
expect(result.soulShardsSiringThresholdMultiplier).toBeCloseTo(0.68);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies siring ichor upgrade when purchased", () => {
|
||||||
|
// awakening_siring_ichor_1 has multiplier 1.5 in siring_ichor category
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_siring_ichor_1" ]);
|
||||||
|
expect(result.soulShardsSiringIchorMultiplier).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks multiple siring ichor upgrades multiplicatively", () => {
|
||||||
|
// awakening_siring_ichor_1 (×1.5) × awakening_siring_ichor_2 (×2) = 3.0
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_siring_ichor_1", "awakening_siring_ichor_2" ]);
|
||||||
|
expect(result.soulShardsSiringIchorMultiplier).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies meta upgrade when purchased", () => {
|
||||||
|
// awakening_meta_1 has multiplier 1.5 in soulshards_meta category
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_meta_1" ]);
|
||||||
|
expect(result.soulShardsMetaMultiplier).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks multiple meta upgrades multiplicatively", () => {
|
||||||
|
// awakening_meta_1 (×1.5) × awakening_meta_2 (×2) = 3.0
|
||||||
|
const result = computeAwakeningMultipliers([ "awakening_meta_1", "awakening_meta_2" ]);
|
||||||
|
expect(result.soulShardsMetaMultiplier).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies upgrades from multiple categories independently", () => {
|
||||||
|
const result = computeAwakeningMultipliers([
|
||||||
|
"awakening_blood_1",
|
||||||
|
"awakening_combat_1",
|
||||||
|
"awakening_meta_1",
|
||||||
|
]);
|
||||||
|
expect(result.soulShardsBloodMultiplier).toBe(1.5);
|
||||||
|
expect(result.soulShardsCombatMultiplier).toBe(1.5);
|
||||||
|
expect(result.soulShardsMetaMultiplier).toBe(1.5);
|
||||||
|
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown upgrade ids gracefully", () => {
|
||||||
|
const result = computeAwakeningMultipliers([ "totally_fake_upgrade_id" ]);
|
||||||
|
expect(result.soulShardsBloodMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsCombatMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsMetaMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||||
|
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPostAwakeningState", () => {
|
||||||
|
it("increments awakening count by 1", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
awakening: makeAwakening({ count: 2, soulShards: 5 }),
|
||||||
|
siring: makeSiring({ count: 4 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.awakening.count).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds soulShardsEarned to existing soul shards", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
awakening: makeAwakening({ count: 0, soulShards: 10 }),
|
||||||
|
siring: makeSiring({ count: 4 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { soulShardsEarned, updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(soulShardsEarned).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(updatedVampire.awakening.soulShards).toBe(10 + soulShardsEarned);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses metaMultiplier from awakening when computing soul shards yield", () => {
|
||||||
|
// metaMultiplier = 1.5, siring count = 4 → floor(sqrt(4) * 1.5) = floor(3) = 3
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
awakening: makeAwakening({ soulShardsMetaMultiplier: 1.5 }),
|
||||||
|
siring: makeSiring({ count: 4 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { soulShardsEarned } = buildPostAwakeningState(state);
|
||||||
|
expect(soulShardsEarned).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves purchased upgrade ids across awakening", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
awakening: makeAwakening({ purchasedUpgradeIds: [ "awakening_blood_1" ] }),
|
||||||
|
siring: makeSiring({ count: 4 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.awakening.purchasedUpgradeIds).toContain("awakening_blood_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recomputes multipliers based on existing purchased upgrade ids", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
awakening: makeAwakening({ purchasedUpgradeIds: [ "awakening_blood_1" ] }),
|
||||||
|
siring: makeSiring({ count: 4 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
// awakening_blood_1 has multiplier 1.5
|
||||||
|
expect(updatedVampire.awakening.soulShardsBloodMultiplier).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves achievements across awakening", () => {
|
||||||
|
const achievements = [ { id: "ach_1", unlockedAt: 1000 } ];
|
||||||
|
const vampire = makeVampireState({ achievements, siring: makeSiring({ count: 1 }) });
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.achievements).toEqual(achievements);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves equipment across awakening", () => {
|
||||||
|
const equipment = [ { id: "eq_1", owned: true, equipped: true } ];
|
||||||
|
const vampire = makeVampireState({ equipment, siring: makeSiring({ count: 1 }) });
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.equipment).toEqual(equipment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves eternalSovereignty count across awakening", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
eternalSovereignty: { count: 5 },
|
||||||
|
siring: makeSiring({ count: 1 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.eternalSovereignty.count).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lifetime blood earned across awakening", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
lifetimeBloodEarned: 9_999_999,
|
||||||
|
siring: makeSiring({ count: 1 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.lifetimeBloodEarned).toBe(9_999_999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lifetime bosses defeated across awakening", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
lifetimeBossesDefeated: 42,
|
||||||
|
siring: makeSiring({ count: 1 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.lifetimeBossesDefeated).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lifetime quests completed across awakening", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
lifetimeQuestsCompleted: 17,
|
||||||
|
siring: makeSiring({ count: 1 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.lifetimeQuestsCompleted).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets totalBloodEarned to 0", () => {
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
siring: makeSiring({ count: 1 }),
|
||||||
|
totalBloodEarned: 500_000,
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.totalBloodEarned).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets siring count to 0 on fresh vampire state", () => {
|
||||||
|
const vampire = makeVampireState({ siring: makeSiring({ count: 25 }) });
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
expect(updatedVampire.siring.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bountyIchorClaimed flag on bosses that match fresh boss list", () => {
|
||||||
|
// Provide an existing boss with bountyIchorClaimed = true for a boss that exists in defaultVampireBosses
|
||||||
|
// We pass it through the bosses array and check that the flag survives the merge
|
||||||
|
const vampire = makeVampireState({
|
||||||
|
bosses: [ { id: "eternal_darkness", status: "defeated", bountyIchorClaimed: true } ],
|
||||||
|
siring: makeSiring({ count: 4 }),
|
||||||
|
});
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
// eternal_darkness should exist in the fresh boss list; its bountyIchorClaimed should be true
|
||||||
|
const eternDark = updatedVampire.bosses.find((b) => {
|
||||||
|
return b.id === "eternal_darkness";
|
||||||
|
});
|
||||||
|
// The boss may or may not exist in default data; if it does, the flag is preserved
|
||||||
|
if (eternDark !== undefined) {
|
||||||
|
expect(eternDark.bountyIchorClaimed).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns minimum 1 soul shard even when siring count is 0", () => {
|
||||||
|
const vampire = makeVampireState({ siring: makeSiring({ count: 0 }) });
|
||||||
|
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||||
|
const { soulShardsEarned } = buildPostAwakeningState(state);
|
||||||
|
expect(soulShardsEarned).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
|||||||
"src/data/materials.ts",
|
"src/data/materials.ts",
|
||||||
// Goddess materials data file — not directly imported by any route (referenced by ID strings only)
|
// Goddess materials data file — not directly imported by any route (referenced by ID strings only)
|
||||||
"src/data/goddessMaterials.ts",
|
"src/data/goddessMaterials.ts",
|
||||||
|
// Vampire materials data file — not directly imported by any route (referenced by ID strings only)
|
||||||
|
"src/data/vampireMaterials.ts",
|
||||||
],
|
],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 100,
|
statements: 100,
|
||||||
|
|||||||
@@ -387,6 +387,99 @@ const howToPlay = [
|
|||||||
+ " tick and are permanent once unlocked.",
|
+ " tick and are permanent once unlocked.",
|
||||||
title: "🏆 Goddess Achievements",
|
title: "🏆 Goddess Achievements",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Your first Eternal Sovereignty unlocks the Vampire Realm — a third"
|
||||||
|
+ " game layer that runs alongside your mortal and goddess progress."
|
||||||
|
+ " Switch between modes using the mode bar at the top of the screen."
|
||||||
|
+ " The Vampire Realm uses three currencies: Blood (earned passively"
|
||||||
|
+ " from Thralls each tick), Ichor (earned from Thralls and quest"
|
||||||
|
+ " rewards, carried through Sirings), and Soul Shards (awarded by"
|
||||||
|
+ " Vampire Quests, Awakening resets, and Achievement unlocks).",
|
||||||
|
title: "🧛 Vampire Realm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Thralls are the Vampire Realm's equivalent of adventurers. Buy them"
|
||||||
|
+ " with Blood to generate passive Blood and Ichor income every tick."
|
||||||
|
+ " Thralls come in six classes — Fledgling, Revenant, Shade,"
|
||||||
|
+ " Bloodbound, Wraith, and Ancient — each progressively more"
|
||||||
|
+ " powerful. Buy in batches of 1, 10, or Max. Thrall-specific"
|
||||||
|
+ " Upgrades multiply the income of individual classes; Blood and"
|
||||||
|
+ " Global Upgrades apply on top. Toggle Auto-Thrall from the Thralls"
|
||||||
|
+ " panel to automatically purchase the highest-tier affordable thrall"
|
||||||
|
+ " each tick.",
|
||||||
|
title: "🧟 Thralls",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"The Vampire Realm has 18 zones, each containing 4 bosses and 5"
|
||||||
|
+ " quests. The starter zone is always available. Subsequent zones"
|
||||||
|
+ " unlock when you defeat the required Vampire Boss AND complete the"
|
||||||
|
+ " required Vampire Quest. Vampire Quests run on a timer and always"
|
||||||
|
+ " succeed — there is no failure chance. Rewards include Blood, Ichor,"
|
||||||
|
+ " Soul Shards, Upgrade unlocks, new Thrall tiers, and equipment."
|
||||||
|
+ " Toggle Auto-Quest from the Quests panel to automatically send your"
|
||||||
|
+ " thralls on the highest available quest.",
|
||||||
|
title: "🗺️ Vampire Zones & Quests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Challenge Vampire Bosses to earn Blood, equipment drops, and unlock"
|
||||||
|
+ " new Vampire Zones. Your thralls' combined combat power determines"
|
||||||
|
+ " the outcome. Defeated bosses stay defeated. Equipment comes in"
|
||||||
|
+ " three types — Fangs, Shrouds, and Talismans — and provides"
|
||||||
|
+ " bonuses to Blood income, Combat Power, or Ichor multipliers."
|
||||||
|
+ " Equip matching set pieces to unlock escalating set bonuses.",
|
||||||
|
title: "⚔️ Vampire Boss Fights & Equipment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Siring is the Vampire Realm's prestige layer. When you Sire, your"
|
||||||
|
+ " Blood resets but you receive Ichor and a permanent production"
|
||||||
|
+ " multiplier that stacks with every Siring. Spend Ichor in the"
|
||||||
|
+ " Siring Shop on upgrades that amplify Blood income, Combat Power,"
|
||||||
|
+ " and Thrall effectiveness.",
|
||||||
|
title: "🩸 Siring",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Awakening is the Vampire Realm's transcendence layer. When you"
|
||||||
|
+ " Awaken, your Blood and Ichor reset in exchange for Soul Shards"
|
||||||
|
+ " that persist forever. Spend Soul Shards on meta-upgrades that"
|
||||||
|
+ " amplify Blood income, Combat Power, Siring thresholds, and future"
|
||||||
|
+ " Soul Shard yields.",
|
||||||
|
title: "💠 Awakening",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Dark Materials are gathered from Vampire Explorations (three unique"
|
||||||
|
+ " materials per zone). Use them in the Dark Crafting panel to craft"
|
||||||
|
+ " recipes that grant permanent multipliers to Blood income, Ichor"
|
||||||
|
+ " income, and Thrall Combat Power. Each recipe can only be crafted"
|
||||||
|
+ " once; multipliers from all crafted recipes stack and persist"
|
||||||
|
+ " through Siring and Awakening resets.",
|
||||||
|
title: "⚗️ Vampire Crafting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Send your thralls to explore dark areas within each Vampire Zone."
|
||||||
|
+ " Each area runs on a timer and rewards Blood, Ichor, and Dark"
|
||||||
|
+ " Materials when collected. Collecting from an area at least once"
|
||||||
|
+ " marks it as discovered. Vampire Explorations never fail. Only one"
|
||||||
|
+ " area can be explored at a time — collect first before sending"
|
||||||
|
+ " thralls out again.",
|
||||||
|
title: "🗺️ Dark Exploration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Vampire Achievements track milestones in the Vampire Realm: total"
|
||||||
|
+ " Blood earned, Vampire Bosses defeated, Vampire Quests completed,"
|
||||||
|
+ " Thralls hired, Siring count, and Vampire Equipment owned."
|
||||||
|
+ " Unlocking an achievement instantly awards bonus Ichor and Soul"
|
||||||
|
+ " Shards. Achievements are permanent once unlocked.",
|
||||||
|
title: "🏆 Vampire Achievements",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"The Story tab contains 22 chapters that unlock as you progress. The"
|
"The Story tab contains 22 chapters that unlock as you progress. The"
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const VampireQuestCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const VampireQuestsPanel = (): JSX.Element => {
|
const VampireQuestsPanel = (): JSX.Element => {
|
||||||
const { state } = useGame();
|
const { state, toggleVampireAutoQuest } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
return sessionStorage.getItem("elysium_vampire_quest_zone")
|
return sessionStorage.getItem("elysium_vampire_quest_zone")
|
||||||
?? "vampire_haunted_catacombs";
|
?? "vampire_haunted_catacombs";
|
||||||
@@ -163,7 +163,8 @@ const VampireQuestsPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { zones, quests } = vampireState;
|
const { zones, quests, autoQuest } = vampireState;
|
||||||
|
const autoQuestOn = autoQuest === true;
|
||||||
|
|
||||||
const activeZone = zones.find((zone: VampireZone) => {
|
const activeZone = zones.find((zone: VampireZone) => {
|
||||||
return zone.id === activeZoneId;
|
return zone.id === activeZoneId;
|
||||||
@@ -202,7 +203,23 @@ const VampireQuestsPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel vampire-quests-panel">
|
<section className="panel vampire-quests-panel">
|
||||||
<h2>{"Vampire Quests"}</h2>
|
<div className="panel-header">
|
||||||
|
<h2>{"Vampire Quests"}</h2>
|
||||||
|
<button
|
||||||
|
className={`auto-toggle ${autoQuestOn
|
||||||
|
? "auto-on"
|
||||||
|
: "auto-off"}`}
|
||||||
|
onClick={toggleVampireAutoQuest}
|
||||||
|
title={autoQuestOn
|
||||||
|
? "Auto-Quest is ON — click to disable"
|
||||||
|
: "Auto-Quest is OFF — click to enable"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{autoQuestOn
|
||||||
|
? "🤖 Auto-Quest: ON"
|
||||||
|
: "🤖 Auto-Quest: OFF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="zone-filter-buttons">
|
<div className="zone-filter-buttons">
|
||||||
{zones.map((zone: VampireZone) => {
|
{zones.map((zone: VampireZone) => {
|
||||||
function handleClick(): void {
|
function handleClick(): void {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const ThrallCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const VampireThrallsPanel = (): JSX.Element => {
|
const VampireThrallsPanel = (): JSX.Element => {
|
||||||
const { state, formatNumber } = useGame();
|
const { state, formatNumber, toggleVampireAutoThrall } = useGame();
|
||||||
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
||||||
return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
|
return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
|
||||||
});
|
});
|
||||||
@@ -221,7 +221,8 @@ const VampireThrallsPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blood = state.resources.blood ?? 0;
|
const blood = state.resources.blood ?? 0;
|
||||||
const { thralls } = vampireState;
|
const { thralls, autoThrall } = vampireState;
|
||||||
|
const autoThrallOn = autoThrall === true;
|
||||||
|
|
||||||
function handleBatchSelect(batch: BatchSize): void {
|
function handleBatchSelect(batch: BatchSize): void {
|
||||||
setSelectedBatch(batch);
|
setSelectedBatch(batch);
|
||||||
@@ -230,7 +231,23 @@ const VampireThrallsPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel disciples-panel">
|
<section className="panel disciples-panel">
|
||||||
<h2>{"Thralls"}</h2>
|
<div className="panel-header">
|
||||||
|
<h2>{"Thralls"}</h2>
|
||||||
|
<button
|
||||||
|
className={`auto-toggle ${autoThrallOn
|
||||||
|
? "auto-on"
|
||||||
|
: "auto-off"}`}
|
||||||
|
onClick={toggleVampireAutoThrall}
|
||||||
|
title={autoThrallOn
|
||||||
|
? "Auto-Thrall is ON — click to disable"
|
||||||
|
: "Auto-Thrall is OFF — click to enable"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{autoThrallOn
|
||||||
|
? "🤖 Auto-Thrall: ON"
|
||||||
|
: "🤖 Auto-Thrall: OFF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="disciples-balance">
|
<div className="disciples-balance">
|
||||||
<span>
|
<span>
|
||||||
{"🩸 Blood: "}
|
{"🩸 Blood: "}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
computeGoldPerSecond,
|
computeGoldPerSecond,
|
||||||
computePartyCombatPower,
|
computePartyCombatPower,
|
||||||
computeProjectedRunestones,
|
computeProjectedRunestones,
|
||||||
|
computeVampireBloodPerSecond,
|
||||||
} from "../../engine/tick.js";
|
} from "../../engine/tick.js";
|
||||||
import type { Resource } from "@elysium/types";
|
import type { Resource } from "@elysium/types";
|
||||||
|
|
||||||
@@ -95,11 +96,13 @@ const ResourceBar = ({
|
|||||||
let goldPerSecond = 0;
|
let goldPerSecond = 0;
|
||||||
let essencePerSecond = 0;
|
let essencePerSecond = 0;
|
||||||
let projectedRunestones = 0;
|
let projectedRunestones = 0;
|
||||||
|
let bloodPerSecond = 0;
|
||||||
if (state !== null) {
|
if (state !== null) {
|
||||||
partyCombatPower = computePartyCombatPower(state);
|
partyCombatPower = computePartyCombatPower(state);
|
||||||
goldPerSecond = computeGoldPerSecond(state);
|
goldPerSecond = computeGoldPerSecond(state);
|
||||||
essencePerSecond = computeEssencePerSecond(state);
|
essencePerSecond = computeEssencePerSecond(state);
|
||||||
projectedRunestones = computeProjectedRunestones(state);
|
projectedRunestones = computeProjectedRunestones(state);
|
||||||
|
bloodPerSecond = computeVampireBloodPerSecond(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
let avatarUrl: string | null = null;
|
let avatarUrl: string | null = null;
|
||||||
@@ -290,6 +293,17 @@ const ResourceBar = ({
|
|||||||
<span className="resource-label">{"Stardust"}</span>
|
<span className="resource-label">{"Stardust"}</span>
|
||||||
</div>
|
</div>
|
||||||
<hr className="resources-divider" />
|
<hr className="resources-divider" />
|
||||||
|
<div className={`resource${hasApotheosis
|
||||||
|
? ""
|
||||||
|
: " resource-locked"}`}>
|
||||||
|
<span className="resource-icon">{"📈"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{hasApotheosis
|
||||||
|
? formatNumber(bloodPerSecond)
|
||||||
|
: "🔒"}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Blood/s"}</span>
|
||||||
|
</div>
|
||||||
<div className={`resource${hasApotheosis
|
<div className={`resource${hasApotheosis
|
||||||
? ""
|
? ""
|
||||||
: " resource-locked"}`}>
|
: " resource-locked"}`}>
|
||||||
|
|||||||
@@ -855,6 +855,16 @@ interface GameContextValue {
|
|||||||
collectVampireExploration: (
|
collectVampireExploration: (
|
||||||
areaId: string,
|
areaId: string,
|
||||||
)=> Promise<VampireExploreCollectResponse>;
|
)=> Promise<VampireExploreCollectResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the vampire auto-quest setting on/off.
|
||||||
|
*/
|
||||||
|
toggleVampireAutoQuest: ()=> void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the vampire auto-thrall setting on/off.
|
||||||
|
*/
|
||||||
|
toggleVampireAutoThrall: ()=> void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleResult {
|
export interface BattleResult {
|
||||||
@@ -1480,6 +1490,90 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vampire auto-quest: start the highest-zone available quest when none is active
|
||||||
|
if (next.vampire?.autoQuest === true) {
|
||||||
|
const hasActiveVampireQuest = next.vampire.quests.some((q) => {
|
||||||
|
return q.status === "active";
|
||||||
|
});
|
||||||
|
if (!hasActiveVampireQuest) {
|
||||||
|
let thrallCombatPower = 0;
|
||||||
|
for (const thrall of next.vampire.thralls) {
|
||||||
|
const singleContrib = thrall.combatPower * thrall.count;
|
||||||
|
thrallCombatPower = thrallCombatPower + singleContrib;
|
||||||
|
}
|
||||||
|
const vampireZoneOrder = new Map(
|
||||||
|
next.vampire.zones.map((z, index) => {
|
||||||
|
return [ z.id, index ];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const vampireCandidates = next.vampire.quests.
|
||||||
|
filter((q) => {
|
||||||
|
return (
|
||||||
|
q.status === "available"
|
||||||
|
&& (q.combatPowerRequired ?? 0) <= thrallCombatPower
|
||||||
|
);
|
||||||
|
}).
|
||||||
|
sort((questA, questB) => {
|
||||||
|
return (
|
||||||
|
(vampireZoneOrder.get(questB.zoneId) ?? 0)
|
||||||
|
- (vampireZoneOrder.get(questA.zoneId) ?? 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const [ bestVampireQuest ] = vampireCandidates;
|
||||||
|
if (bestVampireQuest !== undefined) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
vampire: {
|
||||||
|
...next.vampire,
|
||||||
|
quests: next.vampire.quests.map((q) => {
|
||||||
|
return q.id === bestVampireQuest.id
|
||||||
|
? {
|
||||||
|
...q,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
status: "active" as const,
|
||||||
|
}
|
||||||
|
: q;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vampire auto-thrall: buy one of the highest-tier affordable unlocked thrall per tick
|
||||||
|
if (next.vampire?.autoThrall === true) {
|
||||||
|
const currentBlood = next.resources.blood ?? 0;
|
||||||
|
const [ bestVampireThrall ] = next.vampire.thralls.
|
||||||
|
filter((thrall) => {
|
||||||
|
const cost
|
||||||
|
= thrall.baseCost * Math.pow(1.15, thrall.count);
|
||||||
|
return thrall.unlocked && currentBlood >= cost;
|
||||||
|
}).
|
||||||
|
sort((thrallA, thrallB) => {
|
||||||
|
return thrallB.level - thrallA.level;
|
||||||
|
});
|
||||||
|
if (bestVampireThrall !== undefined) {
|
||||||
|
const thrallCost
|
||||||
|
= bestVampireThrall.baseCost
|
||||||
|
* Math.pow(1.15, bestVampireThrall.count);
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
resources: {
|
||||||
|
...next.resources,
|
||||||
|
blood: currentBlood - thrallCost,
|
||||||
|
},
|
||||||
|
vampire: {
|
||||||
|
...next.vampire,
|
||||||
|
thralls: next.vampire.thralls.map((thrall) => {
|
||||||
|
return thrall.id === bestVampireThrall.id
|
||||||
|
? { ...thrall, count: thrall.count + 1 }
|
||||||
|
: thrall;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect newly unlocked achievements
|
// Detect newly unlocked achievements
|
||||||
unlockedAchievementsReference.current = next.achievements.filter(
|
unlockedAchievementsReference.current = next.achievements.filter(
|
||||||
(a, index) => {
|
(a, index) => {
|
||||||
@@ -3269,6 +3363,36 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleVampireAutoQuest = useCallback(() => {
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous?.vampire === undefined) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
vampire: {
|
||||||
|
...previous.vampire,
|
||||||
|
autoQuest: previous.vampire.autoQuest !== true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleVampireAutoThrall = useCallback(() => {
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous?.vampire === undefined) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
vampire: {
|
||||||
|
...previous.vampire,
|
||||||
|
autoThrall: previous.vampire.autoThrall !== true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setActiveCompanion = useCallback((companionId: string | null) => {
|
const setActiveCompanion = useCallback((companionId: string | null) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -3709,6 +3833,8 @@ export const GameProvider = ({
|
|||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoPrestigeMaxRunestones,
|
toggleAutoPrestigeMaxRunestones,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
|
toggleVampireAutoQuest,
|
||||||
|
toggleVampireAutoThrall,
|
||||||
transcend,
|
transcend,
|
||||||
triggerPrestigeToast,
|
triggerPrestigeToast,
|
||||||
unlockedAchievements,
|
unlockedAchievements,
|
||||||
@@ -3817,6 +3943,8 @@ export const GameProvider = ({
|
|||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoPrestigeMaxRunestones,
|
toggleAutoPrestigeMaxRunestones,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
|
toggleVampireAutoQuest,
|
||||||
|
toggleVampireAutoThrall,
|
||||||
transcend,
|
transcend,
|
||||||
triggerPrestigeToast,
|
triggerPrestigeToast,
|
||||||
unlockedAchievements,
|
unlockedAchievements,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @file Vampire equipment set data for the Elysium game.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs, SCREAMING_SNAKE constants, and numeric bonus keys are conventional for game data */
|
||||||
|
/* eslint-disable stylistic/max-len -- Data content */
|
||||||
|
import type { VampireEquipmentSet } from "@elysium/types";
|
||||||
|
|
||||||
|
const VAMPIRE_EQUIPMENT_SETS: Array<VampireEquipmentSet> = [
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { bloodMultiplier: 1.15 },
|
||||||
|
3: { combatMultiplier: 1.1 },
|
||||||
|
},
|
||||||
|
description: "The starter relics of a newly awakened vampire — mismatched, imperfect, and entirely adequate for the catacombs. Every legend begins with gear this humble.",
|
||||||
|
id: "catacombs_hunter",
|
||||||
|
name: "Catacomb Hunter",
|
||||||
|
pieces: [ "shard_fang", "blood_fang", "tattered_shroud", "blood_shroud", "bone_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { bloodMultiplier: 1.2 },
|
||||||
|
3: { combatMultiplier: 1.15 },
|
||||||
|
},
|
||||||
|
description: "Equipment forged in the fires of early conquest — in the mire's depths and the obsidian corridors. Functional, battle-tested, and smelling faintly of old blood.",
|
||||||
|
id: "blood_stalker",
|
||||||
|
name: "Blood Stalker",
|
||||||
|
pieces: [ "war_fang", "obsidian_fang", "obsidian_shroud", "crimson_shroud", "blood_talisman", "obsidian_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { bloodMultiplier: 1.25 },
|
||||||
|
3: { ichorMultiplier: 1.2 },
|
||||||
|
},
|
||||||
|
description: "The arms of a vampire who has learned to move through courts as easily as through darkness. These pieces announce arrival before the wearer does.",
|
||||||
|
id: "crimson_regent",
|
||||||
|
name: "Crimson Regent",
|
||||||
|
pieces: [ "crimson_fang", "shadow_fang", "shadow_shroud", "plague_shroud", "crimson_talisman", "shadow_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { combatMultiplier: 1.3 },
|
||||||
|
3: { bloodMultiplier: 1.2 },
|
||||||
|
},
|
||||||
|
description: "Equipment sourced from the most dangerous zones of the middle realm — places where even other vampires refuse to hunt. The gear carries the memory of every survival it enabled.",
|
||||||
|
id: "plague_bringer",
|
||||||
|
name: "Plague Bringer",
|
||||||
|
pieces: [ "plague_fang", "ashen_fang", "ashen_shroud", "iron_shroud", "plague_talisman", "ashen_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { combatMultiplier: 1.35 },
|
||||||
|
3: { bloodMultiplier: 1.25 },
|
||||||
|
},
|
||||||
|
description: "The arms of a vampire who has broken open prisons and walked through veils. These pieces have seen the inside of places most vampires only hear about in old stories.",
|
||||||
|
id: "iron_jailer",
|
||||||
|
name: "Iron Jailer",
|
||||||
|
pieces: [ "iron_fang", "veil_fang", "veil_shroud", "moor_shroud", "iron_talisman", "veil_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { bloodMultiplier: 1.3 },
|
||||||
|
3: { combatMultiplier: 1.3 },
|
||||||
|
},
|
||||||
|
description: "Equipment forged in the moonless reaches and recovered from sunken depths. The pieces were each retrieved at significant cost, which they repay with significant interest.",
|
||||||
|
id: "moonlit_predator",
|
||||||
|
name: "Moonlit Predator",
|
||||||
|
pieces: [ "moonless_fang", "sunken_fang", "sunken_shroud", "sanctum_shroud", "moor_talisman", "sunken_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { combatMultiplier: 1.4 },
|
||||||
|
3: { ichorMultiplier: 1.3 },
|
||||||
|
},
|
||||||
|
description: "The regalia of desecration and apex predation — taken from places where even the concept of sanctuary has been dismantled. Each piece is a monument to the absence of mercy.",
|
||||||
|
id: "sanctum_desecrator",
|
||||||
|
name: "Sanctum Desecrator",
|
||||||
|
pieces: [ "sanctum_fang", "carrion_fang", "carrion_shroud", "spire_shroud", "sanctum_talisman", "carrion_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { bloodMultiplier: 1.4 },
|
||||||
|
3: { combatMultiplier: 1.45 },
|
||||||
|
},
|
||||||
|
description: "The arms of a vampire who has conquered both time and blood — relics of the Bloodspire and the Shroud. These pieces are older than the zones they came from.",
|
||||||
|
id: "eternal_tyrant",
|
||||||
|
name: "Eternal Tyrant",
|
||||||
|
pieces: [ "spire_fang", "shroud_fang", "eternity_shroud", "abyss_shroud", "spire_talisman", "eternity_talisman" ],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonuses: {
|
||||||
|
2: { ichorMultiplier: 1.5 },
|
||||||
|
3: { bloodMultiplier: 1.5 },
|
||||||
|
},
|
||||||
|
description: "The complete arms of a vampire who has stood at the edge of the void and returned. These pieces no longer belong to any zone. They belong to whatever you have become.",
|
||||||
|
id: "void_sovereign",
|
||||||
|
name: "Void Sovereign",
|
||||||
|
pieces: [ "abyss_fang", "eternal_fang", "whisper_shroud", "eternal_shroud", "abyss_talisman", "whisper_talisman" ],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export { VAMPIRE_EQUIPMENT_SETS };
|
||||||
@@ -18,11 +18,15 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
type GoddessAchievement,
|
type GoddessAchievement,
|
||||||
type GoddessState,
|
type GoddessState,
|
||||||
|
type VampireAchievement,
|
||||||
|
type VampireState,
|
||||||
computeSetBonuses,
|
computeSetBonuses,
|
||||||
|
computeVampireSetBonuses,
|
||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||||
|
import { VAMPIRE_EQUIPMENT_SETS } from "../data/vampireEquipmentSets.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,6 +145,62 @@ const checkGoddessAchievements = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks all vampire achievements against a snapshot of the vampire state
|
||||||
|
* and returns an updated achievements array, marking newly-met conditions
|
||||||
|
* with the current timestamp.
|
||||||
|
* @param vampire - The current (or projected) vampire state.
|
||||||
|
* @param now - Current Unix timestamp in milliseconds.
|
||||||
|
* @returns Updated vampire achievements array with newly unlocked ones timestamped.
|
||||||
|
*/
|
||||||
|
const checkVampireAchievements = (
|
||||||
|
vampire: VampireState,
|
||||||
|
now: number,
|
||||||
|
): Array<VampireAchievement> => {
|
||||||
|
return vampire.achievements.map((achievement) => {
|
||||||
|
if (achievement.unlockedAt !== null) {
|
||||||
|
return achievement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { condition } = achievement;
|
||||||
|
let met = false;
|
||||||
|
|
||||||
|
switch (condition.type) {
|
||||||
|
case "totalBloodEarned":
|
||||||
|
met = vampire.lifetimeBloodEarned >= condition.amount;
|
||||||
|
break;
|
||||||
|
case "vampireBossesDefeated":
|
||||||
|
met = vampire.lifetimeBossesDefeated >= condition.amount;
|
||||||
|
break;
|
||||||
|
case "vampireQuestsCompleted":
|
||||||
|
met = vampire.lifetimeQuestsCompleted >= condition.amount;
|
||||||
|
break;
|
||||||
|
case "thrallTotal":
|
||||||
|
met
|
||||||
|
= vampire.thralls.reduce((sum, thrall) => {
|
||||||
|
return sum + thrall.count;
|
||||||
|
}, 0) >= condition.amount;
|
||||||
|
break;
|
||||||
|
case "siringCount":
|
||||||
|
met = vampire.siring.count >= condition.amount;
|
||||||
|
break;
|
||||||
|
case "vampireEquipmentOwned":
|
||||||
|
met
|
||||||
|
= vampire.equipment.filter((item) => {
|
||||||
|
return item.owned;
|
||||||
|
}).length >= condition.amount;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive
|
||||||
|
/* v8 ignore next -- @preserve */ break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return met
|
||||||
|
? { ...achievement, unlockedAt: now }
|
||||||
|
: achievement;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||||
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||||
@@ -508,6 +568,79 @@ export const computePartyCombatPower = (state: GameState): number => {
|
|||||||
* companionCombatMult;
|
* companionCombatMult;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the effective blood earned per second from all thralls,
|
||||||
|
* applying all active multipliers (upgrades, siring, awakening, equipment, sets, crafting).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns Blood per second as a number.
|
||||||
|
*/
|
||||||
|
export const computeVampireBloodPerSecond = (state: GameState): number => {
|
||||||
|
if (state.vampire === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const { vampire } = state;
|
||||||
|
|
||||||
|
const equippedItems = vampire.equipment.filter((item) => {
|
||||||
|
return item.equipped;
|
||||||
|
});
|
||||||
|
const equipmentBloodMultiplier = equippedItems.reduce((mult, item) => {
|
||||||
|
return mult * (item.bonus.bloodMultiplier ?? 1);
|
||||||
|
}, 1);
|
||||||
|
const setBloodMultiplier = computeVampireSetBonuses(
|
||||||
|
equippedItems.map((item) => {
|
||||||
|
return item.id;
|
||||||
|
}),
|
||||||
|
VAMPIRE_EQUIPMENT_SETS,
|
||||||
|
).bloodMultiplier;
|
||||||
|
|
||||||
|
const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1;
|
||||||
|
const { soulShardsBloodMultiplier } = vampire.awakening;
|
||||||
|
const { craftedBloodMultiplier } = vampire.exploration;
|
||||||
|
|
||||||
|
let globalBloodMult = 1;
|
||||||
|
let globalUpgradeMult = 1;
|
||||||
|
for (const upgrade of vampire.upgrades) {
|
||||||
|
if (upgrade.purchased) {
|
||||||
|
if (upgrade.target === "blood") {
|
||||||
|
globalBloodMult = globalBloodMult * upgrade.multiplier;
|
||||||
|
} else if (upgrade.target === "global") {
|
||||||
|
globalUpgradeMult = globalUpgradeMult * upgrade.multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bloodPerSecond = 0;
|
||||||
|
for (const thrall of vampire.thralls) {
|
||||||
|
if (!thrall.unlocked || thrall.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let thrallUpgradeMult = 1;
|
||||||
|
for (const upgrade of vampire.upgrades) {
|
||||||
|
if (
|
||||||
|
upgrade.purchased
|
||||||
|
&& upgrade.target === "thrall"
|
||||||
|
&& upgrade.thrallId === thrall.id
|
||||||
|
) {
|
||||||
|
thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult;
|
||||||
|
const contribution
|
||||||
|
= thrall.bloodPerSecond
|
||||||
|
* thrall.count
|
||||||
|
* upgradeMultiplier
|
||||||
|
* globalBloodMult
|
||||||
|
* vampire.siring.productionMultiplier
|
||||||
|
* ichorBloodMult
|
||||||
|
* soulShardsBloodMultiplier
|
||||||
|
* craftedBloodMultiplier
|
||||||
|
* equipmentBloodMultiplier
|
||||||
|
* setBloodMultiplier;
|
||||||
|
bloodPerSecond = bloodPerSecond + contribution;
|
||||||
|
}
|
||||||
|
return bloodPerSecond;
|
||||||
|
};
|
||||||
|
|
||||||
const basePrestigeThreshold = 1_000_000;
|
const basePrestigeThreshold = 1_000_000;
|
||||||
const runestonesPerPrestigeLevelClient = 20;
|
const runestonesPerPrestigeLevelClient = 20;
|
||||||
const maxBaseRunestones = 200;
|
const maxBaseRunestones = 200;
|
||||||
@@ -835,6 +968,10 @@ export const applyTick = (
|
|||||||
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
|
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
|
||||||
let updatedGoddess: GoddessState | undefined = undefined;
|
let updatedGoddess: GoddessState | undefined = undefined;
|
||||||
|
|
||||||
|
let bloodGainedVampire = 0;
|
||||||
|
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
|
||||||
|
let updatedVampire: VampireState | undefined = undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state.apotheosis !== undefined
|
state.apotheosis !== undefined
|
||||||
&& state.apotheosis.count > 0
|
&& state.apotheosis.count > 0
|
||||||
@@ -1091,6 +1228,285 @@ export const applyTick = (
|
|||||||
stardustFromQuests = stardustFromQuests + stardustFromAchievements;
|
stardustFromQuests = stardustFromQuests + stardustFromAchievements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Vampire tick ---
|
||||||
|
if (state.vampire !== undefined) {
|
||||||
|
const { vampire } = state;
|
||||||
|
|
||||||
|
// Compute vampire equipment multipliers once for the tick
|
||||||
|
const vampireEquippedItems = vampire.equipment.filter((item) => {
|
||||||
|
return item.equipped;
|
||||||
|
});
|
||||||
|
const vampireEquipmentBloodMult = vampireEquippedItems.reduce(
|
||||||
|
(mult, item) => {
|
||||||
|
return mult * (item.bonus.bloodMultiplier ?? 1);
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const vampireSetBloodMult = computeVampireSetBonuses(
|
||||||
|
vampireEquippedItems.map((item) => {
|
||||||
|
return item.id;
|
||||||
|
}),
|
||||||
|
VAMPIRE_EQUIPMENT_SETS,
|
||||||
|
).bloodMultiplier;
|
||||||
|
|
||||||
|
const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1;
|
||||||
|
const {
|
||||||
|
soulShards: currentSoulShards,
|
||||||
|
soulShardsBloodMultiplier,
|
||||||
|
} = vampire.awakening;
|
||||||
|
const { craftedBloodMultiplier } = vampire.exploration;
|
||||||
|
|
||||||
|
// Compute global vampire upgrade multipliers
|
||||||
|
let globalBloodMult = 1;
|
||||||
|
let globalUpgradeMult = 1;
|
||||||
|
for (const upgrade of vampire.upgrades) {
|
||||||
|
if (upgrade.purchased) {
|
||||||
|
if (upgrade.target === "blood") {
|
||||||
|
globalBloodMult = globalBloodMult * upgrade.multiplier;
|
||||||
|
} else if (upgrade.target === "global") {
|
||||||
|
globalUpgradeMult = globalUpgradeMult * upgrade.multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive income from thralls
|
||||||
|
let bloodFromThralls = 0;
|
||||||
|
let ichorFromThralls = 0;
|
||||||
|
for (const thrall of vampire.thralls) {
|
||||||
|
if (!thrall.unlocked || thrall.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let thrallUpgradeMult = 1;
|
||||||
|
for (const upgrade of vampire.upgrades) {
|
||||||
|
if (
|
||||||
|
upgrade.purchased
|
||||||
|
&& upgrade.target === "thrall"
|
||||||
|
&& upgrade.thrallId === thrall.id
|
||||||
|
) {
|
||||||
|
thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult;
|
||||||
|
const bloodContribution
|
||||||
|
= thrall.bloodPerSecond
|
||||||
|
* thrall.count
|
||||||
|
* upgradeMultiplier
|
||||||
|
* globalBloodMult
|
||||||
|
* vampire.siring.productionMultiplier
|
||||||
|
* ichorBloodMult
|
||||||
|
* soulShardsBloodMultiplier
|
||||||
|
* craftedBloodMultiplier
|
||||||
|
* vampireEquipmentBloodMult
|
||||||
|
* vampireSetBloodMult
|
||||||
|
* deltaSeconds;
|
||||||
|
bloodFromThralls = bloodFromThralls + bloodContribution;
|
||||||
|
const ichorContribution
|
||||||
|
= thrall.ichorPerSecond * thrall.count * deltaSeconds;
|
||||||
|
ichorFromThralls = ichorFromThralls + ichorContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process vampire quest timers
|
||||||
|
let vampireQuestBloodGained = 0;
|
||||||
|
let vampireQuestIchorGained = 0;
|
||||||
|
let vampireQuestSoulShardsGained = 0;
|
||||||
|
let updatedVampireUpgrades = vampire.upgrades;
|
||||||
|
let updatedVampireThralls = vampire.thralls;
|
||||||
|
let updatedVampireEquipment = vampire.equipment;
|
||||||
|
let vampireQuestsThisTick = 0;
|
||||||
|
|
||||||
|
const updatedVampireQuests = vampire.quests.map((quest) => {
|
||||||
|
const questDurationMs = quest.durationSeconds * 1000;
|
||||||
|
const questExpiry
|
||||||
|
= quest.startedAt === undefined
|
||||||
|
? Infinity
|
||||||
|
: quest.startedAt + questDurationMs;
|
||||||
|
if (quest.status !== "active" || now < questExpiry) {
|
||||||
|
return quest;
|
||||||
|
}
|
||||||
|
|
||||||
|
vampireQuestsThisTick = vampireQuestsThisTick + 1;
|
||||||
|
for (const reward of quest.rewards) {
|
||||||
|
if (reward.type === "blood" && reward.amount !== undefined) {
|
||||||
|
vampireQuestBloodGained = vampireQuestBloodGained + reward.amount;
|
||||||
|
} else if (reward.type === "ichor" && reward.amount !== undefined) {
|
||||||
|
vampireQuestIchorGained = vampireQuestIchorGained + reward.amount;
|
||||||
|
} else if (
|
||||||
|
reward.type === "soulShards"
|
||||||
|
&& reward.amount !== undefined
|
||||||
|
) {
|
||||||
|
vampireQuestSoulShardsGained
|
||||||
|
= vampireQuestSoulShardsGained + reward.amount;
|
||||||
|
} else if (
|
||||||
|
reward.type === "upgrade"
|
||||||
|
&& reward.targetId !== undefined
|
||||||
|
) {
|
||||||
|
const { targetId } = reward;
|
||||||
|
updatedVampireUpgrades = updatedVampireUpgrades.map((upgrade) => {
|
||||||
|
return upgrade.id === targetId
|
||||||
|
? { ...upgrade, unlocked: true }
|
||||||
|
: upgrade;
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
reward.type === "thrall"
|
||||||
|
&& reward.targetId !== undefined
|
||||||
|
) {
|
||||||
|
const { targetId } = reward;
|
||||||
|
updatedVampireThralls = updatedVampireThralls.map((thrall) => {
|
||||||
|
return thrall.id === targetId
|
||||||
|
? { ...thrall, unlocked: true }
|
||||||
|
: thrall;
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
reward.type === "equipment"
|
||||||
|
&& reward.targetId !== undefined
|
||||||
|
) {
|
||||||
|
const rewardTargetId = reward.targetId;
|
||||||
|
const currentEquipment = updatedVampireEquipment;
|
||||||
|
updatedVampireEquipment = currentEquipment.map((item) => {
|
||||||
|
if (item.id !== rewardTargetId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const slotEmpty = !currentEquipment.some((other) => {
|
||||||
|
return other.type === item.type && other.equipped;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
equipped: slotEmpty || item.equipped,
|
||||||
|
owned: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...quest, status: "completed" as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlock vampire quests whose prerequisites are met and zone is unlocked
|
||||||
|
const completedVampireIds = new Set(
|
||||||
|
updatedVampireQuests.
|
||||||
|
filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).
|
||||||
|
map((quest) => {
|
||||||
|
return quest.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const defeatedVampireBossIds = new Set(
|
||||||
|
vampire.bosses.
|
||||||
|
filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).
|
||||||
|
map((boss) => {
|
||||||
|
return boss.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unlock vampire zones whose boss + quest requirements are now met
|
||||||
|
const updatedVampireZones = vampire.zones.map((zone) => {
|
||||||
|
if (zone.status === "unlocked") {
|
||||||
|
return zone;
|
||||||
|
}
|
||||||
|
const bossOk
|
||||||
|
= zone.unlockBossId === null
|
||||||
|
|| defeatedVampireBossIds.has(zone.unlockBossId);
|
||||||
|
const questOk
|
||||||
|
= zone.unlockQuestId === null
|
||||||
|
|| completedVampireIds.has(zone.unlockQuestId);
|
||||||
|
if (bossOk && questOk) {
|
||||||
|
return { ...zone, status: "unlocked" as const };
|
||||||
|
}
|
||||||
|
return zone;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allUnlockedVampireZoneIds = new Set(
|
||||||
|
updatedVampireZones.
|
||||||
|
filter((zone) => {
|
||||||
|
return zone.status === "unlocked";
|
||||||
|
}).
|
||||||
|
map((zone) => {
|
||||||
|
return zone.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullyUpdatedVampireQuests = updatedVampireQuests.map((quest) => {
|
||||||
|
if (quest.status !== "locked") {
|
||||||
|
return quest;
|
||||||
|
}
|
||||||
|
if (!allUnlockedVampireZoneIds.has(quest.zoneId)) {
|
||||||
|
return quest;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
quest.prerequisiteIds.every((id) => {
|
||||||
|
return completedVampireIds.has(id);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return { ...quest, status: "available" as const };
|
||||||
|
}
|
||||||
|
return quest;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute updated lifetime counters
|
||||||
|
const totalBloodThisTick = bloodFromThralls + vampireQuestBloodGained;
|
||||||
|
const updatedTotalBloodEarned
|
||||||
|
= vampire.totalBloodEarned + totalBloodThisTick;
|
||||||
|
const updatedLifetimeBloodEarned
|
||||||
|
= vampire.lifetimeBloodEarned + totalBloodThisTick;
|
||||||
|
const updatedLifetimeQuestsCompleted
|
||||||
|
= vampire.lifetimeQuestsCompleted + vampireQuestsThisTick;
|
||||||
|
|
||||||
|
// Build snapshot for achievement check
|
||||||
|
const vampireSnapshot: VampireState = {
|
||||||
|
...vampire,
|
||||||
|
equipment: updatedVampireEquipment,
|
||||||
|
lifetimeBloodEarned: updatedLifetimeBloodEarned,
|
||||||
|
lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted,
|
||||||
|
quests: fullyUpdatedVampireQuests,
|
||||||
|
thralls: updatedVampireThralls,
|
||||||
|
totalBloodEarned: updatedTotalBloodEarned,
|
||||||
|
upgrades: updatedVampireUpgrades,
|
||||||
|
zones: updatedVampireZones,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedVampireAchievements
|
||||||
|
= checkVampireAchievements(vampireSnapshot, now);
|
||||||
|
let ichorFromAchievements = 0;
|
||||||
|
let soulShardsFromAchievements = 0;
|
||||||
|
for (const [ index, achievement ] of updatedVampireAchievements.entries()) {
|
||||||
|
if (
|
||||||
|
vampire.achievements[index]?.unlockedAt === null
|
||||||
|
&& achievement.unlockedAt !== null
|
||||||
|
) {
|
||||||
|
ichorFromAchievements
|
||||||
|
= ichorFromAchievements + (achievement.reward?.ichor ?? 0);
|
||||||
|
soulShardsFromAchievements
|
||||||
|
= soulShardsFromAchievements + (achievement.reward?.soulShards ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bloodGainedVampire = totalBloodThisTick;
|
||||||
|
|
||||||
|
updatedVampire = {
|
||||||
|
...vampireSnapshot,
|
||||||
|
achievements: updatedVampireAchievements,
|
||||||
|
awakening: {
|
||||||
|
...vampire.awakening,
|
||||||
|
soulShards:
|
||||||
|
currentSoulShards
|
||||||
|
+ vampireQuestSoulShardsGained
|
||||||
|
+ soulShardsFromAchievements,
|
||||||
|
},
|
||||||
|
lastTickAt: now,
|
||||||
|
siring: {
|
||||||
|
...vampire.siring,
|
||||||
|
ichor:
|
||||||
|
vampire.siring.ichor
|
||||||
|
+ ichorFromThralls
|
||||||
|
+ vampireQuestIchorGained
|
||||||
|
+ ichorFromAchievements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||||
const essenceValue = capResource(
|
const essenceValue = capResource(
|
||||||
state.resources.essence + essenceGained + questEssence,
|
state.resources.essence + essenceGained + questEssence,
|
||||||
@@ -1102,6 +1518,9 @@ export const applyTick = (
|
|||||||
...state,
|
...state,
|
||||||
resources: {
|
resources: {
|
||||||
...state.resources,
|
...state.resources,
|
||||||
|
blood: capResource(
|
||||||
|
(state.resources.blood ?? 0) + bloodGainedVampire,
|
||||||
|
),
|
||||||
crystals: capResource(
|
crystals: capResource(
|
||||||
state.resources.crystals + questCrystals + challengeCrystals,
|
state.resources.crystals + questCrystals + challengeCrystals,
|
||||||
),
|
),
|
||||||
@@ -1140,6 +1559,9 @@ export const applyTick = (
|
|||||||
...updatedGoddess === undefined
|
...updatedGoddess === undefined
|
||||||
? {}
|
? {}
|
||||||
: { goddess: updatedGoddess },
|
: { goddess: updatedGoddess },
|
||||||
|
...updatedVampire === undefined
|
||||||
|
? {}
|
||||||
|
: { vampire: updatedVampire },
|
||||||
adventurers: updatedAdventurers,
|
adventurers: updatedAdventurers,
|
||||||
bosses: updatedBosses,
|
bosses: updatedBosses,
|
||||||
equipment: updatedEquipmentReference,
|
equipment: updatedEquipmentReference,
|
||||||
|
|||||||
Reference in New Issue
Block a user