generated from nhcarrigan/template
e7164257c5
## Summary Working through all 15 open balance tickets in a coordinated multi-pass approach. ### Pass 1 — Quest failure rates (closes #172) - Capped all zone quest failure chances at 15% (down from up to 40%) - Proportional scaling preserved (harder zones still fail more than easier ones) ### Pass 2 — Crystal economy (closes #165, #173, #215) - Added `crystal_pulse` (3,000 crystals), `crystal_surge` (20,000), `crystal_tempest` (150,000) upgrades to fill the dead zone between 600 and 2M crystal sinks - Bumped `click_deity`, `prestige_master`, and `prestige_legend` achievement crystal rewards (5K→15K, 5K→15K, 25K→75K) - Added crystal rewards to `first_steps` (+5) and `goblin_camp` (+10) early quests ### Pass 3 — Runestone/prestige loop (closes #166, #170) - Bumped `runestonesPerPrestigeLevel` from 15 → 20 (~33% yield increase for mid-game runs) - Reduced `income_10` cost from 22,500 → 15,000 and `income_11` from 60,000 → 35,000 - Kept client/server parity: `runestonesPerPrestigeLevelClient` in tick.ts updated to match ### Pass 4 — Quest content (#175, #178) - Both already resolved in commit666a5b2: quests now reach 5e141 CP across reality_forge, cosmic_maelstrom, primeval_sanctum, and the_absolute — fully covering P60–P212 ### Pass 5 — Daily challenges (closes #167) - Added `crafting` as a new `DailyChallengeType` - Added 3 crafting challenge templates (craft 1/2/3 recipes) - Changed generation to guarantee: 1 clicks + 1 crafting + 1 from progression pool - Added crafting challenge tracking in `craft.ts` (awards crystals on recipe craft) - Stuck players now have 2/3 daily challenges always completable ### Pass 6 — Transcendence costs (#179) - Already resolved in commit666a5b2: echo meta costs are 15/45/100 (was 25/75/200) ### Also closed as stale - #171 (milestone bonus already quadratic) - #174 (production multiplier already 1.3^n) - #176 (expanse_sovereign HP already at 3e39) - #177 (recipe costs already in expected range) - #178 (post-absolute quests already present) - #179 (echo meta costs already reduced) ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #239 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
336 lines
12 KiB
TypeScript
336 lines
12 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 runestonesPerPrestigeLevel = 20;
|
||
const milestoneInterval = 5;
|
||
const milestoneRunestonesPerInterval = 25;
|
||
|
||
/*
|
||
* Hard cap on the base runestone yield (before multipliers) to prevent
|
||
* extreme AFK accumulation from producing game-breaking runestone counts.
|
||
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige.
|
||
*/
|
||
const maxBaseRunestones = 200;
|
||
|
||
/**
|
||
* Calculates the gold threshold required for the next prestige.
|
||
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
|
||
* meaningful even as the production multiplier scales.
|
||
* @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(prestigeCount + 1, 2.5)
|
||
* 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: min(floor(cbrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL, MAX_BASE) * multipliers.
|
||
* Uses cube root for stronger diminishing returns than sqrt, and caps the base before multipliers
|
||
* to prevent extended AFK sessions from producing runestone windfalls.
|
||
* @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.min(
|
||
Math.floor(Math.cbrt(totalGoldEarned / threshold))
|
||
* runestonesPerPrestigeLevel,
|
||
maxBaseRunestones,
|
||
);
|
||
const runestoneMult = getCategoryMultiplier(
|
||
purchasedUpgradeIds,
|
||
"runestones",
|
||
);
|
||
return Math.floor(base * runestoneMult * echoRunestoneMultiplier);
|
||
};
|
||
|
||
/**
|
||
* Calculates the new prestige production multiplier.
|
||
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually
|
||
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
|
||
* @param prestigeCount - The new prestige count.
|
||
* @returns The production multiplier for the new prestige level.
|
||
*/
|
||
const calculateProductionMultiplier = (
|
||
prestigeCount: number,
|
||
): number => {
|
||
return Math.pow(1.3, 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 * 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,
|
||
autoPrestigeMaxRunestonesOnly,
|
||
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 },
|
||
...autoPrestigeMaxRunestonesOnly === undefined
|
||
? {}
|
||
: { autoPrestigeMaxRunestonesOnly },
|
||
};
|
||
|
||
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.
|
||
*/
|
||
autoAdventurer: currentState.autoAdventurer ?? false,
|
||
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,
|
||
};
|