Files
elysium/apps/api/src/services/prestige.ts
T
hikari aede55a13d
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m9s
fix: preserve runestone bounty flag for legacy defeated bosses (#67)
## Summary

- Bosses defeated before `bountyRunestonesClaimed` was introduced had `status: "defeated"` but the field `undefined`
- After prestige, the preservation check (`=== true`) missed these bosses, so the first-kill bounty was re-awarded on the next run
- Now also treats `status === "defeated"` as proof the bounty was already earned, covering the migration case

## Test plan

- [ ] Existing test: `preserves bountyRunestonesClaimed flag on bosses across prestige` — still passes
- [ ] New test: `sets bountyRunestonesClaimed on bosses defeated before the flag was introduced` — covers the legacy save migration path
- [ ] Full coverage maintained at 100%

Closes #52

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #67
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-18 13:50:20 -07:00

319 lines
11 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 */
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
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);
/*
* Preserve first-kill (bounty claimed) status across the prestige reset so
* the one-time bounty is never re-awarded in subsequent runs.
*/
const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => {
const currentBoss = currentState.bosses.find((candidate) => {
return candidate.id === freshBoss.id;
});
if (
currentBoss?.bountyRunestonesClaimed === true
|| currentBoss?.status === "defeated"
) {
return { ...freshBoss, bountyRunestonesClaimed: true };
}
return freshBoss;
});
// 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,
/*
* Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset.
*/
autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed,
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,
};