Files
elysium/apps/api/test/services/prestige.spec.ts
T
hikari d0790890ee
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
fix: preserve all-time stats, achievements, and boss first-kill across prestige (#47)
Resolves #37, resolves #38, and resolves #39 — three related bugs where prestige incorrectly reset data that should survive all prestige resets.

## Changes

### fix: preserve lifetime player stats across prestige (#37)
After prestige, `GameState.player.lifetime*` fields were stale — they reflected values from *before* the current run. The Prisma Player record was incremented correctly, but the GameState JSON saved to the DB had old values, so the UI showed wrong all-time totals on reload.

`buildPostPrestigeState` now computes the run-stat contributions (bosses defeated, quests completed, adventurers recruited, achievements unlocked, gold earned, clicks) and folds them into the fresh player object before writing the prestige state.

### fix: preserve achievements across prestige (#38)
`buildPostPrestigeState` was reconstructing achievements from `defaultAchievements` (via `initialGameState`), resetting all unlocked achievements on every prestige. Achievements are now carried forward from `currentState.achievements` instead.

### fix: preserve boss first-kill state across prestige (#39)
Added `bountyRunestonesClaimed?: boolean` to the `Boss` type. The boss challenge route now:
- Only awards the first-kill bounty runestones if `bountyRunestonesClaimed !== true`
- Sets `bountyRunestonesClaimed = true` on first defeat

`buildPostPrestigeState` maps the fresh boss list and carries the `bountyRunestonesClaimed` flag forward from the current state, so the bounty is never re-awarded in subsequent prestige runs. The boss panel badge is also hidden for bosses whose bounty is already claimed.

## Test Coverage
All three fixes include new tests covering the new behaviours. API coverage remains at 100%.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #47
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 21:53:58 -07:00

378 lines
14 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", () => {
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);
});
});