Files
elysium/apps/api/test/services/awakening.spec.ts
T
hikari e02827dbb6 feat: vampire tick engine, auto systems, and full test suite
- vampire blood production tick with thrall bloodPerSecond + multipliers
- auto-quest and auto-thrall purchase in tick engine
- computeVampireBloodPerSecond helper exposed for ResourceBar display
- ResourceBar now shows blood/s and currency balances for vampire mode
- vampire quests and thralls panels gain auto-toggle buttons
- About page updated with vampire mode how-to-play entries
- vampireEquipmentSets data file added to web
- 100% test coverage across all API routes and services:
  - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade
  - debug route now covers grant-apotheosis endpoint
  - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
2026-04-16 14:01:50 -07:00

429 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { 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);
});
});