generated from nhcarrigan/template
d0790890ee
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>
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);
|
||
});
|
||
});
|