feat: vampire tick engine, auto systems, and full test suite

- vampire blood production tick with thrall bloodPerSecond + multipliers
- auto-quest and auto-thrall purchase in tick engine
- computeVampireBloodPerSecond helper exposed for ResourceBar display
- ResourceBar now shows blood/s and currency balances for vampire mode
- vampire quests and thralls panels gain auto-toggle buttons
- About page updated with vampire mode how-to-play entries
- vampireEquipmentSets data file added to web
- 100% test coverage across all API routes and services:
  - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade
  - debug route now covers grant-apotheosis endpoint
  - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
This commit is contained in:
2026-04-16 14:01:50 -07:00
committed by Naomi Carrigan
parent 1e0a7b142a
commit e02827dbb6
20 changed files with 3660 additions and 10 deletions
+428
View File
@@ -0,0 +1,428 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { describe, expect, it } from "vitest";
import {
buildPostAwakeningState,
calculateSoulShardsYield,
computeAwakeningMultipliers,
isEligibleForAwakening,
} from "../../src/services/awakening.js";
import type { GameState } from "@elysium/types";
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
count: 0,
purchasedUpgradeIds: [] as Array<string>,
soulShards: 0,
soulShardsBloodMultiplier: 1,
soulShardsCombatMultiplier: 1,
soulShardsMetaMultiplier: 1,
soulShardsSiringIchorMultiplier: 1,
soulShardsSiringThresholdMultiplier: 1,
...overrides,
});
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
count: 0,
ichor: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as Array<string>,
...overrides,
});
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
achievements: [] as Array<{ id: string; unlockedAt: number | null }>,
awakening: makeAwakening(),
baseClickPower: 1,
bosses: [] as Array<{ id: string; status: string; bountyIchorClaimed?: boolean }>,
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
eternalSovereignty: { count: 0 },
exploration: {
areas: [] as Array<{ id: string; status: string }>,
craftedBloodMultiplier: 1,
craftedCombatMultiplier: 1,
craftedIchorMultiplier: 1,
craftedRecipeIds: [] as Array<string>,
materials: [] as Array<{ materialId: string; quantity: number }>,
},
lastTickAt: 0,
lifetimeBloodEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
quests: [] as Array<{ id: string; status: string }>,
siring: makeSiring(),
thralls: [] as Array<{ id: string; count: number }>,
totalBloodEarned: 0,
upgrades: [] as Array<{ id: string; purchased: boolean }>,
zones: [] as Array<{ id: string; status: string }>,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: "test_id", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("isEligibleForAwakening", () => {
it("returns false when vampire state is undefined", () => {
const state = makeState();
expect(isEligibleForAwakening(state)).toBe(false);
});
it("returns false when bosses array is empty", () => {
const state = makeState({ vampire: makeVampireState() as GameState["vampire"] });
expect(isEligibleForAwakening(state)).toBe(false);
});
it("returns false when eternal_darkness boss is present but not defeated", () => {
const state = makeState({
vampire: makeVampireState({
bosses: [ { id: "eternal_darkness", status: "available" } ],
}) as GameState["vampire"],
});
expect(isEligibleForAwakening(state)).toBe(false);
});
it("returns false when eternal_darkness boss is in_progress", () => {
const state = makeState({
vampire: makeVampireState({
bosses: [ { id: "eternal_darkness", status: "in_progress" } ],
}) as GameState["vampire"],
});
expect(isEligibleForAwakening(state)).toBe(false);
});
it("returns true when eternal_darkness boss is defeated", () => {
const state = makeState({
vampire: makeVampireState({
bosses: [ { id: "eternal_darkness", status: "defeated" } ],
}) as GameState["vampire"],
});
expect(isEligibleForAwakening(state)).toBe(true);
});
it("returns true even with other bosses in the array", () => {
const state = makeState({
vampire: makeVampireState({
bosses: [
{ id: "some_other_boss", status: "defeated" },
{ id: "eternal_darkness", status: "defeated" },
],
}) as GameState["vampire"],
});
expect(isEligibleForAwakening(state)).toBe(true);
});
it("returns false when only other bosses are defeated (not eternal_darkness)", () => {
const state = makeState({
vampire: makeVampireState({
bosses: [ { id: "some_other_boss", status: "defeated" } ],
}) as GameState["vampire"],
});
expect(isEligibleForAwakening(state)).toBe(false);
});
});
describe("calculateSoulShardsYield", () => {
it("returns 1 as minimum yield when siring count is 0", () => {
expect(calculateSoulShardsYield(0, 1)).toBe(1);
});
it("returns 1 as minimum yield when result would be below 1", () => {
// sqrt(0) * 100 = 0 → max(1, 0) = 1
expect(calculateSoulShardsYield(0, 100)).toBe(1);
});
it("computes floor(sqrt(4) * 1) = 2", () => {
expect(calculateSoulShardsYield(4, 1)).toBe(2);
});
it("computes floor(sqrt(9) * 1) = 3", () => {
expect(calculateSoulShardsYield(9, 1)).toBe(3);
});
it("applies meta multiplier correctly", () => {
// floor(sqrt(4) * 2) = floor(2 * 2) = 4
expect(calculateSoulShardsYield(4, 2)).toBe(4);
});
it("floors fractional results", () => {
// floor(sqrt(2) * 1) = floor(1.414...) = 1
expect(calculateSoulShardsYield(2, 1)).toBe(1);
});
it("floors fractional results with multiplier", () => {
// floor(sqrt(9) * 1.5) = floor(3 * 1.5) = floor(4.5) = 4
expect(calculateSoulShardsYield(9, 1.5)).toBe(4);
});
it("returns at least 1 even with very small siring count and no multiplier", () => {
expect(calculateSoulShardsYield(1, 1)).toBe(1);
});
});
describe("computeAwakeningMultipliers", () => {
it("returns all 1s with empty purchasedUpgradeIds", () => {
const result = computeAwakeningMultipliers([]);
expect(result.soulShardsBloodMultiplier).toBe(1);
expect(result.soulShardsCombatMultiplier).toBe(1);
expect(result.soulShardsMetaMultiplier).toBe(1);
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
});
it("applies blood upgrade when purchased", () => {
// awakening_blood_1 has multiplier 1.5 in blood category
const result = computeAwakeningMultipliers([ "awakening_blood_1" ]);
expect(result.soulShardsBloodMultiplier).toBe(1.5);
expect(result.soulShardsCombatMultiplier).toBe(1);
expect(result.soulShardsMetaMultiplier).toBe(1);
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
});
it("stacks multiple blood upgrades multiplicatively", () => {
// awakening_blood_1 (×1.5) × awakening_blood_2 (×2) = 3.0
const result = computeAwakeningMultipliers([ "awakening_blood_1", "awakening_blood_2" ]);
expect(result.soulShardsBloodMultiplier).toBe(3);
});
it("applies combat upgrade when purchased", () => {
// awakening_combat_1 has multiplier 1.5 in combat category
const result = computeAwakeningMultipliers([ "awakening_combat_1" ]);
expect(result.soulShardsCombatMultiplier).toBe(1.5);
expect(result.soulShardsBloodMultiplier).toBe(1);
});
it("stacks multiple combat upgrades multiplicatively", () => {
// awakening_combat_1 (×1.5) × awakening_combat_2 (×2) = 3.0
const result = computeAwakeningMultipliers([ "awakening_combat_1", "awakening_combat_2" ]);
expect(result.soulShardsCombatMultiplier).toBe(3);
});
it("applies siring threshold upgrade when purchased", () => {
// awakening_threshold_1 has multiplier 0.85 in siring_threshold category
const result = computeAwakeningMultipliers([ "awakening_threshold_1" ]);
expect(result.soulShardsSiringThresholdMultiplier).toBe(0.85);
});
it("stacks multiple threshold upgrades multiplicatively", () => {
// awakening_threshold_1 (×0.85) × awakening_threshold_2 (×0.8) = 0.68
const result = computeAwakeningMultipliers([ "awakening_threshold_1", "awakening_threshold_2" ]);
expect(result.soulShardsSiringThresholdMultiplier).toBeCloseTo(0.68);
});
it("applies siring ichor upgrade when purchased", () => {
// awakening_siring_ichor_1 has multiplier 1.5 in siring_ichor category
const result = computeAwakeningMultipliers([ "awakening_siring_ichor_1" ]);
expect(result.soulShardsSiringIchorMultiplier).toBe(1.5);
});
it("stacks multiple siring ichor upgrades multiplicatively", () => {
// awakening_siring_ichor_1 (×1.5) × awakening_siring_ichor_2 (×2) = 3.0
const result = computeAwakeningMultipliers([ "awakening_siring_ichor_1", "awakening_siring_ichor_2" ]);
expect(result.soulShardsSiringIchorMultiplier).toBe(3);
});
it("applies meta upgrade when purchased", () => {
// awakening_meta_1 has multiplier 1.5 in soulshards_meta category
const result = computeAwakeningMultipliers([ "awakening_meta_1" ]);
expect(result.soulShardsMetaMultiplier).toBe(1.5);
});
it("stacks multiple meta upgrades multiplicatively", () => {
// awakening_meta_1 (×1.5) × awakening_meta_2 (×2) = 3.0
const result = computeAwakeningMultipliers([ "awakening_meta_1", "awakening_meta_2" ]);
expect(result.soulShardsMetaMultiplier).toBe(3);
});
it("applies upgrades from multiple categories independently", () => {
const result = computeAwakeningMultipliers([
"awakening_blood_1",
"awakening_combat_1",
"awakening_meta_1",
]);
expect(result.soulShardsBloodMultiplier).toBe(1.5);
expect(result.soulShardsCombatMultiplier).toBe(1.5);
expect(result.soulShardsMetaMultiplier).toBe(1.5);
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
});
it("ignores unknown upgrade ids gracefully", () => {
const result = computeAwakeningMultipliers([ "totally_fake_upgrade_id" ]);
expect(result.soulShardsBloodMultiplier).toBe(1);
expect(result.soulShardsCombatMultiplier).toBe(1);
expect(result.soulShardsMetaMultiplier).toBe(1);
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
});
});
describe("buildPostAwakeningState", () => {
it("increments awakening count by 1", () => {
const vampire = makeVampireState({
awakening: makeAwakening({ count: 2, soulShards: 5 }),
siring: makeSiring({ count: 4 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.awakening.count).toBe(3);
});
it("adds soulShardsEarned to existing soul shards", () => {
const vampire = makeVampireState({
awakening: makeAwakening({ count: 0, soulShards: 10 }),
siring: makeSiring({ count: 4 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { soulShardsEarned, updatedVampire } = buildPostAwakeningState(state);
expect(soulShardsEarned).toBeGreaterThanOrEqual(1);
expect(updatedVampire.awakening.soulShards).toBe(10 + soulShardsEarned);
});
it("uses metaMultiplier from awakening when computing soul shards yield", () => {
// metaMultiplier = 1.5, siring count = 4 → floor(sqrt(4) * 1.5) = floor(3) = 3
const vampire = makeVampireState({
awakening: makeAwakening({ soulShardsMetaMultiplier: 1.5 }),
siring: makeSiring({ count: 4 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { soulShardsEarned } = buildPostAwakeningState(state);
expect(soulShardsEarned).toBe(3);
});
it("preserves purchased upgrade ids across awakening", () => {
const vampire = makeVampireState({
awakening: makeAwakening({ purchasedUpgradeIds: [ "awakening_blood_1" ] }),
siring: makeSiring({ count: 4 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.awakening.purchasedUpgradeIds).toContain("awakening_blood_1");
});
it("recomputes multipliers based on existing purchased upgrade ids", () => {
const vampire = makeVampireState({
awakening: makeAwakening({ purchasedUpgradeIds: [ "awakening_blood_1" ] }),
siring: makeSiring({ count: 4 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
// awakening_blood_1 has multiplier 1.5
expect(updatedVampire.awakening.soulShardsBloodMultiplier).toBe(1.5);
});
it("preserves achievements across awakening", () => {
const achievements = [ { id: "ach_1", unlockedAt: 1000 } ];
const vampire = makeVampireState({ achievements, siring: makeSiring({ count: 1 }) });
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.achievements).toEqual(achievements);
});
it("preserves equipment across awakening", () => {
const equipment = [ { id: "eq_1", owned: true, equipped: true } ];
const vampire = makeVampireState({ equipment, siring: makeSiring({ count: 1 }) });
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.equipment).toEqual(equipment);
});
it("preserves eternalSovereignty count across awakening", () => {
const vampire = makeVampireState({
eternalSovereignty: { count: 5 },
siring: makeSiring({ count: 1 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.eternalSovereignty.count).toBe(5);
});
it("preserves lifetime blood earned across awakening", () => {
const vampire = makeVampireState({
lifetimeBloodEarned: 9_999_999,
siring: makeSiring({ count: 1 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.lifetimeBloodEarned).toBe(9_999_999);
});
it("preserves lifetime bosses defeated across awakening", () => {
const vampire = makeVampireState({
lifetimeBossesDefeated: 42,
siring: makeSiring({ count: 1 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.lifetimeBossesDefeated).toBe(42);
});
it("preserves lifetime quests completed across awakening", () => {
const vampire = makeVampireState({
lifetimeQuestsCompleted: 17,
siring: makeSiring({ count: 1 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.lifetimeQuestsCompleted).toBe(17);
});
it("resets totalBloodEarned to 0", () => {
const vampire = makeVampireState({
siring: makeSiring({ count: 1 }),
totalBloodEarned: 500_000,
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.totalBloodEarned).toBe(0);
});
it("resets siring count to 0 on fresh vampire state", () => {
const vampire = makeVampireState({ siring: makeSiring({ count: 25 }) });
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
expect(updatedVampire.siring.count).toBe(0);
});
it("preserves bountyIchorClaimed flag on bosses that match fresh boss list", () => {
// Provide an existing boss with bountyIchorClaimed = true for a boss that exists in defaultVampireBosses
// We pass it through the bosses array and check that the flag survives the merge
const vampire = makeVampireState({
bosses: [ { id: "eternal_darkness", status: "defeated", bountyIchorClaimed: true } ],
siring: makeSiring({ count: 4 }),
});
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { updatedVampire } = buildPostAwakeningState(state);
// eternal_darkness should exist in the fresh boss list; its bountyIchorClaimed should be true
const eternDark = updatedVampire.bosses.find((b) => {
return b.id === "eternal_darkness";
});
// The boss may or may not exist in default data; if it does, the flag is preserved
if (eternDark !== undefined) {
expect(eternDark.bountyIchorClaimed).toBe(true);
}
});
it("returns minimum 1 soul shard even when siring count is 0", () => {
const vampire = makeVampireState({ siring: makeSiring({ count: 0 }) });
const state = makeState({ vampire: vampire as GameState["vampire"] });
const { soulShardsEarned } = buildPostAwakeningState(state);
expect(soulShardsEarned).toBe(1);
});
});