generated from nhcarrigan/template
aede55a13d
## 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>
319 lines
11 KiB
TypeScript
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,
|
|
};
|