generated from nhcarrigan/template
fix: runestone formula, prestige/transcendence rebalance, exploration fixes, and comprehensive balance audit (#135)
## What changed and why ### Runestone formula (`prestige.ts`) - Swapped `sqrt` for `cbrt` — much stronger diminishing returns for large gold values - Added base cap of **200** (→ ~1,125 max with all upgrades at 5.625× multiplier) - Prevents extended AFK sessions from producing runestone windfalls that allow immediate upgrade purchasing and rapid prestige chaining ### Prestige threshold formula (`prestige.ts`) - Old: `1,000,000 × 5^n` — exponential, grows impossibly fast, prestige 10+ takes years - New: `1,000,000 × (n+1)²` — polynomial, peaks at ~1 day/run around P8–10, then gets *easier* as the production multiplier overtakes it - Removed `thresholdScaleFactor` constant (no longer needed) ### Production multiplier (`prestige.ts`) - Old: `1.15^n` - New: `1.25^n` — compounds faster, ensures the polynomial threshold eventually gets easy in the late game ### Boss prestige requirements (`bosses.ts`) - Rescaled proportionally from 0–88 range to 0–20 range - The Absolute One now requires prestige **20** (was 88), making transcendence reachable in a few weeks of idle play ### Echo formula (`transcendence.ts`) - Constant changed from 853 → **224** - At the target prestige of 20: `floor(224 / sqrt(20)) = 50 echoes` per transcendence (no meta upgrades) - With all echo_meta upgrades (3.75× total): up to **187 echoes** per transcendence ### Transcendence upgrade costs (`transcendenceUpgrades.ts`) - Old total: **866 echoes** → New total: **400 echoes** (roughly halved across all categories) - Apotheosis still requires **all 15 upgrades** purchased ### Balance fixes (closes #141, #142, #143, #144, #145) - Equipment: `philosophers_stone` click multiplier 2.25→2.5, `crystal_shard` 1.55→1.65 (#144) - Recipes: added `primal_omega_lens` cross-zone click_power recipe at 1.38× (#142) - Adventurers: `celestial_guard` base cost adjusted to smooth tier 14→15→16 cost curve (#145) ### Quest reward rebalancing (closes #136, #137) - Shadow Marshes: buffed `shadow_mere`, `witch_coven`, `plague_ruins` rewards to match combat requirements (#136) - Astral Void: added gold to `void_rift`, increased rewards across all Astral Void quests (#137) ### Boss reward additions (closes #138, #139, #140) - Assigned 9 unassigned adventurer-specific upgrades to Crystalline Spire through Eternal Throne bosses that had empty `upgradeRewards` arrays (#140) ### Combat power documentation (closes #153) - Expanded JSDoc on `computePartyCombatPower` to clarify companion `bossDamage` multiplier behaviour ### Effective adventurer stats (closes #154) - Added `computeEffectiveAdventurerStats` to `tick.ts` and updated `AdventurerCard` to display effective post-multiplier stats ### Adventurer upgrade timing (closes #158) - Audited every adventurer-specific upgrade reward — upgrades now land within the same progression window where that adventurer tier is still a meaningful contributor ### Sync and save fixes (closes #147, #148, #151) - Fixed sync new content count to report only genuinely changed items (#147) - Fixed signature mismatch after first auto-boss completion (#148) - Added auto-buy cap (100) on non-max-tier adventurers (#151) ### Auto-adventurer persistence (closes #156) - Auto-buy preference now preserved across prestige resets ### Broken CDN image (closes #159) - Uploaded missing `auto_adventurer.jpg` to CDN ### Codex unlock hints (closes #146) - Locked codex entries now display a hint generated from `sourceType` and `sourceId` ### Exploration bug fixes (closes #160, #161) - Fixed auto-save race condition discarding exploration materials collected mid-tick (#160) - Fixed exploration areas failing to unlock when zone was unlocked via boss kill or quest completion (#161) ### Concurrent prestige fix (closes #162) - Added optimistic locking via `updatedAt` — concurrent prestige requests return 409 ### Prestige UX (closes #163) - Added `reloadSilent` to game context — no loading screen flash after prestige ### Balance adjustments (closes #164, #165, #166, #167) - Reduced `shadow_mere` CP requirement 5,000,000 → 2,000,000 (#164) - Buffed crystal drops from Shadow Marshes bosses and quests (#165) - Increased runestone yield from 10 → 15 per prestige level (#166) - Daily challenge set always includes a clicks challenge (#167) ### Progression QoL (closes #168, #169) - Added `computeProjectedRunestones()` and persistent `+N On Prestige` resource bar row (#168) - Added `enablePrestigeAnnouncements` setting per player (#169) --- ## Comprehensive balance audit (closes #187, #191, #192, #193, #194, #195, #196, #197, #198) ### Crystal economy fixes - Zeroed crystal rewards for all Zone 7+ boss drops (Celestial Reaches onwards) — crystals are an early/mid-game currency and should not flow freely into the endgame (#187) - Zeroed crystal rewards for all Zone 9+ quest rewards (Infernal Court onwards) — same rationale (#191) ### Achievement additions and fixes - Added quest milestone achievements at 75 quests (10,000 crystals) and 100 quests (15,000 crystals) - Added boss milestone achievement at 50 bosses (15,000 crystals) - Added prestige milestone achievements at P50, P100, P150, P200 — rewarding **runestones** rather than crystals to match the late-game economy - Added gold milestone achievements through 1e90 gold earned - Fixed `quest_eternal` condition from 122 → **112** (actual quest count) — was permanently impossible (#197) - Fixed `fully_equipped` condition from 65 → **78** (actual equipment count after new items) (#197) - Fixed `devourer_slayer` description to remove incorrect zone reference ### Upgrade balance - Fixed Essence Guild multiplier 1.5× → **2×** — was identical to the cheaper Merchant Alliance for 5× the cost (#194) - Raised Void Ascendancy crystal cost 10M → **50M** — was trivially cheap compared to the parallel Celestial Mandate upgrade (100B essence + 50T gold) (#195) - Fixed Sunken Temple quest rewards (gold 2M → 60M, essence 1,500 → 25,000, crystals 75 → 400) — was rewarding less than its easier prerequisite Witch Coven (#193) ### Equipment balance - Buffed Eternal Prism stats to click 5×, combat **3×**, gold **2.5×** — was only marginally better than the free Eternity Stone boss drop for 100M crystals (#196) ### Missing content - Created **13 missing equipment items** for Zones 15–18 (primordial_chaos through the_absolute) that were referenced by late-game boss `equipmentRewards` arrays but never existed in `equipment.ts` (#198): - `chaos_mantle`, `titan_core` (Primordial Chaos) - `expanse_blade`, `void_armour_mk2` (Infinite Expanse) - `cosmos_blade`, `reality_plate` (Reality Forge) - `maelstrom_edge`, `cosmic_plate` (Cosmic Maelstrom) - `primeval_blade`, `ancient_aegis` (Primeval Sanctum) - `absolute_blade`, `eternity_plate`, `omniversal_core` (The Absolute) - Stats scale from combat 14× / gold 9× (Zone 15) up to combat 28× / gold 20× for the final boss drops ### Type system - Extended `AchievementReward` type to support `runestones` field - Updated tick engine achievement processing to award both crystals and runestones --- ## Target progression timeline (optimal play, ~16h/day idle) - First cycle to P20: ~375h (~3.3 weeks) - Each subsequent cycle gets faster as echo upgrades boost income/combat/threshold - Expected **~5 transcendences** before apotheosis at 50–187 echoes/transcendence - **~6 months** to apotheosis for a dedicated player ## Test plan - [ ] Lint, build, and test pipeline passes (100% coverage maintained) - [ ] Prestige threshold at P0 is still 1,000,000 gold - [ ] Prestige runs feel ~1 day long around P8–10 and get easier after - [ ] The Absolute One is locked until prestige 20 - [ ] Transcendence at P20 awards 50 echoes (no meta upgrades) - [ ] All 15 transcendence upgrades cost 400 echoes total - [ ] Bosses in Zones 7+ drop 0 crystals; Zones 1–6 retain crystal drops - [ ] Quests in Zones 9+ reward 0 crystals; Zones 1–8 retain crystal rewards - [ ] Sunken Temple rewards more gold/essence/crystals than Witch Coven - [ ] Essence Guild gives 2× income (stronger than Merchant Alliance 1.5×) - [ ] Void Ascendancy costs 50M crystals - [ ] Eternal Prism stats are click 5×, combat 3×, gold 2.5× - [ ] Late-game bosses (primordial_titan through the_absolute_one) drop equipment on kill - [ ] `quest_eternal` achievement requires 112 quests - [ ] `fully_equipped` achievement requires 78 equipment pieces - [ ] P50/P100/P150/P200 prestige achievements reward runestones - [ ] Adventurer cards show effective post-multiplier stats - [ ] Exploration areas unlock correctly when their zone is unlocked - [ ] Concurrent prestige requests return 409 - [ ] No loading screen flash after prestige - [ ] Daily challenge set always includes a clicks challenge - [ ] Resource bar shows `+N On Prestige` runestone preview ✨ This PR was crafted with help from Hikari~ 🌸 Reviewed-on: #135 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #135.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
@@ -76,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface EffectiveAdventurerStats {
|
||||
readonly combatPower: number;
|
||||
readonly essencePerSecond: number;
|
||||
readonly goldPerSecond: number;
|
||||
}
|
||||
|
||||
interface AdventurerCardProperties {
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly effectiveStats: EffectiveAdventurerStats;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +100,7 @@ interface AdventurerCardProperties {
|
||||
* @param props.batchSize - The selected batch size.
|
||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.effectiveStats - The post-multiplier per-unit stats.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerCard = ({
|
||||
@@ -100,6 +109,7 @@ const AdventurerCard = ({
|
||||
batchSize,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
effectiveStats,
|
||||
}: AdventurerCardProperties): JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
@@ -134,17 +144,17 @@ const AdventurerCard = ({
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
{formatNumber(adventurer.goldPerSecond)}
|
||||
{formatNumber(effectiveStats.goldPerSecond)}
|
||||
{" gold/s each"}
|
||||
</p>
|
||||
{adventurer.essencePerSecond > 0
|
||||
&& <p>
|
||||
{formatNumber(adventurer.essencePerSecond)}
|
||||
{formatNumber(effectiveStats.essencePerSecond)}
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{formatNumber(adventurer.combatPower)}
|
||||
{formatNumber(effectiveStats.combatPower)}
|
||||
{" combat power each"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
effectiveStats={computeEffectiveAdventurerStats(
|
||||
state,
|
||||
adventurer.id,
|
||||
)}
|
||||
formatNumber={formatNumber}
|
||||
key={adventurer.id}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { computePartyCombatPower } from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Boss, GameState } from "@elysium/types";
|
||||
import type { Boss } from "@elysium/types";
|
||||
|
||||
interface BossCardProperties {
|
||||
readonly boss: Boss;
|
||||
@@ -157,72 +158,6 @@ const BossCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes party DPS and HP from the current game state.
|
||||
* @param state - The full game state.
|
||||
* @returns The computed party DPS and HP values.
|
||||
*/
|
||||
const computePartyStats = (
|
||||
state: GameState,
|
||||
): {
|
||||
partyDps: number;
|
||||
partyHp: number;
|
||||
} => {
|
||||
const { upgrades, adventurers, equipment, prestige } = state;
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const { purchased, target, multiplier } = upgrade;
|
||||
if (purchased && target === "global") {
|
||||
globalMultiplier = globalMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeBonus = prestige.count * 0.1;
|
||||
const prestigeMultiplier = 1 + prestigeBonus;
|
||||
const equipmentCombatMultiplier = equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((multiplier, item) => {
|
||||
return multiplier * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
let partyDps = 0;
|
||||
let partyHp = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const { count, id: adventurerId, combatPower, level } = adventurer;
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const {
|
||||
purchased,
|
||||
target,
|
||||
multiplier,
|
||||
adventurerId: upgradeAdventurerId,
|
||||
} = upgrade;
|
||||
if (
|
||||
purchased
|
||||
&& target === "adventurer"
|
||||
&& upgradeAdventurerId === adventurerId
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const dps
|
||||
= combatPower
|
||||
* count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyDps = partyDps + dps;
|
||||
const hp = level * 50 * count;
|
||||
partyHp = partyHp + hp;
|
||||
}
|
||||
partyDps = partyDps * equipmentCombatMultiplier;
|
||||
return { partyDps, partyHp };
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the boss panel with zone selection and boss list.
|
||||
* @returns The JSX element.
|
||||
@@ -266,7 +201,14 @@ const BossPanel = (): JSX.Element => {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||
const {
|
||||
adventurers,
|
||||
autoBoss,
|
||||
bosses,
|
||||
prestige: playerPrestige,
|
||||
quests,
|
||||
zones,
|
||||
} = state;
|
||||
|
||||
const activeZone = zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
@@ -349,7 +291,12 @@ const BossPanel = (): JSX.Element => {
|
||||
}
|
||||
|
||||
const autoBossOn = autoBoss === true;
|
||||
const { partyDps, partyHp } = computePartyStats(state);
|
||||
const partyDps = computePartyCombatPower(state);
|
||||
let partyHp = 0;
|
||||
for (const { level, count } of adventurers) {
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
|
||||
partyHp = partyHp + level * 50 * count;
|
||||
}
|
||||
const { count: prestigeCount } = playerPrestige;
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,6 +49,40 @@ const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
||||
zone: "zones",
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a snake_case ID to a Title Case display name.
|
||||
* @param id - The snake_case identifier to format.
|
||||
* @returns The formatted display name.
|
||||
*/
|
||||
const formatId = (id: string): string => {
|
||||
return id.split("_").
|
||||
map((word) => {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}).
|
||||
join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable unlock hint for a locked codex entry.
|
||||
* @param entry - The locked codex entry.
|
||||
* @returns A string describing how to unlock the entry.
|
||||
*/
|
||||
const buildUnlockHint = (entry: CodexEntry): string => {
|
||||
const name = formatId(entry.sourceId);
|
||||
switch (entry.sourceType) {
|
||||
case "boss": return `Defeat ${name}`;
|
||||
case "quest": return `Complete: ${name}`;
|
||||
case "equipment": return `Obtain: ${name}`;
|
||||
case "adventurer": return `Recruit a ${name}`;
|
||||
case "upgrade": return `Purchase: ${name}`;
|
||||
case "prestige": return `Purchase runestone upgrade: ${name}`;
|
||||
case "zone": return `Explore: ${name}`;
|
||||
case "exploration": return `Discover: ${name}`;
|
||||
case "recipe": return `Craft: ${name}`;
|
||||
default: return "Keep playing to unlock";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex panel with lore entries grouped by zone.
|
||||
* @returns The JSX element.
|
||||
@@ -136,6 +170,9 @@ const CodexPanel = (): JSX.Element => {
|
||||
<span className="codex-lock">{"🔒"}</span>
|
||||
<span className="codex-entry-title">{"???"}</span>
|
||||
</div>
|
||||
<p className="codex-unlock-hint">
|
||||
{buildUnlockHint(entry)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@ const EditProfileModal = ({
|
||||
void handleNotificationsEnable();
|
||||
}
|
||||
|
||||
function handlePrestigeAnnouncementsToggle(): void {
|
||||
toggleSetting("enablePrestigeAnnouncements");
|
||||
}
|
||||
|
||||
const isSaveDisabled = saving || characterName.trim() === "";
|
||||
|
||||
let saveLabel = "Save Profile";
|
||||
@@ -417,6 +421,23 @@ const EditProfileModal = ({
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.enablePrestigeAnnouncements
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handlePrestigeAnnouncementsToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"⭐ Prestige Bot Announcements"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.enablePrestigeAnnouncements
|
||||
? "✓ On"
|
||||
: "Off"
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
|
||||
@@ -12,25 +12,27 @@ import { useState, type JSX } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
PRESTIGE_UPGRADES,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import {
|
||||
computeProjectedRunestones,
|
||||
} from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { sendNotification } from "../../utils/notification.js";
|
||||
import { playSound } from "../../utils/sound.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseThreshold = 1_000_000;
|
||||
const thresholdScale = 5;
|
||||
const runestonesPerLevel = 10;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
||||
return baseThreshold * Math.pow(prestigeCount + 1, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -42,32 +44,6 @@ const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the runestone preview for a prestige.
|
||||
* @param totalGoldEarned - Total gold earned this run.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
||||
* @returns The predicted runestone reward.
|
||||
*/
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === "runestones"
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
"income",
|
||||
"click",
|
||||
@@ -84,7 +60,7 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
const PrestigePanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reload,
|
||||
reloadSilent,
|
||||
formatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
enableNotifications,
|
||||
@@ -114,11 +90,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const runestonePreview = computeProjectedRunestones(state);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
@@ -141,7 +113,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||
);
|
||||
}
|
||||
await reload();
|
||||
await reloadSilent();
|
||||
} catch (error_: unknown) {
|
||||
setPrestigeError(
|
||||
error_ instanceof Error
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { zoneFailureChance } from "../../engine/tick.js";
|
||||
import {
|
||||
computePartyCombatPower,
|
||||
zoneFailureChance,
|
||||
} from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
@@ -208,7 +211,7 @@ const QuestPanel = (): JSX.Element => {
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, bosses, quests, zones } = state;
|
||||
const { autoQuest, bosses, quests, zones } = state;
|
||||
|
||||
const activeZone = zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
@@ -226,11 +229,7 @@ const QuestPanel = (): JSX.Element => {
|
||||
: quests.find((quest) => {
|
||||
return quest.id === activeZone.unlockQuestId;
|
||||
});
|
||||
let partyCombatPower = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const contribution = adventurer.combatPower * adventurer.count;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
const partyCombatPower = computePartyCombatPower(state);
|
||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||
return zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||
/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */
|
||||
/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
@@ -238,6 +240,22 @@ const UpgradePanel = (): JSX.Element => {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const upgrade of locked) {
|
||||
if (
|
||||
!upgradeUnlockHints.has(upgrade.id)
|
||||
&& upgrade.adventurerId !== undefined
|
||||
) {
|
||||
const adventurerForHint = adventurers.find((a) => {
|
||||
return a.id === upgrade.adventurerId;
|
||||
});
|
||||
if (adventurerForHint !== undefined) {
|
||||
upgradeUnlockHints.set(
|
||||
upgrade.id,
|
||||
`🗡️ Recruit: ${adventurerForHint.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||
import { useState, type FocusEvent, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
|
||||
import {
|
||||
RESOURCE_CAP,
|
||||
computeEssencePerSecond,
|
||||
computeGoldPerSecond,
|
||||
computePartyCombatPower,
|
||||
computeProjectedRunestones,
|
||||
} from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
|
||||
interface ResourceBarProperties {
|
||||
@@ -83,12 +89,13 @@ const ResourceBar = ({
|
||||
const { gold, essence, crystals } = resources;
|
||||
let partyCombatPower = 0;
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
let projectedRunestones = 0;
|
||||
if (state !== null) {
|
||||
for (const adventurer of state.adventurers) {
|
||||
const contribution = adventurer.combatPower * adventurer.count;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
partyCombatPower = computePartyCombatPower(state);
|
||||
goldPerSecond = computeGoldPerSecond(state);
|
||||
essencePerSecond = computeEssencePerSecond(state);
|
||||
projectedRunestones = computeProjectedRunestones(state);
|
||||
}
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
@@ -182,6 +189,13 @@ const ResourceBar = ({
|
||||
</span>
|
||||
<span className="resource-label">{"Gold/s"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⚡"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(essencePerSecond)}
|
||||
</span>
|
||||
<span className="resource-label">{"Essence/s"}</span>
|
||||
</div>
|
||||
<div className={`resource${essenceFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
@@ -223,6 +237,13 @@ const ResourceBar = ({
|
||||
</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⭐"}</span>
|
||||
<span className="resource-value">
|
||||
{`+${formatNumber(projectedRunestones)}`}
|
||||
</span>
|
||||
<span className="resource-label">{"On Prestige"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⚔️"}</span>
|
||||
<span className="resource-value">
|
||||
|
||||
@@ -53,11 +53,13 @@ import {
|
||||
transcend as transcendApi,
|
||||
} from "../api/client.js";
|
||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||
import { RECIPES } from "../data/recipes.js";
|
||||
import {
|
||||
RESOURCE_CAP,
|
||||
applyTick,
|
||||
calculateClickPower,
|
||||
computePartyCombatPower,
|
||||
} from "../engine/tick.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||
@@ -115,6 +117,9 @@ const applyBossResult = (
|
||||
}).
|
||||
filter(Boolean),
|
||||
);
|
||||
const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => {
|
||||
return z.id;
|
||||
}));
|
||||
|
||||
const challengeUpdate
|
||||
= previous.dailyChallenges === undefined
|
||||
@@ -215,6 +220,23 @@ const applyBossResult = (
|
||||
? { ...u, unlocked: true }
|
||||
: u;
|
||||
}),
|
||||
...newlyUnlockedZoneIds.size === 0 || previous.exploration === undefined
|
||||
? {}
|
||||
: {
|
||||
exploration: {
|
||||
...previous.exploration,
|
||||
areas: previous.exploration.areas.map((area) => {
|
||||
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
||||
return definition.id === area.id;
|
||||
});
|
||||
return areaDefinition !== undefined
|
||||
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
||||
&& area.status === "locked"
|
||||
? { ...area, status: "available" as const }
|
||||
: area;
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,6 +310,12 @@ interface GameContextValue {
|
||||
*/
|
||||
reload: ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Reload state from the server without showing the loading screen (used
|
||||
* after prestige to avoid the visible flash/hang).
|
||||
*/
|
||||
reloadSilent: ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Unix timestamp of the last successful cloud save (null until first save response).
|
||||
*/
|
||||
@@ -696,6 +724,10 @@ export const GameProvider = ({
|
||||
|
||||
/* No-op placeholder */
|
||||
});
|
||||
const reloadSilentReference = useRef<()=> Promise<void>>(async() => {
|
||||
|
||||
/* No-op placeholder */
|
||||
});
|
||||
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
|
||||
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
|
||||
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
|
||||
@@ -783,6 +815,32 @@ export const GameProvider = ({
|
||||
|
||||
reloadReference.current = reload;
|
||||
|
||||
const reloadSilent = useCallback(async() => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await loadGame();
|
||||
setState(data.state);
|
||||
setLastSavedAt(data.state.player.lastSavedAt);
|
||||
if (data.signature !== undefined) {
|
||||
signatureReference.current = data.signature;
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
setLoginStreak(data.loginStreak);
|
||||
setSchemaOutdated(data.schemaOutdated);
|
||||
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||
setInGuild(data.inGuild);
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load game",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
reloadSilentReference.current = reloadSilent;
|
||||
|
||||
useEffect(() => {
|
||||
enableSoundsReference.current = enableSounds;
|
||||
}, [ enableSounds ]);
|
||||
@@ -1078,11 +1136,7 @@ export const GameProvider = ({
|
||||
return q.status === "active";
|
||||
});
|
||||
if (!hasActiveQuest) {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
||||
const partyCombatPower = next.adventurers.reduce((total, a) => {
|
||||
const power = total + a.combatPower;
|
||||
return power * a.count;
|
||||
}, 0);
|
||||
const partyCombatPower = computePartyCombatPower(next);
|
||||
const zoneOrder = new Map(
|
||||
next.zones.map((z, index) => {
|
||||
return [ z.id, index ];
|
||||
@@ -1120,14 +1174,31 @@ export const GameProvider = ({
|
||||
next.autoAdventurer === true
|
||||
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
|
||||
) {
|
||||
const maxAdventurerLevel = Math.max(
|
||||
...next.adventurers.
|
||||
filter((a) => {
|
||||
return a.unlocked;
|
||||
}).
|
||||
map((a) => {
|
||||
return a.level;
|
||||
}),
|
||||
);
|
||||
const autoBuyCap = 100;
|
||||
const [ bestAdventurer ] = next.adventurers.
|
||||
filter((adventurer) => {
|
||||
const cost
|
||||
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
|
||||
return adventurer.unlocked && next.resources.gold >= cost;
|
||||
const isMaxTier = adventurer.level === maxAdventurerLevel;
|
||||
const withinCap
|
||||
= isMaxTier || adventurer.count < autoBuyCap;
|
||||
return (
|
||||
adventurer.unlocked
|
||||
&& next.resources.gold >= cost
|
||||
&& withinCap
|
||||
);
|
||||
}).
|
||||
sort((adventurerA, adventurerB) => {
|
||||
return adventurerB.combatPower - adventurerA.combatPower;
|
||||
return adventurerB.level - adventurerA.level;
|
||||
});
|
||||
if (bestAdventurer !== undefined) {
|
||||
const purchaseCost
|
||||
@@ -1280,7 +1351,7 @@ export const GameProvider = ({
|
||||
if (enableNotificationsReference.current) {
|
||||
sendNotification("⭐ Prestige!", "You have ascended!");
|
||||
}
|
||||
await reloadReference.current();
|
||||
await reloadSilentReference.current();
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
@@ -1346,6 +1417,13 @@ export const GameProvider = ({
|
||||
}
|
||||
return afterBoss;
|
||||
});
|
||||
|
||||
/*
|
||||
* Boss fight modifies server state; clear stale signature so
|
||||
* the next pre-save or auto-save does not send a mismatched one.
|
||||
*/
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
setAutoBossLastResult({
|
||||
at: Date.now(),
|
||||
bossName: bossName,
|
||||
@@ -1789,7 +1867,18 @@ export const GameProvider = ({
|
||||
|
||||
const collectExploration = useCallback(
|
||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||
isSyncingReference.current = true;
|
||||
const result = await collectExplorationApi({ areaId });
|
||||
|
||||
/*
|
||||
* Collect mutates server state outside the normal save flow — clear the
|
||||
* stale HMAC signature and reset the timer so the next auto-save fires
|
||||
* after React has re-rendered with the new materials in stateReference.
|
||||
*/
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
lastSaveReference.current = Date.now();
|
||||
isSyncingReference.current = false;
|
||||
setState((previous) => {
|
||||
if (previous?.exploration === undefined) {
|
||||
return previous;
|
||||
@@ -2320,6 +2409,7 @@ export const GameProvider = ({
|
||||
offlineEssence,
|
||||
offlineGold,
|
||||
reload,
|
||||
reloadSilent,
|
||||
resetProgress,
|
||||
saveSchemaVersion,
|
||||
schemaOutdated,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.08 },
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
||||
id: "elder_bark_shield",
|
||||
@@ -102,7 +102,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
description:
|
||||
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
||||
id: "cursed_focus",
|
||||
@@ -128,7 +128,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.12 },
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
||||
id: "elemental_ore_ingot",
|
||||
@@ -194,7 +194,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description:
|
||||
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
||||
id: "pressure_forged_core",
|
||||
@@ -272,7 +272,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.18 },
|
||||
bonus: { type: "combat_power", value: 1.28 },
|
||||
description:
|
||||
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
||||
id: "null_field_generator",
|
||||
@@ -310,7 +310,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description:
|
||||
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
||||
id: "eternity_bound_ring",
|
||||
@@ -376,7 +376,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.22 },
|
||||
bonus: { type: "combat_power", value: 1.35 },
|
||||
description:
|
||||
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
||||
id: "reality_ingot",
|
||||
@@ -428,7 +428,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
bonus: { type: "combat_power", value: 1.4 },
|
||||
description:
|
||||
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
||||
id: "ancient_memory_array",
|
||||
@@ -466,7 +466,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
bonus: { type: "combat_power", value: 1.55 },
|
||||
description:
|
||||
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
||||
id: "omega_convergence",
|
||||
|
||||
+335
-14
@@ -11,7 +11,6 @@
|
||||
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
|
||||
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */
|
||||
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
|
||||
import {
|
||||
type Achievement,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
getActiveCompanionBonus,
|
||||
} from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,12 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||
*/
|
||||
export const PRESTIGE_COMBAT_BASE = 4;
|
||||
|
||||
/**
|
||||
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
|
||||
*/
|
||||
@@ -195,6 +201,285 @@ export const computeGoldPerSecond = (state: GameState): number => {
|
||||
return goldPerSecond;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the current essence per second for the given game state,
|
||||
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
|
||||
* @param state - The current game state.
|
||||
* @returns The total essence per second.
|
||||
*/
|
||||
export const computeEssencePerSecond = (state: GameState): number => {
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier
|
||||
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionEssenceMult
|
||||
= companionBonus?.type === "essenceIncome"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
let essencePerSecond = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isThisAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id;
|
||||
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
const contribution
|
||||
= adventurer.essencePerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesEssence
|
||||
* craftedEssenceMultiplier
|
||||
* companionEssenceMult;
|
||||
essencePerSecond = essencePerSecond + contribution;
|
||||
}
|
||||
return essencePerSecond;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the effective per-unit stats for a single adventurer type,
|
||||
* applying all active multipliers (upgrades, prestige, equipment, echo,
|
||||
* crafted, companion). The returned values represent what a single
|
||||
* adventurer of this type currently contributes per second, matching the
|
||||
* per-unit contribution used by computeGoldPerSecond and
|
||||
* computeEssencePerSecond.
|
||||
* @param state - The current game state.
|
||||
* @param adventurerId - The ID of the adventurer to compute stats for.
|
||||
* @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower.
|
||||
*/
|
||||
export const computeEffectiveAdventurerStats = (
|
||||
state: GameState,
|
||||
adventurerId: string,
|
||||
): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => {
|
||||
const adventurer = state.adventurers.find((a) => {
|
||||
return a.id === adventurerId;
|
||||
});
|
||||
|
||||
/* V8 ignore next 3 -- @preserve */
|
||||
if (adventurer === undefined) {
|
||||
return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 };
|
||||
}
|
||||
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isThisAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurerId;
|
||||
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const equippedItems = state.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||
}, 1);
|
||||
const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
const equippedItemIds = equippedItems.map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS);
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
||||
const craftedGoldMultiplier
|
||||
= state.exploration?.craftedGoldMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier
|
||||
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
const craftedCombatMultiplier
|
||||
= state.exploration?.craftedCombatMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionGoldMult
|
||||
= companionBonus?.type === "passiveGold"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionEssenceMult
|
||||
= companionBonus?.type === "essenceIncome"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionCombatMult
|
||||
= companionBonus?.type === "bossDamage"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
const goldPerSecond
|
||||
= adventurer.goldPerSecond
|
||||
* upgradeMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesIncome
|
||||
* echoIncome
|
||||
* equipmentGoldMultiplier
|
||||
* setBonuses.goldMultiplier
|
||||
* craftedGoldMultiplier
|
||||
* companionGoldMult;
|
||||
|
||||
const essencePerSecond
|
||||
= adventurer.essencePerSecond
|
||||
* upgradeMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesEssence
|
||||
* craftedEssenceMultiplier
|
||||
* companionEssenceMult;
|
||||
|
||||
const combatPower
|
||||
= adventurer.combatPower
|
||||
* upgradeMultiplier
|
||||
* prestigeCombatMultiplier
|
||||
* equipmentCombatMultiplier
|
||||
* setBonuses.combatMultiplier
|
||||
* echoCombatMultiplier
|
||||
* craftedCombatMultiplier
|
||||
* companionCombatMult;
|
||||
|
||||
return { combatPower, essencePerSecond, goldPerSecond };
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the party's total combat power, applying all active multipliers
|
||||
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
|
||||
* This mirrors the server-side calculatePartyStats in boss.ts and is the
|
||||
* single source of truth for all combat-power checks in the client:
|
||||
* - Displayed as "Combat Power" in the resource bar
|
||||
* - Displayed as "Party DPS" in the boss panel
|
||||
* - Used to gate quest availability
|
||||
* Note: the active companion's bossDamage bonus is intentionally included
|
||||
* here, as it applies to the full combat power calculation (boss fights and
|
||||
* quest gating alike), matching the server-side behaviour.
|
||||
* @param state - The current game state.
|
||||
* @returns The total party combat power.
|
||||
*/
|
||||
export const computePartyCombatPower = (state: GameState): number => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
|
||||
|
||||
const equipmentCombatMultiplier = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
const equippedItemIds = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
|
||||
equippedItemIds,
|
||||
EQUIPMENT_SETS,
|
||||
);
|
||||
|
||||
const echoCombatMultiplier
|
||||
= state.transcendence?.echoCombatMultiplier ?? 1;
|
||||
const craftedCombatMultiplier
|
||||
= state.exploration?.craftedCombatMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionCombatMult
|
||||
= companionBonus?.type === "bossDamage"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
let partyCombatPower = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const contribution
|
||||
= adventurer.combatPower
|
||||
* adventurer.count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
|
||||
return partyCombatPower
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* echoCombatMultiplier
|
||||
* craftedCombatMultiplier
|
||||
* companionCombatMult;
|
||||
};
|
||||
|
||||
const basePrestigeThreshold = 1_000_000;
|
||||
const runestonesPerPrestigeLevelClient = 15;
|
||||
const maxBaseRunestones = 200;
|
||||
|
||||
/**
|
||||
* Computes the projected runestone reward if the player were to prestige right now.
|
||||
* Mirrors the server-side calculateRunestones formula exactly.
|
||||
* @param state - The current game state.
|
||||
* @returns The number of runestones the player would earn from a prestige now.
|
||||
*/
|
||||
export const computeProjectedRunestones = (state: GameState): number => {
|
||||
const { count, purchasedUpgradeIds } = state.prestige;
|
||||
const threshold = basePrestigeThreshold * Math.pow(count + 1, 2);
|
||||
const base = Math.min(
|
||||
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevelClient,
|
||||
maxBaseRunestones,
|
||||
);
|
||||
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
|
||||
? 1.25
|
||||
: 1;
|
||||
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
|
||||
? 1.5
|
||||
: 1;
|
||||
const runestoneMult = gain1Mult * gain2Mult;
|
||||
const echoMult: number
|
||||
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
|
||||
return Math.floor(base * runestoneMult * echoMult);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — applies one game tick to the state.
|
||||
* DeltaSeconds: time elapsed since last tick.
|
||||
@@ -469,6 +754,19 @@ export const applyTick = (
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
// Auto-unlock adventurer-specific upgrades when their adventurer is recruited
|
||||
updatedUpgrades = updatedUpgrades.map((upgrade) => {
|
||||
if (upgrade.unlocked || upgrade.adventurerId === undefined) {
|
||||
return upgrade;
|
||||
}
|
||||
const adventurer = updatedAdventurers.find((a) => {
|
||||
return a.id === upgrade.adventurerId;
|
||||
});
|
||||
return adventurer !== undefined && adventurer.count > 0
|
||||
? { ...upgrade, unlocked: true }
|
||||
: upgrade;
|
||||
});
|
||||
|
||||
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||
const essenceValue = capResource(
|
||||
state.resources.essence + essenceGained + questEssence,
|
||||
@@ -489,6 +787,23 @@ export const applyTick = (
|
||||
...updatedDailyChallenges === undefined
|
||||
? {}
|
||||
: { dailyChallenges: updatedDailyChallenges },
|
||||
...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined
|
||||
? {}
|
||||
: {
|
||||
exploration: {
|
||||
...state.exploration,
|
||||
areas: state.exploration.areas.map((area) => {
|
||||
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
||||
return definition.id === area.id;
|
||||
});
|
||||
return areaDefinition !== undefined
|
||||
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
||||
&& area.status === "locked"
|
||||
? { ...area, status: "available" as const }
|
||||
: area;
|
||||
}),
|
||||
},
|
||||
},
|
||||
adventurers: updatedAdventurers,
|
||||
bosses: updatedBosses,
|
||||
equipment: updatedEquipmentReference,
|
||||
@@ -502,24 +817,30 @@ export const applyTick = (
|
||||
zones: updatedZones,
|
||||
};
|
||||
|
||||
// Check achievements and apply crystal rewards for newly unlocked ones
|
||||
// Check achievements and apply crystal and runestone rewards for newly unlocked ones
|
||||
const updatedAchievements = checkAchievements(partialState);
|
||||
const crystalsFromAchievements = updatedAchievements.reduce(
|
||||
(sum, achievement, index) => {
|
||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
return sum + (achievement.reward?.crystals ?? 0);
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
let crystalsFromAchievements = 0;
|
||||
let runestonesFromAchievements = 0;
|
||||
for (const [ index, achievement ] of updatedAchievements.entries()) {
|
||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
crystalsFromAchievements
|
||||
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
|
||||
runestonesFromAchievements
|
||||
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...partialState,
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
prestige: {
|
||||
...partialState.prestige,
|
||||
runestones:
|
||||
partialState.prestige.runestones + runestonesFromAchievements,
|
||||
},
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: capResource(
|
||||
partialState.resources.crystals + crystalsFromAchievements,
|
||||
|
||||
Reference in New Issue
Block a user