generated from nhcarrigan/template
feat: vampire tick engine, auto systems, and full test suite
- vampire blood production tick with thrall bloodPerSecond + multipliers - auto-quest and auto-thrall purchase in tick engine - computeVampireBloodPerSecond helper exposed for ResourceBar display - ResourceBar now shows blood/s and currency balances for vampire mode - vampire quests and thralls panels gain auto-toggle buttons - About page updated with vampire mode how-to-play entries - vampireEquipmentSets data file added to web - 100% test coverage across all API routes and services: - siring, awakening, vampireBoss, vampireCraft, vampireExplore, vampireUpgrade - debug route now covers grant-apotheosis endpoint - vampireMaterials excluded from coverage (ID-referenced only, same as goddessMaterials)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user