Files
elysium/apps/api/src/services/prestige.ts
T
hikari f2d82d58fc fix: preserve achievements across prestige
Fixes #38. buildPostPrestigeState was using structuredClone(defaultAchievements)
via the freshState, which reset all achievements on every prestige. Achievements
are now carried forward from currentState.achievements instead, ensuring unlocked
achievements are never lost across prestige resets.
2026-03-09 21:31:06 -07:00

290 lines
9.8 KiB
TypeScript

/**
* @file Prestige eligibility checks, runestone calculations, and post-prestige state builder.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import type {
GameState,
PrestigeData,
PrestigeUpgradeCategory,
} from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000;
const thresholdScaleFactor = 5;
const runestonesPerPrestigeLevel = 10;
const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25;
/**
* Calculates the gold threshold required for the next prestige.
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
* @param prestigeCount - The current number of prestiges completed.
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
* @returns The gold amount required to prestige.
*/
const calculatePrestigeThreshold = (
prestigeCount: number,
thresholdMultiplier = 1,
): number => {
return (
basePrestigeGoldThreshold
* Math.pow(thresholdScaleFactor, prestigeCount)
* thresholdMultiplier
);
};
/**
* Returns true if the player has earned enough gold to prestige.
* @param state - The current game state.
* @returns Whether the player is eligible for a prestige reset.
*/
const isEligibleForPrestige = (state: GameState): boolean => {
const thresholdMultiplier
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
return (
state.player.totalGoldEarned
>= calculatePrestigeThreshold(state.prestige.count, thresholdMultiplier)
);
};
const getCategoryMultiplier = (
purchasedUpgradeIds: Array<string>,
category: PrestigeUpgradeCategory,
): number => {
return defaultPrestigeUpgrades.filter((upgrade) => {
const matchesCategory = upgrade.category === category;
const isPurchased = purchasedUpgradeIds.includes(upgrade.id);
return matchesCategory && isPurchased;
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
};
/**
* Computes all four runestone multipliers from the purchased upgrade IDs.
* @param purchasedUpgradeIds - The array of purchased prestige upgrade IDs.
* @returns An object containing all four runestone multiplier values.
*/
const computeRunestoneMultipliers = (
purchasedUpgradeIds: Array<string>,
): {
runestonesIncomeMultiplier: number;
runestonesClickMultiplier: number;
runestonesEssenceMultiplier: number;
runestonesCrystalMultiplier: number;
} => {
return {
runestonesClickMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"click",
),
runestonesCrystalMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"crystals",
),
runestonesEssenceMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"essence",
),
runestonesIncomeMultiplier: getCategoryMultiplier(
purchasedUpgradeIds,
"income",
),
};
};
interface RunestoneParameters {
totalGoldEarned: number;
prestigeCount: number;
purchasedUpgradeIds: Array<string>;
echoRunestoneMultiplier?: number;
}
/**
* Calculates how many runestones the player earns from a prestige.
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
* @param parameters - The parameters for the runestone calculation.
* @param parameters.totalGoldEarned - The total gold earned in the current run.
* @param parameters.prestigeCount - The current prestige count.
* @param parameters.purchasedUpgradeIds - The purchased prestige upgrade IDs.
* @param parameters.echoRunestoneMultiplier - An optional echo-upgrade multiplier.
* @returns The number of runestones earned.
*/
const calculateRunestones = (parameters: RunestoneParameters): number => {
const {
totalGoldEarned,
prestigeCount,
purchasedUpgradeIds,
echoRunestoneMultiplier = 1,
} = parameters;
const threshold = calculatePrestigeThreshold(prestigeCount);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel;
const runestoneMult = getCategoryMultiplier(
purchasedUpgradeIds,
"runestones",
);
return Math.floor(base * runestoneMult * echoRunestoneMultiplier);
};
/**
* Calculates the new prestige production multiplier.
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
* @param prestigeCount - The new prestige count.
* @returns The production multiplier for the new prestige level.
*/
const calculateProductionMultiplier = (
prestigeCount: number,
): number => {
return Math.pow(1.15, prestigeCount);
};
/**
* Returns the milestone runestone bonus for the given prestige count.
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param prestigeCount - The prestige count after the current prestige.
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
*/
const calculateMilestoneBonus = (prestigeCount: number): number => {
if (prestigeCount % milestoneInterval !== 0) {
return 0;
}
const milestoneNumber = prestigeCount / milestoneInterval;
return milestoneNumber * milestoneRunestonesPerInterval;
};
/**
* Generates the reset game state after a prestige.
* Carries over prestige data and runestones; resets everything else.
* @param currentState - The game state at the time of the prestige.
* @param characterName - The player's character name to carry forward.
* @returns The new game state, prestige data, and runestone counts.
*/
const buildPostPrestigeState = (
currentState: GameState,
characterName: string,
): {
prestigeState: GameState;
prestigeData: PrestigeData;
runestonesEarned: number;
milestoneRunestones: number;
} => {
const {
autoPrestigeEnabled,
count: currentPrestigeCount,
purchasedUpgradeIds,
runestones: currentRunestones,
} = currentState.prestige;
const echoRunestoneMultiplier
= currentState.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
const runestonesEarned = calculateRunestones({
echoRunestoneMultiplier: echoRunestoneMultiplier,
prestigeCount: currentPrestigeCount,
purchasedUpgradeIds: purchasedUpgradeIds,
totalGoldEarned: currentState.player.totalGoldEarned,
});
const updatedPrestigeCount = currentPrestigeCount + 1;
const milestoneRunestones = calculateMilestoneBonus(updatedPrestigeCount);
const prestigeData: PrestigeData = {
count: updatedPrestigeCount,
lastPrestigedAt: Date.now(),
productionMultiplier: calculateProductionMultiplier(updatedPrestigeCount),
purchasedUpgradeIds: purchasedUpgradeIds,
runestones:
currentRunestones + runestonesEarned + milestoneRunestones,
...computeRunestoneMultipliers(purchasedUpgradeIds),
...autoPrestigeEnabled === undefined
? {}
: { autoPrestigeEnabled },
};
const freshState = initialGameState(currentState.player, characterName);
// Compute current-run contributions to accumulate into lifetime totals
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
const runAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const prestigeState: GameState = {
...freshState,
// Achievements are permanent — earned achievements survive all prestiges
achievements: currentState.achievements,
lastTickAt: Date.now(),
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after prestige.
*/
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ runAchievementsUnlocked,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
lifetimeBossesDefeated:
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
lifetimeClicks:
freshState.player.lifetimeClicks + currentState.player.totalClicks,
lifetimeGoldEarned:
freshState.player.lifetimeGoldEarned
+ currentState.player.totalGoldEarned,
lifetimeQuestsCompleted:
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
prestige: prestigeData,
// Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined
? {}
: { codex: currentState.codex },
// Transcendence data is permanent — never wiped by prestige
...currentState.transcendence === undefined
? {}
: { transcendence: currentState.transcendence },
// Apotheosis data is eternal — never wiped by prestige
...currentState.apotheosis === undefined
? {}
: { apotheosis: currentState.apotheosis },
// Story chapter progress is permanent — survives all resets
...currentState.story === undefined
? {}
: { story: currentState.story },
};
return {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
};
};
export {
buildPostPrestigeState,
calculateMilestoneBonus,
calculatePrestigeThreshold,
calculateProductionMultiplier,
calculateRunestones,
computeRunestoneMultipliers,
isEligibleForPrestige,
};