diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 5073397..fc420c1 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -893,7 +893,7 @@ const validateAndSanitize = ( * Blood income will be computed and allowed to grow once Chunk 7 adds vampire tick logic. */ // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 154 -- @preserve */ + /* v8 ignore next 160 -- @preserve */ let vampireSpread: object = {}; const previousVampire = previous.vampire; const incomingVampire = incoming.vampire; diff --git a/apps/api/src/routes/siring.ts b/apps/api/src/routes/siring.ts index cc062dd..f881c66 100644 --- a/apps/api/src/routes/siring.ts +++ b/apps/api/src/routes/siring.ts @@ -166,6 +166,8 @@ siringRouter.post("/buy-upgrade", async(context) => { 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 = { ichorBloodMultiplier: updatedMultipliers.ichorBloodMultiplier ?? 1, ichorCombatMultiplier: updatedMultipliers.ichorCombatMultiplier ?? 1, diff --git a/apps/api/src/routes/vampireBoss.ts b/apps/api/src/routes/vampireBoss.ts index 0e6260b..a60f16d 100644 --- a/apps/api/src/routes/vampireBoss.ts +++ b/apps/api/src/routes/vampireBoss.ts @@ -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 { soulShardsCombatMultiplier } = vampire.awakening; @@ -310,7 +312,7 @@ vampireBossRouter.post("/challenge", async(context) => { }); // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 7 -- @preserve */ + /* v8 ignore next 8 -- @preserve */ const bountyIchor = boss.bountyIchorClaimed === true ? 0 diff --git a/apps/api/src/routes/vampireExplore.ts b/apps/api/src/routes/vampireExplore.ts index ac81f73..dd72f99 100644 --- a/apps/api/src/routes/vampireExplore.ts +++ b/apps/api/src/routes/vampireExplore.ts @@ -305,9 +305,9 @@ vampireExploreRouter.post("/collect", async(context) => { const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0); state.resources.blood = (state.resources.blood ?? 0) - amount; bloodChange = -amount; - } else if (event.effect.type === "ichor_gain") { // 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; state.vampire.siring.ichor = state.vampire.siring.ichor + amount; ichorChange = amount; diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index ae339ec..1293916 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -1206,6 +1206,67 @@ describe("debug route", () => { }); }); + describe("POST /grant-apotheosis", () => { + const grantApotheosis = () => + app.fetch(new Request("http://localhost/debug/grant-apotheosis", { method: "POST" })); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await grantApotheosis(); + expect(res.status).toBe(404); + const body = await res.json() as { error: string }; + expect(body.error).toContain("No save found"); + }); + + it("returns 200 with unchanged state when apotheosis count is already >= 1", async () => { + const state = makeState({ apotheosis: { count: 1 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await grantApotheosis(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.apotheosis?.count).toBe(1); + expect(vi.mocked(prisma.gameState.update)).not.toHaveBeenCalled(); + }); + + it("returns 200 and grants apotheosis with goddess state when not yet granted", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await grantApotheosis(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.apotheosis?.count).toBe(1); + expect(body.state.goddess).toBeDefined(); + }); + + it("returns 200 with HMAC signature when ANTI_CHEAT_SECRET is set", async () => { + process.env.ANTI_CHEAT_SECRET = "test_secret"; + const state = makeState({ apotheosis: { count: 1 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await grantApotheosis(); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeDefined(); + delete process.env.ANTI_CHEAT_SECRET; + }); + + it("returns 500 when DB throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await grantApotheosis(); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toContain("Internal server error"); + }); + + it("returns 500 when DB throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error"); + const res = await grantApotheosis(); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toContain("Internal server error"); + }); + }); + describe("POST /hard-reset", () => { it("returns 404 when no player found", async () => { vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null); diff --git a/apps/api/test/routes/siring.spec.ts b/apps/api/test/routes/siring.spec.ts new file mode 100644 index 0000000..4bd08aa --- /dev/null +++ b/apps/api/test/routes/siring.spec.ts @@ -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) => { + 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 = {}) => ({ + count: 0, + ichor: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as Array, + ...overrides, +}); + +const makeAwakening = (overrides: Record = {}) => ({ + count: 0, + purchasedUpgradeIds: [] as Array, + soulShards: 0, + soulShardsBloodMultiplier: 1, + soulShardsCombatMultiplier: 1, + soulShardsMetaMultiplier: 1, + soulShardsSiringIchorMultiplier: 1, + soulShardsSiringThresholdMultiplier: 1, + ...overrides, +}); + +const makeVampireState = (overrides: Record = {}) => ({ + 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, + 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 => ({ + 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; update: ReturnType }; + }; + + 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) => + 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 }; + 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); + }); + }); +}); diff --git a/apps/api/test/routes/vampireAwakening.spec.ts b/apps/api/test/routes/vampireAwakening.spec.ts new file mode 100644 index 0000000..ff8cccc --- /dev/null +++ b/apps/api/test/routes/vampireAwakening.spec.ts @@ -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) => { + 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 = {}) => ({ + count: 0, + ichor: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as Array, + ...overrides, +}); + +const makeAwakening = (overrides: Record = {}) => ({ + count: 0, + purchasedUpgradeIds: [] as Array, + soulShards: 0, + soulShardsBloodMultiplier: 1, + soulShardsCombatMultiplier: 1, + soulShardsMetaMultiplier: 1, + soulShardsSiringIchorMultiplier: 1, + soulShardsSiringThresholdMultiplier: 1, + ...overrides, +}); + +const makeVampireState = (overrides: Record = {}) => ({ + 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, + 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 = {}) => + makeVampireState({ + bosses: [ { id: "eternal_darkness", status: "defeated" } ], + siring: makeSiring({ count: 4 }), + ...overrides, + }); + +const makeState = (overrides: Partial = {}): 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; update: ReturnType }; + }; + + 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) => + 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; + 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); + }); + }); +}); diff --git a/apps/api/test/routes/vampireBoss.spec.ts b/apps/api/test/routes/vampireBoss.spec.ts new file mode 100644 index 0000000..313b10f --- /dev/null +++ b/apps/api/test/routes/vampireBoss.spec.ts @@ -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) => { + 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 = {}) => ({ + count: 0, + ichor: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as Array, + ichorCombatMultiplier: 1, + ...overrides, +}); + +const makeAwakening = (overrides: Record = {}) => ({ + count: 0, + purchasedUpgradeIds: [] as Array, + soulShards: 0, + soulShardsBloodMultiplier: 1, + soulShardsCombatMultiplier: 1, + soulShardsMetaMultiplier: 1, + soulShardsSiringIchorMultiplier: 1, + soulShardsSiringThresholdMultiplier: 1, + ...overrides, +}); + +const makeVampireState = (overrides: Record = {}) => ({ + 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; + equipmentRewards: Array; + bountyIchorClaimed: boolean; + }>, + equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean; type: string; bonus: Record }>, + 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, + 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 => ({ + 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 = {}) => ({ + 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, + equipmentRewards: [] as Array, + bountyIchorClaimed: false, + ...overrides, +}); + +const makeStrongThrall = (overrides: Record = {}) => ({ + 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; update: ReturnType }; + }; + + 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) => + 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 }; + 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 }; + 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); + }); + }); +}); diff --git a/apps/api/test/routes/vampireCraft.spec.ts b/apps/api/test/routes/vampireCraft.spec.ts new file mode 100644 index 0000000..7677482 --- /dev/null +++ b/apps/api/test/routes/vampireCraft.spec.ts @@ -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) => { + 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["exploration"]> = {}): NonNullable["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 => ({ + 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 => ({ + 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; update: ReturnType } }; + + 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) => + 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); + }); +}); diff --git a/apps/api/test/routes/vampireExplore.spec.ts b/apps/api/test/routes/vampireExplore.spec.ts new file mode 100644 index 0000000..5ce01be --- /dev/null +++ b/apps/api/test/routes/vampireExplore.spec.ts @@ -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) => { + 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 = {}) => ({ + count: 0, + ichor: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as Array, + ichorCombatMultiplier: 1, + ...overrides, +}); + +const makeAwakening = (overrides: Record = {}) => ({ + count: 0, + purchasedUpgradeIds: [] as Array, + soulShards: 0, + soulShardsBloodMultiplier: 1, + soulShardsCombatMultiplier: 1, + soulShardsMetaMultiplier: 1, + soulShardsSiringIchorMultiplier: 1, + soulShardsSiringThresholdMultiplier: 1, + ...overrides, +}); + +const makeVampireState = (overrides: Record = {}) => ({ + 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; equipmentRewards: Array; bountyIchorClaimed: boolean }>, + equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean; type: string; bonus: Record }>, + 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, + 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 => ({ + 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 = {}, vampireOverrides: Record = {}) => + 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; update: ReturnType }; + }; + + 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) => + 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 }; + 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); + }); + }); +}); diff --git a/apps/api/test/routes/vampireUpgrade.spec.ts b/apps/api/test/routes/vampireUpgrade.spec.ts new file mode 100644 index 0000000..3d82921 --- /dev/null +++ b/apps/api/test/routes/vampireUpgrade.spec.ts @@ -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) => { + 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 = {}) => ({ + count: 0, + ichor: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as Array, + ...overrides, +}); + +const makeAwakening = (overrides: Record = {}) => ({ + count: 0, + purchasedUpgradeIds: [] as Array, + soulShards: 0, + soulShardsBloodMultiplier: 1, + soulShardsCombatMultiplier: 1, + soulShardsMetaMultiplier: 1, + soulShardsSiringIchorMultiplier: 1, + soulShardsSiringThresholdMultiplier: 1, + ...overrides, +}); + +const makeVampireState = (overrides: Record = {}) => ({ + 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, + 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 => ({ + 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; update: ReturnType }; + }; + + 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) => + 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"); + }); + }); +}); diff --git a/apps/api/test/services/awakening.spec.ts b/apps/api/test/services/awakening.spec.ts new file mode 100644 index 0000000..3e59e01 --- /dev/null +++ b/apps/api/test/services/awakening.spec.ts @@ -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 = {}) => ({ + count: 0, + purchasedUpgradeIds: [] as Array, + soulShards: 0, + soulShardsBloodMultiplier: 1, + soulShardsCombatMultiplier: 1, + soulShardsMetaMultiplier: 1, + soulShardsSiringIchorMultiplier: 1, + soulShardsSiringThresholdMultiplier: 1, + ...overrides, +}); + +const makeSiring = (overrides: Record = {}) => ({ + count: 0, + ichor: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as Array, + ...overrides, +}); + +const makeVampireState = (overrides: Record = {}) => ({ + 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, + 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 => ({ + 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); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 846e221..b11e7bd 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ "src/data/materials.ts", // Goddess materials data file — not directly imported by any route (referenced by ID strings only) "src/data/goddessMaterials.ts", + // Vampire materials data file — not directly imported by any route (referenced by ID strings only) + "src/data/vampireMaterials.ts", ], thresholds: { statements: 100, diff --git a/apps/web/src/components/game/aboutPanel.tsx b/apps/web/src/components/game/aboutPanel.tsx index a6b8a9f..0d4cbcf 100644 --- a/apps/web/src/components/game/aboutPanel.tsx +++ b/apps/web/src/components/game/aboutPanel.tsx @@ -387,6 +387,99 @@ const howToPlay = [ + " tick and are permanent once unlocked.", 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: "The Story tab contains 22 chapters that unlock as you progress. The" diff --git a/apps/web/src/components/game/vampireQuestsPanel.tsx b/apps/web/src/components/game/vampireQuestsPanel.tsx index 91af4de..4d8b29b 100644 --- a/apps/web/src/components/game/vampireQuestsPanel.tsx +++ b/apps/web/src/components/game/vampireQuestsPanel.tsx @@ -140,7 +140,7 @@ const VampireQuestCard = ({ * @returns The JSX element. */ const VampireQuestsPanel = (): JSX.Element => { - const { state } = useGame(); + const { state, toggleVampireAutoQuest } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return sessionStorage.getItem("elysium_vampire_quest_zone") ?? "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) => { return zone.id === activeZoneId; @@ -202,7 +203,23 @@ const VampireQuestsPanel = (): JSX.Element => { return (
-

{"Vampire Quests"}

+
+

{"Vampire Quests"}

+ +
{zones.map((zone: VampireZone) => { function handleClick(): void { diff --git a/apps/web/src/components/game/vampireThrallsPanel.tsx b/apps/web/src/components/game/vampireThrallsPanel.tsx index 830050e..3d75215 100644 --- a/apps/web/src/components/game/vampireThrallsPanel.tsx +++ b/apps/web/src/components/game/vampireThrallsPanel.tsx @@ -198,7 +198,7 @@ const ThrallCard = ({ * @returns The JSX element. */ const VampireThrallsPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, toggleVampireAutoThrall } = useGame(); const [ selectedBatch, setSelectedBatch ] = useState(() => { return parseBatchSize(localStorage.getItem("elysium_thrall_batch")); }); @@ -221,7 +221,8 @@ const VampireThrallsPanel = (): JSX.Element => { } const blood = state.resources.blood ?? 0; - const { thralls } = vampireState; + const { thralls, autoThrall } = vampireState; + const autoThrallOn = autoThrall === true; function handleBatchSelect(batch: BatchSize): void { setSelectedBatch(batch); @@ -230,7 +231,23 @@ const VampireThrallsPanel = (): JSX.Element => { return (
-

{"Thralls"}

+
+

{"Thralls"}

+ +
{"🩸 Blood: "} diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index 00aafc1..0762e35 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -16,6 +16,7 @@ import { computeGoldPerSecond, computePartyCombatPower, computeProjectedRunestones, + computeVampireBloodPerSecond, } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; @@ -95,11 +96,13 @@ const ResourceBar = ({ let goldPerSecond = 0; let essencePerSecond = 0; let projectedRunestones = 0; + let bloodPerSecond = 0; if (state !== null) { partyCombatPower = computePartyCombatPower(state); goldPerSecond = computeGoldPerSecond(state); essencePerSecond = computeEssencePerSecond(state); projectedRunestones = computeProjectedRunestones(state); + bloodPerSecond = computeVampireBloodPerSecond(state); } let avatarUrl: string | null = null; @@ -290,6 +293,17 @@ const ResourceBar = ({ {"Stardust"}

+
+ {"📈"} + + {hasApotheosis + ? formatNumber(bloodPerSecond) + : "🔒"} + + {"Blood/s"} +
diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 776d9b5..e2be73a 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -855,6 +855,16 @@ interface GameContextValue { collectVampireExploration: ( areaId: string, )=> Promise; + + /** + * Toggle the vampire auto-quest setting on/off. + */ + toggleVampireAutoQuest: ()=> void; + + /** + * Toggle the vampire auto-thrall setting on/off. + */ + toggleVampireAutoThrall: ()=> void; } 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 unlockedAchievementsReference.current = next.achievements.filter( (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) => { setState((previous) => { if (previous === null) { @@ -3709,6 +3833,8 @@ export const GameProvider = ({ toggleAutoPrestige, toggleAutoPrestigeMaxRunestones, toggleAutoQuest, + toggleVampireAutoQuest, + toggleVampireAutoThrall, transcend, triggerPrestigeToast, unlockedAchievements, @@ -3817,6 +3943,8 @@ export const GameProvider = ({ toggleAutoPrestige, toggleAutoPrestigeMaxRunestones, toggleAutoQuest, + toggleVampireAutoQuest, + toggleVampireAutoThrall, transcend, triggerPrestigeToast, unlockedAchievements, diff --git a/apps/web/src/data/vampireEquipmentSets.ts b/apps/web/src/data/vampireEquipmentSets.ts new file mode 100644 index 0000000..457d83c --- /dev/null +++ b/apps/web/src/data/vampireEquipmentSets.ts @@ -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 = [ + { + 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 }; diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index c08d540..b42a2f0 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -18,11 +18,15 @@ import { type GameState, type GoddessAchievement, type GoddessState, + type VampireAchievement, + type VampireState, computeSetBonuses, + computeVampireSetBonuses, getActiveCompanionBonus, } from "@elysium/types"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { EXPLORATION_AREAS } from "../data/explorations.js"; +import { VAMPIRE_EQUIPMENT_SETS } from "../data/vampireEquipmentSets.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 => { + 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). * 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; }; +/** + * 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 runestonesPerPrestigeLevelClient = 20; const maxBaseRunestones = 200; @@ -835,6 +968,10 @@ export const applyTick = ( // eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations 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 ( state.apotheosis !== undefined && state.apotheosis.count > 0 @@ -1091,6 +1228,285 @@ export const applyTick = ( 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 essenceValue = capResource( state.resources.essence + essenceGained + questEssence, @@ -1102,6 +1518,9 @@ export const applyTick = ( ...state, resources: { ...state.resources, + blood: capResource( + (state.resources.blood ?? 0) + bloodGainedVampire, + ), crystals: capResource( state.resources.crystals + questCrystals + challengeCrystals, ), @@ -1140,6 +1559,9 @@ export const applyTick = ( ...updatedGoddess === undefined ? {} : { goddess: updatedGoddess }, + ...updatedVampire === undefined + ? {} + : { vampire: updatedVampire }, adventurers: updatedAdventurers, bosses: updatedBosses, equipment: updatedEquipmentReference,