generated from nhcarrigan/template
35e4d71d98
Fixes #39. Added bountyRunestonesClaimed?: boolean to the Boss type. The first-kill bounty runestones are now only awarded once across all prestige resets — the boss route checks the flag before awarding, sets it on first defeat, and buildPostPrestigeState carries the flag forward through fresh boss state on prestige. The boss panel badge no longer shows for bosses whose bounty has already been claimed.
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
/* 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 {
|
||
buildPostPrestigeState,
|
||
calculateMilestoneBonus,
|
||
calculatePrestigeThreshold,
|
||
calculateProductionMultiplier,
|
||
calculateRunestones,
|
||
computeRunestoneMultipliers,
|
||
isEligibleForPrestige,
|
||
} from "../../src/services/prestige.js";
|
||
import type { GameState } from "@elysium/types";
|
||
|
||
const makePlayer = (
|
||
totalGoldEarned: number,
|
||
lifetimeGoldEarned = 0,
|
||
totalClicks = 0,
|
||
) => ({
|
||
avatar: null,
|
||
characterName: "Tester",
|
||
discordId: "test_id",
|
||
discriminator: "0",
|
||
lifetimeAchievementsUnlocked: 0,
|
||
lifetimeAdventurersRecruited: 0,
|
||
lifetimeBossesDefeated: 0,
|
||
lifetimeClicks: 0,
|
||
lifetimeGoldEarned: lifetimeGoldEarned,
|
||
lifetimeQuestsCompleted: 0,
|
||
totalClicks: totalClicks,
|
||
totalGoldEarned: totalGoldEarned,
|
||
username: "testuser",
|
||
});
|
||
|
||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||
({
|
||
player: makePlayer(0),
|
||
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("calculatePrestigeThreshold", () => {
|
||
it("returns base threshold at count 0", () => {
|
||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||
});
|
||
|
||
it("returns 5× at count 1", () => {
|
||
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
|
||
});
|
||
|
||
it("returns 25× at count 2", () => {
|
||
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
|
||
});
|
||
|
||
it("applies threshold multiplier correctly", () => {
|
||
expect(calculatePrestigeThreshold(0, 2)).toBe(2_000_000);
|
||
});
|
||
});
|
||
|
||
describe("isEligibleForPrestige", () => {
|
||
it("returns true when totalGoldEarned meets threshold", () => {
|
||
const state = makeMinimalState({ player: makePlayer(1_000_000) });
|
||
expect(isEligibleForPrestige(state)).toBe(true);
|
||
});
|
||
|
||
it("returns false when totalGoldEarned is below threshold", () => {
|
||
const state = makeMinimalState({ player: makePlayer(999_999) });
|
||
expect(isEligibleForPrestige(state)).toBe(false);
|
||
});
|
||
|
||
it("uses echoPrestigeThresholdMultiplier from transcendence when present", () => {
|
||
const state = makeMinimalState({
|
||
player: makePlayer(2_000_000),
|
||
transcendence: {
|
||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||
echoPrestigeThresholdMultiplier: 2,
|
||
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||
},
|
||
});
|
||
// threshold = 1_000_000 × 2 = 2_000_000 — exactly meets
|
||
expect(isEligibleForPrestige(state)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("calculateRunestones", () => {
|
||
it("calculates basic runestones formula", () => {
|
||
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
|
||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||
expect(result).toBe(20);
|
||
});
|
||
|
||
it("applies echo runestone multiplier", () => {
|
||
// floor(sqrt(4) × 10) = 20; × 2 = 40
|
||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||
expect(result).toBe(40);
|
||
});
|
||
|
||
it("applies purchased runestone upgrade multiplier", () => {
|
||
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||
expect(result).toBeGreaterThan(20);
|
||
});
|
||
});
|
||
|
||
describe("calculateProductionMultiplier", () => {
|
||
it("returns 1 at count 0", () => {
|
||
expect(calculateProductionMultiplier(0)).toBe(1);
|
||
});
|
||
|
||
it("returns 1.15 at count 1", () => {
|
||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
|
||
});
|
||
|
||
it("scales exponentially", () => {
|
||
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
|
||
});
|
||
});
|
||
|
||
describe("calculateMilestoneBonus", () => {
|
||
it("returns 0 for non-milestone prestiges", () => {
|
||
expect(calculateMilestoneBonus(1)).toBe(0);
|
||
expect(calculateMilestoneBonus(3)).toBe(0);
|
||
expect(calculateMilestoneBonus(4)).toBe(0);
|
||
});
|
||
|
||
it("returns 25 at prestige 5", () => {
|
||
expect(calculateMilestoneBonus(5)).toBe(25);
|
||
});
|
||
|
||
it("returns 50 at prestige 10", () => {
|
||
expect(calculateMilestoneBonus(10)).toBe(50);
|
||
});
|
||
|
||
it("returns 75 at prestige 15", () => {
|
||
expect(calculateMilestoneBonus(15)).toBe(75);
|
||
});
|
||
});
|
||
|
||
describe("computeRunestoneMultipliers", () => {
|
||
it("returns all 1s with empty ids", () => {
|
||
const result = computeRunestoneMultipliers([]);
|
||
expect(result.runestonesIncomeMultiplier).toBe(1);
|
||
expect(result.runestonesClickMultiplier).toBe(1);
|
||
expect(result.runestonesEssenceMultiplier).toBe(1);
|
||
expect(result.runestonesCrystalMultiplier).toBe(1);
|
||
});
|
||
|
||
it("applies income upgrade when purchased", () => {
|
||
const result = computeRunestoneMultipliers(["income_1"]);
|
||
expect(result.runestonesIncomeMultiplier).toBeGreaterThan(1);
|
||
expect(result.runestonesClickMultiplier).toBe(1);
|
||
});
|
||
|
||
it("applies click upgrade when purchased", () => {
|
||
const result = computeRunestoneMultipliers(["click_power_1"]);
|
||
expect(result.runestonesClickMultiplier).toBeGreaterThan(1);
|
||
expect(result.runestonesIncomeMultiplier).toBe(1);
|
||
});
|
||
|
||
it("applies essence upgrade when purchased", () => {
|
||
const result = computeRunestoneMultipliers(["essence_1"]);
|
||
expect(result.runestonesEssenceMultiplier).toBeGreaterThan(1);
|
||
});
|
||
|
||
it("applies crystals upgrade when purchased", () => {
|
||
const result = computeRunestoneMultipliers(["crystal_1"]);
|
||
expect(result.runestonesCrystalMultiplier).toBeGreaterThan(1);
|
||
});
|
||
});
|
||
|
||
describe("buildPostPrestigeState", () => {
|
||
it("increments prestige count", () => {
|
||
const state = makeMinimalState({ player: makePlayer(4_000_000) });
|
||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeData.count).toBe(1);
|
||
});
|
||
|
||
it("sums runestones earned", () => {
|
||
const state = makeMinimalState({ player: makePlayer(4_000_000) });
|
||
const { prestigeData, runestonesEarned } = buildPostPrestigeState(state, "Tester");
|
||
expect(runestonesEarned).toBeGreaterThan(0);
|
||
expect(prestigeData.runestones).toBe(runestonesEarned);
|
||
});
|
||
|
||
it("adds milestone runestones at prestige 5", () => {
|
||
const state = makeMinimalState({
|
||
player: makePlayer(100_000_000),
|
||
prestige: { count: 4, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||
});
|
||
const { milestoneRunestones } = buildPostPrestigeState(state, "Tester");
|
||
expect(milestoneRunestones).toBe(25);
|
||
});
|
||
|
||
it("persists codex from current state", () => {
|
||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||
const state = makeMinimalState({ codex });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.codex).toEqual(codex);
|
||
});
|
||
|
||
it("persists story from current state", () => {
|
||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||
const state = makeMinimalState({ story });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.story).toEqual(story);
|
||
});
|
||
|
||
it("persists transcendence from current state", () => {
|
||
const transcendence = {
|
||
count: 1, echoes: 10, purchasedUpgradeIds: [],
|
||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||
echoPrestigeThresholdMultiplier: 1,
|
||
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||
};
|
||
const state = makeMinimalState({ transcendence });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.transcendence).toEqual(transcendence);
|
||
});
|
||
|
||
it("preserves autoPrestigeEnabled when set", () => {
|
||
const state = makeMinimalState({
|
||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [], autoPrestigeEnabled: true },
|
||
});
|
||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeData.autoPrestigeEnabled).toBe(true);
|
||
});
|
||
|
||
it("omits autoPrestigeEnabled when not set", () => {
|
||
const state = makeMinimalState();
|
||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||
});
|
||
|
||
it("preserves apotheosis data across prestige", () => {
|
||
const apotheosis = { count: 2 };
|
||
const state = makeMinimalState({ apotheosis });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
||
});
|
||
|
||
it("accumulates current-run gold into lifetime total", () => {
|
||
const state = makeMinimalState({
|
||
player: makePlayer(4_000_000, 1_000_000),
|
||
});
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000);
|
||
});
|
||
|
||
it("accumulates current-run clicks into lifetime total", () => {
|
||
const state = makeMinimalState({
|
||
player: makePlayer(4_000_000, 0, 500),
|
||
});
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.player.lifetimeClicks).toBe(500);
|
||
});
|
||
|
||
it("accumulates defeated bosses into lifetime total", () => {
|
||
const defeatedBoss = {
|
||
bountyRunestones: 0,
|
||
crystalReward: 0,
|
||
currentHp: 0,
|
||
damagePerSecond: 10,
|
||
description: "A boss",
|
||
equipmentRewards: [] as string[],
|
||
essenceReward: 0,
|
||
goldReward: 100,
|
||
id: "boss_1",
|
||
maxHp: 100,
|
||
name: "Boss One",
|
||
prestigeRequirement: 0,
|
||
status: "defeated" as const,
|
||
upgradeRewards: [] as string[],
|
||
zoneId: "zone_1",
|
||
};
|
||
const state = makeMinimalState({ bosses: [ defeatedBoss ] });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.player.lifetimeBossesDefeated).toBe(1);
|
||
});
|
||
|
||
it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => {
|
||
const claimedBoss = {
|
||
bountyRunestones: 5,
|
||
bountyRunestonesClaimed: true,
|
||
crystalReward: 0,
|
||
currentHp: 0,
|
||
damagePerSecond: 10,
|
||
description: "A boss",
|
||
equipmentRewards: [] as string[],
|
||
essenceReward: 0,
|
||
goldReward: 100,
|
||
id: "troll_king",
|
||
maxHp: 100,
|
||
name: "Troll King",
|
||
prestigeRequirement: 0,
|
||
status: "defeated" as const,
|
||
upgradeRewards: [] as string[],
|
||
zoneId: "verdant_vale",
|
||
};
|
||
const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||
return boss.id === "troll_king";
|
||
});
|
||
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||
});
|
||
|
||
it("accumulates completed quests into lifetime total", () => {
|
||
const quest = {
|
||
id: "q_1",
|
||
name: "A Quest",
|
||
description: "Do the thing",
|
||
status: "completed" as const,
|
||
zoneId: "zone_1",
|
||
};
|
||
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
|
||
});
|
||
|
||
it("accumulates recruited adventurers into lifetime total", () => {
|
||
const adventurer = {
|
||
combatPower: 10,
|
||
count: 5,
|
||
essencePerSecond: 0,
|
||
goldPerSecond: 1,
|
||
id: "adv_1",
|
||
level: 1,
|
||
unlocked: true,
|
||
};
|
||
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
|
||
});
|
||
|
||
it("preserves achievements from current state across prestige", () => {
|
||
const achievement = {
|
||
description: "Did a thing",
|
||
id: "ach_persisted",
|
||
name: "Achiever",
|
||
requirement: 1,
|
||
type: "totalClicks" as const,
|
||
unlockedAt: Date.now(),
|
||
};
|
||
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.achievements).toEqual([ achievement ]);
|
||
});
|
||
|
||
it("accumulates unlocked achievements into lifetime total", () => {
|
||
const achievement = {
|
||
description: "Did a thing",
|
||
id: "ach_1",
|
||
name: "Achiever",
|
||
requirement: 1,
|
||
type: "totalClicks" as const,
|
||
unlockedAt: Date.now(),
|
||
};
|
||
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
|
||
});
|
||
});
|