Files
elysium/apps/api/test/services/prestige.spec.ts
T
hikari e7164257c5
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m12s
feat: comprehensive balance pass (#239)
## Summary

Working through all 15 open balance tickets in a coordinated multi-pass approach.

### Pass 1 — Quest failure rates (closes #172)
- Capped all zone quest failure chances at 15% (down from up to 40%)
- Proportional scaling preserved (harder zones still fail more than easier ones)

### Pass 2 — Crystal economy (closes #165, #173, #215)
- Added `crystal_pulse` (3,000 crystals), `crystal_surge` (20,000), `crystal_tempest` (150,000) upgrades to fill the dead zone between 600 and 2M crystal sinks
- Bumped `click_deity`, `prestige_master`, and `prestige_legend` achievement crystal rewards (5K→15K, 5K→15K, 25K→75K)
- Added crystal rewards to `first_steps` (+5) and `goblin_camp` (+10) early quests

### Pass 3 — Runestone/prestige loop (closes #166, #170)
- Bumped `runestonesPerPrestigeLevel` from 15 → 20 (~33% yield increase for mid-game runs)
- Reduced `income_10` cost from 22,500 → 15,000 and `income_11` from 60,000 → 35,000
- Kept client/server parity: `runestonesPerPrestigeLevelClient` in tick.ts updated to match

### Pass 4 — Quest content (#175, #178)
- Both already resolved in commit 666a5b2: quests now reach 5e141 CP across reality_forge, cosmic_maelstrom, primeval_sanctum, and the_absolute — fully covering P60–P212

### Pass 5 — Daily challenges (closes #167)
- Added `crafting` as a new `DailyChallengeType`
- Added 3 crafting challenge templates (craft 1/2/3 recipes)
- Changed generation to guarantee: 1 clicks + 1 crafting + 1 from progression pool
- Added crafting challenge tracking in `craft.ts` (awards crystals on recipe craft)
- Stuck players now have 2/3 daily challenges always completable

### Pass 6 — Transcendence costs (#179)
- Already resolved in commit 666a5b2: echo meta costs are 15/45/100 (was 25/75/200)

### Also closed as stale
- #171 (milestone bonus already quadratic)
- #174 (production multiplier already 1.3^n)
- #176 (expanse_sovereign HP already at 3e39)
- #177 (recipe costs already in expected range)
- #178 (post-absolute quests already present)
- #179 (echo meta costs already reduced)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #239
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:58:04 -07:00

427 lines
16 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 {
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", () => {
// base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
});
it("returns base × 2^2.5 at count 1", () => {
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
});
it("returns base × 3^2.5 at count 2", () => {
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
});
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(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(20);
});
it("applies echo runestone multiplier", () => {
// floor(cbrt(4)) × 20 = 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 "runestone_gain_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).toBe(25);
});
it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200);
});
});
describe("calculateProductionMultiplier", () => {
it("returns 1 at count 0", () => {
expect(calculateProductionMultiplier(0)).toBe(1);
});
it("returns 1.3 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
});
it("scales exponentially", () => {
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 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 100 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(100);
});
it("returns 225 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(225);
});
});
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 autoPrestigeMaxRunestonesOnly when set", () => {
const state = makeMinimalState({
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
});
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
});
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
const state = makeMinimalState();
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).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("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
const legacyDefeatedBoss = {
bountyRunestones: 5,
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: [ legacyDefeatedBoss ] 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);
});
});