32 Commits

Author SHA1 Message Date
hikari 87686a310f feat: add opt-out toggle for prestige bot announcements
CI / Lint, Build & Test (pull_request) Failing after 1m2s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m9s
Adds enablePrestigeAnnouncements to ProfileSettings (defaults to true).
The prestige route now checks this setting before posting the Discord
webhook, and the edit profile modal exposes a toggle in the Sounds &
Notifications section so players can opt out.

Closes #169
2026-03-31 13:20:01 -07:00
hikari 19f5f5e54f feat: show projected runestone gain persistently in resource bar
Adds computeProjectedRunestones() to the shared tick engine using the
correct server-side formula (cbrt, (count+1)^2 threshold). The resource
bar now shows a persistent '+N On Prestige' row so players can always
see what they would earn. The prestige panel's own preview was also
fixed to use the shared helper, replacing a broken local calculation
that used sqrt and the wrong threshold formula.

Closes #168
2026-03-31 13:19:54 -07:00
hikari 7d1126e8ad fix: guarantee clicks challenge in daily set (#167)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m7s
CI / Lint, Build & Test (pull_request) Successful in 1m12s
Players blocked on zone progression had days where all three daily
challenges (bossesDefeated, questsCompleted, prestige) required
progression they couldn't make. Always including a clicks challenge
ensures at least one challenge is completable regardless of where
the player is in the game.
2026-03-31 12:48:34 -07:00
hikari ec0763819e balance: increase runestone yield 50% per prestige (#166)
Raise runestonesPerPrestigeLevel from 10 to 15. Early-game players
were earning only 10-20 runestones per prestige, making the upgrade
shop feel out of reach. This boost helps mid-game without affecting
the cap behaviour (cbrt formula still prevents AFK windfalls).
2026-03-31 12:47:48 -07:00
hikari 4a9ecbf706 balance: improve mid-game crystal income (#165)
Add 150 crystals to shadow_mere and 500 to witch_coven quest rewards.
Double shadow_marshes boss crystal drops (700->1500, 1500->3000, 3000->6000)
to provide meaningful crystal flow for players reaching Shadow Marshes.
2026-03-31 12:46:58 -07:00
hikari 96868c4143 balance: reduce shadow_mere CP requirement 5M -> 2M (#164)
The zone unlocks at 1.5M CP (storm_citadel), making the 5M CP entry
quest unreachable for most players. 2M CP is achievable with Arcane
Scholar and Dragon Rider adventurers without being trivial.
2026-03-31 12:45:57 -07:00
hikari 48477ee286 fix: eliminate loading screen flash after prestige (#163)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
CI / Lint, Build & Test (pull_request) Successful in 1m13s
Add reloadSilent which rehydrates state without toggling isLoading,
preventing the game from unmounting and showing the loading screen
after auto- or manual prestige.
2026-03-31 12:35:13 -07:00
hikari b3d257048f fix: prevent duplicate prestige bot announcements on concurrent requests
Adds an optimistic lock on the prestige route so that a second concurrent
request for the same state is rejected with 409 rather than firing the
Discord announcement twice. Also adds missing branch-coverage tests for
debug.ts to satisfy the 100% threshold.

Closes #162
2026-03-31 12:12:42 -07:00
hikari 3735cff23f fix: unlock exploration areas when their zone is unlocked by boss kill or quest (#161)
applyBossResult and the tick engine both updated zone status to "unlocked"
but never propagated that unlock to state.exploration.areas, leaving all
areas in the new zone permanently locked until force-unlock was used.
Both code paths now map over exploration areas and set any locked area
whose zone just became unlocked to "available" in the same state update.
2026-03-31 10:35:36 -07:00
hikari a09280470e fix: prevent auto-save race from discarding collected exploration materials (#160)
Block the auto-save tick while the /explore/collect request is in-flight,
clear the stale HMAC signature after the server-side DB write, and reset
the save timer so the next auto-save fires after React has re-rendered with
the new materials in stateReference — eliminating the window where a stale
client snapshot could overwrite the server's freshly saved collect result.
2026-03-31 10:35:29 -07:00
hikari 48120e0789 fix: pull adventurer upgrade rewards forward to their relevant progression window
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m13s
CI / Lint, Build & Test (pull_request) Failing after 1m13s
Ten upgrades were dropping 1-2 zones after the adventurer they buff was
no longer meaningful. Moved apprentice_1 to goblin_camp, militia_1 to
haunted_mine, knight_1 to frozen_wastes, peasant_2 to glacier_tomb,
peasant_3 to shadow_mere, and pulled the T27-30 upgrades
(astral_sovereign_1, primordial_mage_1, reality_warden_1,
infinity_ranger_1) and cosmos_knight_1 into their adventurer's own zone.
2026-03-26 15:43:13 -07:00
hikari 0542402b4d fix: use computePartyCombatPower in quest panel for consistent CP display
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m5s
CI / Lint, Build & Test (pull_request) Failing after 1m10s
The quest panel was computing party combat power with a simplified
hand-rolled loop (base combatPower × count only) that did not account for
upgrade multipliers, prestige bonus, equipment set bonuses, echo or
crafted multipliers, or the active companion bonus.

This meant the displayed "you have X combat power" value diverged from
the value used by the auto-quest engine (computePartyCombatPower), which
could show the player an incorrect picture of whether a quest was
startable — particularly after upgrades or equipment began boosting
combat power.

Replacing the loop with computePartyCombatPower(state) makes the quest
card display fully consistent with the auto-quest eligibility check.

Closes #157
2026-03-26 10:25:06 -07:00
hikari 689133d05d fix: preserve autoAdventurer setting across prestige
The auto-buy adventurers toggle was silently reset to false on every
prestige because it was not included in the list of automation preferences
carried forward into the fresh state. This mirrors the existing handling
for autoBoss and autoQuest.

Closes #156
2026-03-26 10:24:53 -07:00
hikari 8a332dc9ce fix: show effective post-multiplier stats on adventurer cards (#154)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m2s
CI / Lint, Build & Test (pull_request) Failing after 1m8s
Adds computeEffectiveAdventurerStats to tick.ts to calculate per-unit
gold/s, essence/s, and combat power with all active multipliers applied
(upgrades, prestige, equipment, echo, crafted, companions). Updates
AdventurerCard to display these effective values so players can see the
true contribution of each adventurer rather than raw base stats.
2026-03-25 17:13:00 -07:00
hikari 56d963dc90 fix: clarify combat power vs boss damage distinction (#153)
Expands the JSDoc on computePartyCombatPower to explicitly document
that the companion bossDamage multiplier is intentionally included in
all combat-power calculations (boss panel, resource bar, quest gating),
matching server-side behaviour and resolving labelling ambiguity.
2026-03-25 17:07:13 -07:00
hikari 77c7ee02a6 fix: assign upgrade rewards to late-game bosses (#140)
Distributes the nine unassigned adventurer-specific upgrade rewards
across Crystalline Spire through Eternal Throne bosses that previously
had empty upgradeRewards arrays, ensuring all adventurer upgrades are
obtainable via boss drops.
2026-03-25 17:05:56 -07:00
hikari d1559c327f fix: balance equipment, click_power recipe ceiling, adventurer cost curve
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m5s
CI / Lint, Build & Test (pull_request) Failing after 1m10s
- #141: Already resolved in prior commits (celestial_focus 4.25×,
  void_conduit 10.5×, crystal_matrix 7.5× all exceed free-drop tier)
- #142: Add primal_omega_lens cross-zone click_power recipe at 1.38×,
  matching the eternal_omega combat ceiling and closing the gap above
  the zone-17 cap of 1.25×
- #143: Already resolved in prior commits (elder_bark_shield 1.12×,
  void_fragment_amulet 1.15×, soul_bound_catalyst 1.20× all buffed)
- #144: Raise philosophers_stone click 2.25×→2.5× to differentiate from
  eternal_flame; raise crystal_shard click 1.55×→1.65× so the
  volcanic_forger set trinket beats void_compass (1.6×)
- #145: Militia goldPerSecond already fixed; raise celestial_guard
  baseCost 1.4T→1.8T, smoothing tier 14→15 from 4.67× to 6× and
  removing the jarring tier 15→16 wall (7.14×→5.56×)
2026-03-25 16:54:53 -07:00
hikari 4c297f1ce1 fix: resolve sync inflation, signature mismatch, CP accuracy, auto-buy cap, unlock hints
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint, Build & Test (pull_request) Failing after 1m8s
- #147: Guard all patch functions with hasChanged before incrementing
  sync counter to prevent inflation on no-op patches
- #148: Clear stale HMAC signature after each boss fight so subsequent
  auto-saves do not send a mismatched signature
- #146: Auto-unlock adventurer-specific upgrades in applyTick when
  their adventurer count > 0; show recruit hint in upgrade panel
- #149: Add Essence/s row to resource bar dropdown
- #150: Fix broken auto-quest CP reduce formula; centralise via
  computePartyCombatPower which applies all multipliers correctly
- #151: Cap auto-buy at 100 for non-max-tier adventurers; max tier
  (highest level unlocked) remains uncapped
- #152: Export computePartyCombatPower from tick, applying global
  upgrades, prestige, equipment, set bonuses, echo, crafted, and
  companion multipliers; use it in resource bar and boss panel
2026-03-25 16:47:53 -07:00
hikari b6e218167d fix: differentiate philosophers_stone and buff crystal_shard
CI / Lint, Build & Test (pull_request) Successful in 1m17s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m18s
- philosophers_stone: gold 1.25x → 1.4x (income specialist, distinct
  from eternal_flame which has combat 1.1x + gold 1.25x)
- crystal_shard: gold 1.10x → 1.20x (zone-5 epic, better premium)
- Closes #144
2026-03-25 15:29:42 -07:00
hikari 0609cc7584 fix: buff rare-material crafting recipes to justify ingredient cost
- elder_bark_shield: combat 1.08x → 1.12x
- void_fragment_amulet: gold 1.10x → 1.15x
- soul_bound_catalyst: essence 1.15x → 1.20x
- Closes #143
2026-03-25 14:44:59 -07:00
hikari 7c390f45b5 fix: add zone-18 click_power recipe, raising ceiling to 1.28x
- Added absolute_focus (click 1.28x) to the_absolute zone
- Matches zone-18 pattern, filling gap left by existing gold/combat recipes
- Closes #142
2026-03-25 14:37:11 -07:00
hikari 7ecc655484 fix: buff purchasable equipment dominated by boss drops
- celestial_focus: click 3x → 4.25x (above free void_heart_gem)
- void_conduit: combat 7x → 10.5x (above free throne_blade)
- crystal_matrix: gold 4.75x → 7.5x (above free eternal_armour)
- Closes #141
2026-03-25 14:35:51 -07:00
hikari 4b3a856ef9 fix: smooth adventurer cost curve
- militia: GPS 0.5 → 0.7 to match 10x cost jump
- Tiers 11-14: costs raised to even ~4.7x spread through tier 15
- Closes #145
2026-03-25 14:25:34 -07:00
hikari d84725921a fix: restore upgrade drops to late-game bosses
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m5s
CI / Lint, Build & Test (pull_request) Successful in 1m10s
Assigned 3 previously orphaned adventurer upgrades to appropriate bosses:
- horizon_beast (Infinite Expanse) → oblivion_paladin_1
- maelstrom_god (Cosmic Maelstrom) → transcendent_rogue_1
- eternal_end (The Absolute) → omniversal_champion_1

Closes #140
2026-03-25 14:06:01 -07:00
hikari e4808680ed feat: add missing quests to Frozen Peaks zone
- Added glacier_tomb (200K combat, 2.5hr) between frozen_wastes and ice_caves
- Added frozen_throne (3M combat, 7hr) after storm_citadel
- Updated ice_caves prerequisite to chain from glacier_tomb
- Frozen Peaks now has 5 quests, in line with other zones

Closes #139
2026-03-25 14:02:16 -07:00
hikari f001acc382 fix: buff Astral Void quest rewards
- void_rift: zero gold → 2B gold + 300K essence + 1K crystals
- star_graveyard: 1B gold + 100K essence → 8B gold + 800K essence + 3K crystals
- between_worlds: zero gold + 250K essence → 25B gold + 2M essence + 8K crystals
- the_end: 10B gold + 1M essence → 80B gold + 5M essence + 20K crystals

Closes #137
2026-03-25 14:00:33 -07:00
hikari 8a38d02e69 fix: buff Shadow Marshes quest rewards
- shadow_mere: 150 essence → 5M gold + 5K essence
- witch_coven: 500 essence → 20M gold + 20K essence
- plague_ruins: 8M gold + 2K essence → 100M gold + 30K essence + 500 crystals

Closes #136
2026-03-25 13:58:54 -07:00
hikari eed61db410 fix: add dark_templar_1 upgrade reward to Void Titan boss
Closes #138
2026-03-25 13:56:44 -07:00
hikari 0ae6aa12b2 fix: rewrite prestige/transcendence formula and rebalance progression
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m8s
CI / Lint, Build & Test (pull_request) Successful in 1m11s
2026-03-24 20:44:25 -07:00
hikari 0d6d05e50b chore: raise runestone base cap to 200
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
CI / Lint, Build & Test (pull_request) Successful in 1m10s
2026-03-24 20:08:53 -07:00
hikari 74dd3bf463 chore: raise runestone base cap to 100
CI / Lint, Build & Test (pull_request) Successful in 1m12s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m12s
2026-03-24 20:03:17 -07:00
hikari 959b86fa8b fix: apply cbrt and cap to runestone formula to prevent AFK windfalls
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint, Build & Test (pull_request) Successful in 1m10s
2026-03-24 20:01:22 -07:00
25 changed files with 741 additions and 238 deletions
+6 -6
View File
@@ -26,7 +26,7 @@ export const defaultAdventurers: Array<Adventurer> = [
combatPower: 3, combatPower: 3,
count: 0, count: 0,
essencePerSecond: 0, essencePerSecond: 0,
goldPerSecond: 0.5, goldPerSecond: 0.7,
id: "militia", id: "militia",
level: 2, level: 2,
name: "Militia", name: "Militia",
@@ -129,7 +129,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 2_600_000_000, baseCost: 2_850_000_000,
class: "mage", class: "mage",
combatPower: 13_000, combatPower: 13_000,
count: 0, count: 0,
@@ -141,7 +141,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 11_000_000_000, baseCost: 13_500_000_000,
class: "rogue", class: "rogue",
combatPower: 28_000, combatPower: 28_000,
count: 0, count: 0,
@@ -153,7 +153,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 47_000_000_000, baseCost: 64_000_000_000,
class: "paladin", class: "paladin",
combatPower: 60_000, combatPower: 60_000,
count: 0, count: 0,
@@ -165,7 +165,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 200_000_000_000, baseCost: 300_000_000_000,
class: "rogue", class: "rogue",
combatPower: 130_000, combatPower: 130_000,
count: 0, count: 0,
@@ -177,7 +177,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 1_400_000_000_000, baseCost: 1_800_000_000_000,
class: "paladin", class: "paladin",
combatPower: 400_000, combatPower: 400_000,
count: 0, count: 0,
+70 -70
View File
@@ -122,7 +122,7 @@ export const defaultBosses: Array<Boss> = [
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
bountyRunestones: 20, bountyRunestones: 20,
crystalReward: 700, crystalReward: 1500,
currentHp: 6_000_000, currentHp: 6_000_000,
damagePerSecond: 1200, damagePerSecond: 1200,
description: description:
@@ -140,7 +140,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 25, bountyRunestones: 25,
crystalReward: 1500, crystalReward: 3000,
currentHp: 12_000_000, currentHp: 12_000_000,
damagePerSecond: 2400, damagePerSecond: 2400,
description: description:
@@ -158,7 +158,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 30, bountyRunestones: 30,
crystalReward: 3000, crystalReward: 6000,
currentHp: 20_000_000, currentHp: 20_000_000,
damagePerSecond: 4000, damagePerSecond: 4000,
description: description:
@@ -226,7 +226,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Titan", name: "The Void Titan",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "dark_templar_1" ],
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
}, },
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
@@ -353,7 +353,7 @@ export const defaultBosses: Array<Boss> = [
id: "seraph_guardian", id: "seraph_guardian",
maxHp: 500_000_000, maxHp: 500_000_000,
name: "The Seraph Guardian", name: "The Seraph Guardian",
prestigeRequirement: 6, prestigeRequirement: 1,
status: "locked", status: "locked",
upgradeRewards: [ "click_4" ], upgradeRewards: [ "click_4" ],
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -371,7 +371,7 @@ export const defaultBosses: Array<Boss> = [
id: "fallen_archangel", id: "fallen_archangel",
maxHp: 2_000_000_000, maxHp: 2_000_000_000,
name: "The Fallen Archangel", name: "The Fallen Archangel",
prestigeRequirement: 7, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -389,7 +389,7 @@ export const defaultBosses: Array<Boss> = [
id: "divine_judge", id: "divine_judge",
maxHp: 8_000_000_000, maxHp: 8_000_000_000,
name: "The Divine Judge", name: "The Divine Judge",
prestigeRequirement: 8, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [ "divine_covenant" ], upgradeRewards: [ "divine_covenant" ],
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -407,7 +407,7 @@ export const defaultBosses: Array<Boss> = [
id: "celestial_titan", id: "celestial_titan",
maxHp: 30_000_000_000, maxHp: 30_000_000_000,
name: "The Celestial Titan", name: "The Celestial Titan",
prestigeRequirement: 9, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -425,7 +425,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_first_light", id: "the_first_light",
maxHp: 100_000_000_000, maxHp: 100_000_000_000,
name: "The First Light", name: "The First Light",
prestigeRequirement: 10, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -444,7 +444,7 @@ export const defaultBosses: Array<Boss> = [
id: "depth_leviathan", id: "depth_leviathan",
maxHp: 250_000_000_000, maxHp: 250_000_000_000,
name: "The Depth Leviathan", name: "The Depth Leviathan",
prestigeRequirement: 9, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -462,7 +462,7 @@ export const defaultBosses: Array<Boss> = [
id: "kraken_elder", id: "kraken_elder",
maxHp: 1_000_000_000_000, maxHp: 1_000_000_000_000,
name: "The Elder Kraken", name: "The Elder Kraken",
prestigeRequirement: 10, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [ "abyssal_pact" ], upgradeRewards: [ "abyssal_pact" ],
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -480,7 +480,7 @@ export const defaultBosses: Array<Boss> = [
id: "abyssal_colossus", id: "abyssal_colossus",
maxHp: 4_000_000_000_000, maxHp: 4_000_000_000_000,
name: "The Abyssal Colossus", name: "The Abyssal Colossus",
prestigeRequirement: 11, prestigeRequirement: 2,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -498,7 +498,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_deep_one", id: "the_deep_one",
maxHp: 15_000_000_000_000, maxHp: 15_000_000_000_000,
name: "The Deep One", name: "The Deep One",
prestigeRequirement: 12, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [ "global_4" ], upgradeRewards: [ "global_4" ],
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -516,7 +516,7 @@ export const defaultBosses: Array<Boss> = [
id: "elder_abomination", id: "elder_abomination",
maxHp: 50_000_000_000_000, maxHp: 50_000_000_000_000,
name: "The Elder Abomination", name: "The Elder Abomination",
prestigeRequirement: 13, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -535,7 +535,7 @@ export const defaultBosses: Array<Boss> = [
id: "demon_prince", id: "demon_prince",
maxHp: 120_000_000_000_000, maxHp: 120_000_000_000_000,
name: "The Demon Prince", name: "The Demon Prince",
prestigeRequirement: 12, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -553,7 +553,7 @@ export const defaultBosses: Array<Boss> = [
id: "hellfire_titan", id: "hellfire_titan",
maxHp: 500_000_000_000_000, maxHp: 500_000_000_000_000,
name: "The Hellfire Titan", name: "The Hellfire Titan",
prestigeRequirement: 13, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [ "celestial_mandate" ], upgradeRewards: [ "celestial_mandate" ],
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -571,7 +571,7 @@ export const defaultBosses: Array<Boss> = [
id: "lord_of_sin", id: "lord_of_sin",
maxHp: 2_000_000_000_000_000, maxHp: 2_000_000_000_000_000,
name: "The Lord of Sin", name: "The Lord of Sin",
prestigeRequirement: 14, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -589,7 +589,7 @@ export const defaultBosses: Array<Boss> = [
id: "infernal_sovereign", id: "infernal_sovereign",
maxHp: 6_000_000_000_000_000, maxHp: 6_000_000_000_000_000,
name: "The Infernal Sovereign", name: "The Infernal Sovereign",
prestigeRequirement: 15, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [ "click_5" ], upgradeRewards: [ "click_5" ],
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -607,7 +607,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_fallen", id: "the_fallen",
maxHp: 8_000_000_000_000_000, maxHp: 8_000_000_000_000_000,
name: "The Fallen", name: "The Fallen",
prestigeRequirement: 16, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -626,9 +626,9 @@ export const defaultBosses: Array<Boss> = [
id: "prism_golem", id: "prism_golem",
maxHp: 2e16, maxHp: 2e16,
name: "The Prism Golem", name: "The Prism Golem",
prestigeRequirement: 15, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "crystal_sage_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
@@ -644,7 +644,7 @@ export const defaultBosses: Array<Boss> = [
id: "crystal_drake", id: "crystal_drake",
maxHp: 8e16, maxHp: 8e16,
name: "The Crystal Drake", name: "The Crystal Drake",
prestigeRequirement: 16, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [ "void_ascendancy" ], upgradeRewards: [ "void_ascendancy" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -662,9 +662,9 @@ export const defaultBosses: Array<Boss> = [
id: "the_faceted", id: "the_faceted",
maxHp: 3e17, maxHp: 3e17,
name: "The Faceted", name: "The Faceted",
prestigeRequirement: 17, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "void_sentinel_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
@@ -680,9 +680,9 @@ export const defaultBosses: Array<Boss> = [
id: "diamond_colossus", id: "diamond_colossus",
maxHp: 1e18, maxHp: 1e18,
name: "The Diamond Colossus", name: "The Diamond Colossus",
prestigeRequirement: 18, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "eternal_champion_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
@@ -698,9 +698,9 @@ export const defaultBosses: Array<Boss> = [
id: "crystal_sovereign", id: "crystal_sovereign",
maxHp: 4e18, maxHp: 4e18,
name: "The Crystal Sovereign", name: "The Crystal Sovereign",
prestigeRequirement: 19, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "cosmos_knight_1" ],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
// ── Void Sanctum ────────────────────────────────────────────────────────── // ── Void Sanctum ──────────────────────────────────────────────────────────
@@ -717,9 +717,9 @@ export const defaultBosses: Array<Boss> = [
id: "void_herald", id: "void_herald",
maxHp: 1e19, maxHp: 1e19,
name: "The Void Herald", name: "The Void Herald",
prestigeRequirement: 18, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "seraph_knight_1" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
@@ -735,7 +735,7 @@ export const defaultBosses: Array<Boss> = [
id: "eternal_shade", id: "eternal_shade",
maxHp: 5e19, maxHp: 5e19,
name: "The Eternal Shade", name: "The Eternal Shade",
prestigeRequirement: 19, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [ "divine_harmony" ], upgradeRewards: [ "divine_harmony" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -753,9 +753,9 @@ export const defaultBosses: Array<Boss> = [
id: "the_unmaker", id: "the_unmaker",
maxHp: 2e20, maxHp: 2e20,
name: "The Unmaker", name: "The Unmaker",
prestigeRequirement: 20, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "abyss_diver_1" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
@@ -771,7 +771,7 @@ export const defaultBosses: Array<Boss> = [
id: "void_progenitor", id: "void_progenitor",
maxHp: 8e20, maxHp: 8e20,
name: "The Void Progenitor", name: "The Void Progenitor",
prestigeRequirement: 21, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -789,9 +789,9 @@ export const defaultBosses: Array<Boss> = [
id: "void_emperor", id: "void_emperor",
maxHp: 3e21, maxHp: 3e21,
name: "The Void Emperor", name: "The Void Emperor",
prestigeRequirement: 22, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "infernal_warden_1" ],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
// ── Eternal Throne ──────────────────────────────────────────────────────── // ── Eternal Throne ────────────────────────────────────────────────────────
@@ -808,9 +808,9 @@ export const defaultBosses: Array<Boss> = [
id: "throne_warden", id: "throne_warden",
maxHp: 1e22, maxHp: 1e22,
name: "The Throne Warden", name: "The Throne Warden",
prestigeRequirement: 21, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "infinity_ranger_1" ],
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
@@ -826,7 +826,7 @@ export const defaultBosses: Array<Boss> = [
id: "eternal_knight", id: "eternal_knight",
maxHp: 5e22, maxHp: 5e22,
name: "The Eternal Knight", name: "The Eternal Knight",
prestigeRequirement: 22, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [ "infernal_fury" ], upgradeRewards: [ "infernal_fury" ],
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -844,9 +844,9 @@ export const defaultBosses: Array<Boss> = [
id: "the_undying", id: "the_undying",
maxHp: 2e23, maxHp: 2e23,
name: "The Undying", name: "The Undying",
prestigeRequirement: 23, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "reality_warden_1" ],
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
@@ -862,7 +862,7 @@ export const defaultBosses: Array<Boss> = [
id: "apex_sovereign", id: "apex_sovereign",
maxHp: 8e23, maxHp: 8e23,
name: "The Apex Sovereign", name: "The Apex Sovereign",
prestigeRequirement: 24, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -880,7 +880,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_apex", id: "the_apex",
maxHp: 3e24, maxHp: 3e24,
name: "The Apex", name: "The Apex",
prestigeRequirement: 25, prestigeRequirement: 6,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -899,7 +899,7 @@ export const defaultBosses: Array<Boss> = [
id: "chaos_wyrm", id: "chaos_wyrm",
maxHp: 1e26, maxHp: 1e26,
name: "The Chaos Wyrm", name: "The Chaos Wyrm",
prestigeRequirement: 26, prestigeRequirement: 6,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -917,7 +917,7 @@ export const defaultBosses: Array<Boss> = [
id: "creation_engine", id: "creation_engine",
maxHp: 5e27, maxHp: 5e27,
name: "The Creation Engine", name: "The Creation Engine",
prestigeRequirement: 27, prestigeRequirement: 6,
status: "locked", status: "locked",
upgradeRewards: [ "aether_weaver_1" ], upgradeRewards: [ "aether_weaver_1" ],
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -935,7 +935,7 @@ export const defaultBosses: Array<Boss> = [
id: "entropy_avatar", id: "entropy_avatar",
maxHp: 2e29, maxHp: 2e29,
name: "The Entropy Avatar", name: "The Entropy Avatar",
prestigeRequirement: 29, prestigeRequirement: 7,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -953,7 +953,7 @@ export const defaultBosses: Array<Boss> = [
id: "primordial_titan", id: "primordial_titan",
maxHp: 8e30, maxHp: 8e30,
name: "The Primordial Titan", name: "The Primordial Titan",
prestigeRequirement: 31, prestigeRequirement: 7,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -972,7 +972,7 @@ export const defaultBosses: Array<Boss> = [
id: "expanse_drifter", id: "expanse_drifter",
maxHp: 3e33, maxHp: 3e33,
name: "The Expanse Drifter", name: "The Expanse Drifter",
prestigeRequirement: 33, prestigeRequirement: 8,
status: "locked", status: "locked",
upgradeRewards: [ "titan_warrior_1" ], upgradeRewards: [ "titan_warrior_1" ],
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -990,9 +990,9 @@ export const defaultBosses: Array<Boss> = [
id: "horizon_beast", id: "horizon_beast",
maxHp: 1e37, maxHp: 1e37,
name: "The Horizon Beast", name: "The Horizon Beast",
prestigeRequirement: 35, prestigeRequirement: 8,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "oblivion_paladin_1" ],
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
@@ -1008,7 +1008,7 @@ export const defaultBosses: Array<Boss> = [
id: "infinity_construct", id: "infinity_construct",
maxHp: 5e40, maxHp: 5e40,
name: "The Infinity Construct", name: "The Infinity Construct",
prestigeRequirement: 37, prestigeRequirement: 8,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -1026,7 +1026,7 @@ export const defaultBosses: Array<Boss> = [
id: "expanse_sovereign", id: "expanse_sovereign",
maxHp: 2e44, maxHp: 2e44,
name: "The Expanse Sovereign", name: "The Expanse Sovereign",
prestigeRequirement: 39, prestigeRequirement: 9,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -1045,7 +1045,7 @@ export const defaultBosses: Array<Boss> = [
id: "forge_guardian", id: "forge_guardian",
maxHp: 8e47, maxHp: 8e47,
name: "The Forge Guardian", name: "The Forge Guardian",
prestigeRequirement: 41, prestigeRequirement: 9,
status: "locked", status: "locked",
upgradeRewards: [ "nexus_sage_1" ], upgradeRewards: [ "nexus_sage_1" ],
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -1063,7 +1063,7 @@ export const defaultBosses: Array<Boss> = [
id: "reality_shaper", id: "reality_shaper",
maxHp: 4e52, maxHp: 4e52,
name: "The Reality Shaper", name: "The Reality Shaper",
prestigeRequirement: 44, prestigeRequirement: 10,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -1081,7 +1081,7 @@ export const defaultBosses: Array<Boss> = [
id: "creation_prime", id: "creation_prime",
maxHp: 2e57, maxHp: 2e57,
name: "The Creation Prime", name: "The Creation Prime",
prestigeRequirement: 47, prestigeRequirement: 11,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -1099,7 +1099,7 @@ export const defaultBosses: Array<Boss> = [
id: "reality_architect", id: "reality_architect",
maxHp: 8e61, maxHp: 8e61,
name: "The Reality Architect", name: "The Reality Architect",
prestigeRequirement: 49, prestigeRequirement: 11,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -1118,7 +1118,7 @@ export const defaultBosses: Array<Boss> = [
id: "storm_colossus", id: "storm_colossus",
maxHp: 4e65, maxHp: 4e65,
name: "The Storm Colossus", name: "The Storm Colossus",
prestigeRequirement: 51, prestigeRequirement: 12,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -1136,7 +1136,7 @@ export const defaultBosses: Array<Boss> = [
id: "force_prime", id: "force_prime",
maxHp: 2e71, maxHp: 2e71,
name: "The Force Prime", name: "The Force Prime",
prestigeRequirement: 54, prestigeRequirement: 12,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -1154,9 +1154,9 @@ export const defaultBosses: Array<Boss> = [
id: "maelstrom_god", id: "maelstrom_god",
maxHp: 1e77, maxHp: 1e77,
name: "The Maelstrom God", name: "The Maelstrom God",
prestigeRequirement: 57, prestigeRequirement: 13,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "transcendent_rogue_1" ],
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
@@ -1172,7 +1172,7 @@ export const defaultBosses: Array<Boss> = [
id: "cosmic_annihilator", id: "cosmic_annihilator",
maxHp: 5e82, maxHp: 5e82,
name: "The Cosmic Annihilator", name: "The Cosmic Annihilator",
prestigeRequirement: 59, prestigeRequirement: 13,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -1191,7 +1191,7 @@ export const defaultBosses: Array<Boss> = [
id: "ancient_sentinel", id: "ancient_sentinel",
maxHp: 2e88, maxHp: 2e88,
name: "The Ancient Sentinel", name: "The Ancient Sentinel",
prestigeRequirement: 61, prestigeRequirement: 14,
status: "locked", status: "locked",
upgradeRewards: [ "astral_sovereign_1" ], upgradeRewards: [ "astral_sovereign_1" ],
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -1209,7 +1209,7 @@ export const defaultBosses: Array<Boss> = [
id: "time_elder", id: "time_elder",
maxHp: 1e95, maxHp: 1e95,
name: "The Time Elder", name: "The Time Elder",
prestigeRequirement: 65, prestigeRequirement: 15,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -1227,7 +1227,7 @@ export const defaultBosses: Array<Boss> = [
id: "origin_beast", id: "origin_beast",
maxHp: 8e101, maxHp: 8e101,
name: "The Origin Beast", name: "The Origin Beast",
prestigeRequirement: 69, prestigeRequirement: 16,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -1245,7 +1245,7 @@ export const defaultBosses: Array<Boss> = [
id: "primeval_god", id: "primeval_god",
maxHp: 5e108, maxHp: 5e108,
name: "The Primeval God", name: "The Primeval God",
prestigeRequirement: 74, prestigeRequirement: 17,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -1264,7 +1264,7 @@ export const defaultBosses: Array<Boss> = [
id: "absolute_herald", id: "absolute_herald",
maxHp: 2e116, maxHp: 2e116,
name: "The Absolute Herald", name: "The Absolute Herald",
prestigeRequirement: 76, prestigeRequirement: 17,
status: "locked", status: "locked",
upgradeRewards: [ "primordial_mage_1" ], upgradeRewards: [ "primordial_mage_1" ],
zoneId: "the_absolute", zoneId: "the_absolute",
@@ -1282,7 +1282,7 @@ export const defaultBosses: Array<Boss> = [
id: "void_convergence", id: "void_convergence",
maxHp: 1e125, maxHp: 1e125,
name: "The Void Convergence", name: "The Void Convergence",
prestigeRequirement: 79, prestigeRequirement: 18,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
@@ -1300,9 +1300,9 @@ export const defaultBosses: Array<Boss> = [
id: "eternal_end", id: "eternal_end",
maxHp: 5e134, maxHp: 5e134,
name: "The Eternal End", name: "The Eternal End",
prestigeRequirement: 83, prestigeRequirement: 19,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [ "omniversal_champion_1" ],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_absolute_one", id: "the_absolute_one",
maxHp: 2e145, maxHp: 2e145,
name: "The Absolute One", name: "The Absolute One",
prestigeRequirement: 88, prestigeRequirement: 20,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
+6 -6
View File
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 }, bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 },
description: description:
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
equipped: false, equipped: false,
@@ -305,9 +305,9 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 }, bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 },
description: description:
"The legendary stone that grants mastery over gold and combat alike.", "The legendary stone that transmutes effort into wealth — every action fills the coffers.",
equipped: false, equipped: false,
id: "philosophers_stone", id: "philosophers_stone",
name: "Philosopher's Stone", name: "Philosopher's Stone",
@@ -697,7 +697,7 @@ export const defaultEquipment: Array<Equipment> = [
}, },
// ── Purchasable endgame sinks ───────────────────────────────────────────── // ── Purchasable endgame sinks ─────────────────────────────────────────────
{ {
bonus: { clickMultiplier: 3 }, bonus: { clickMultiplier: 4.25 },
cost: { crystals: 0, essence: 20_000_000, gold: 0 }, cost: { crystals: 0, essence: 20_000_000, gold: 0 },
description: description:
"A lens of compressed celestial light that sharpens every strike with divine precision.", "A lens of compressed celestial light that sharpens every strike with divine precision.",
@@ -721,7 +721,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour", type: "armour",
}, },
{ {
bonus: { combatMultiplier: 7 }, bonus: { combatMultiplier: 10.5 },
cost: { crystals: 0, essence: 100_000_000, gold: 0 }, cost: { crystals: 0, essence: 100_000_000, gold: 0 },
description: description:
"A weapon that channels void energy — the absence of resistance makes every strike devastating.", "A weapon that channels void energy — the absence of resistance makes every strike devastating.",
@@ -745,7 +745,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { goldMultiplier: 4.75 }, bonus: { goldMultiplier: 7.5 },
cost: { crystals: 20_000_000, essence: 0, gold: 0 }, cost: { crystals: 20_000_000, essence: 0, gold: 0 },
description: description:
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.", "Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
+64 -27
View File
@@ -34,6 +34,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2000, type: "gold" }, { amount: 2000, type: "gold" },
{ amount: 5, type: "essence" }, { amount: 5, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" }, { targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" }, { targetId: "apprentice", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -50,6 +51,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 10, type: "crystals" }, { amount: 10, type: "crystals" },
{ targetId: "global_1", type: "upgrade" }, { targetId: "global_1", type: "upgrade" },
{ targetId: "militia_1", type: "upgrade" },
{ targetId: "scout", type: "adventurer" }, { targetId: "scout", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -82,7 +84,6 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 15_000, type: "gold" }, { amount: 15_000, type: "gold" },
{ amount: 20, type: "essence" }, { amount: 20, type: "essence" },
{ targetId: "militia_1", type: "upgrade" },
{ targetId: "acolyte_1", type: "upgrade" }, { targetId: "acolyte_1", type: "upgrade" },
{ targetId: "ranger", type: "adventurer" }, { targetId: "ranger", type: "adventurer" },
], ],
@@ -117,7 +118,6 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 300, type: "essence" }, { amount: 300, type: "essence" },
{ amount: 30, type: "crystals" }, { amount: 30, type: "crystals" },
{ targetId: "apprentice_1", type: "upgrade" },
{ targetId: "archmage", type: "adventurer" }, { targetId: "archmage", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -153,6 +153,23 @@ export const defaultQuests: Array<Quest> = [
{ amount: 5_000_000, type: "gold" }, { amount: 5_000_000, type: "gold" },
{ amount: 100, type: "crystals" }, { amount: 100, type: "crystals" },
{ targetId: "global_3", type: "upgrade" }, { targetId: "global_3", type: "upgrade" },
{ targetId: "knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "frozen_peaks",
},
{
combatPowerRequired: 200_000,
description:
"A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.",
durationSeconds: 150 * 60,
id: "glacier_tomb",
name: "The Glacier Tomb",
prerequisiteIds: [ "frozen_wastes" ],
rewards: [
{ amount: 10_000_000, type: "gold" },
{ amount: 3000, type: "essence" },
{ targetId: "peasant_2", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -164,7 +181,7 @@ export const defaultQuests: Array<Quest> = [
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
id: "ice_caves", id: "ice_caves",
name: "The Ice Caves", name: "The Ice Caves",
prerequisiteIds: [ "frozen_wastes" ], prerequisiteIds: [ "glacier_tomb" ],
rewards: [ rewards: [
{ amount: 5000, type: "essence" }, { amount: 5000, type: "essence" },
{ amount: 200, type: "crystals" }, { amount: 200, type: "crystals" },
@@ -188,9 +205,25 @@ export const defaultQuests: Array<Quest> = [
status: "locked", status: "locked",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
}, },
{
combatPowerRequired: 3_000_000,
description:
"Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.",
durationSeconds: 7 * 60 * 60,
id: "frozen_throne",
name: "The Frozen Throne",
prerequisiteIds: [ "storm_citadel" ],
rewards: [
{ amount: 60_000_000, type: "gold" },
{ amount: 25_000, type: "essence" },
{ amount: 400, type: "crystals" },
],
status: "locked",
zoneId: "frozen_peaks",
},
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 5_000_000, combatPowerRequired: 2_000_000,
description: description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60, durationSeconds: 45 * 60,
@@ -198,7 +231,10 @@ export const defaultQuests: Array<Quest> = [
name: "The Shadow Mere", name: "The Shadow Mere",
prerequisiteIds: [], prerequisiteIds: [],
rewards: [ rewards: [
{ amount: 150, type: "essence" }, { amount: 5_000_000, type: "gold" },
{ amount: 5000, type: "essence" },
{ amount: 150, type: "crystals" },
{ targetId: "peasant_3", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -212,7 +248,9 @@ export const defaultQuests: Array<Quest> = [
name: "The Witch Coven", name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ], prerequisiteIds: [ "shadow_mere" ],
rewards: [ rewards: [
{ amount: 500, type: "essence" }, { amount: 20_000_000, type: "gold" },
{ amount: 20_000, type: "essence" },
{ amount: 500, type: "crystals" },
{ targetId: "shadow_assassin", type: "adventurer" }, { targetId: "shadow_assassin", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -230,8 +268,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2_000_000, type: "gold" }, { amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" }, { amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" }, { amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
{ targetId: "peasant_2", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -245,9 +281,9 @@ export const defaultQuests: Array<Quest> = [
name: "The Plague Ruins", name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ], prerequisiteIds: [ "sunken_temple" ],
rewards: [ rewards: [
{ amount: 8_000_000, type: "gold" }, { amount: 100_000_000, type: "gold" },
{ amount: 2000, type: "essence" }, { amount: 30_000, type: "essence" },
{ amount: 150, type: "crystals" }, { amount: 500, type: "crystals" },
{ targetId: "dark_templar", type: "adventurer" }, { targetId: "dark_templar", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -282,7 +318,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 40_000_000, type: "gold" }, { amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" }, { amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" }, { amount: 300, type: "crystals" },
{ targetId: "peasant_3", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -329,8 +364,9 @@ export const defaultQuests: Array<Quest> = [
name: "Void Rift", name: "Void Rift",
prerequisiteIds: [], prerequisiteIds: [],
rewards: [ rewards: [
{ amount: 500, type: "crystals" }, { amount: 2_000_000_000, type: "gold" },
{ amount: 5000, type: "essence" }, { amount: 300_000, type: "essence" },
{ amount: 1000, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "astral_void", zoneId: "astral_void",
@@ -344,9 +380,9 @@ export const defaultQuests: Array<Quest> = [
name: "The Star Graveyard", name: "The Star Graveyard",
prerequisiteIds: [ "void_rift" ], prerequisiteIds: [ "void_rift" ],
rewards: [ rewards: [
{ amount: 1_000_000_000, type: "gold" }, { amount: 8_000_000_000, type: "gold" },
{ amount: 100_000, type: "essence" }, { amount: 800_000, type: "essence" },
{ amount: 1000, type: "crystals" }, { amount: 3000, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "astral_void", zoneId: "astral_void",
@@ -360,8 +396,9 @@ export const defaultQuests: Array<Quest> = [
name: "Between Worlds", name: "Between Worlds",
prerequisiteIds: [ "star_graveyard" ], prerequisiteIds: [ "star_graveyard" ],
rewards: [ rewards: [
{ amount: 250_000, type: "essence" }, { amount: 25_000_000_000, type: "gold" },
{ amount: 2000, type: "crystals" }, { amount: 2_000_000, type: "essence" },
{ amount: 8000, type: "crystals" },
{ targetId: "divine_champion", type: "adventurer" }, { targetId: "divine_champion", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -376,9 +413,9 @@ export const defaultQuests: Array<Quest> = [
name: "The End of All Things", name: "The End of All Things",
prerequisiteIds: [ "between_worlds" ], prerequisiteIds: [ "between_worlds" ],
rewards: [ rewards: [
{ amount: 10_000_000_000, type: "gold" }, { amount: 80_000_000_000, type: "gold" },
{ amount: 1_000_000, type: "essence" }, { amount: 5_000_000, type: "essence" },
{ amount: 10_000, type: "crystals" }, { amount: 20_000, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "astral_void", zoneId: "astral_void",
@@ -1148,6 +1185,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 8e39, type: "gold" }, { amount: 8e39, type: "gold" },
{ amount: 2.5e36, type: "essence" }, { amount: 2.5e36, type: "essence" },
{ amount: 5e32, type: "crystals" }, { amount: 5e32, type: "crystals" },
{ targetId: "cosmos_knight_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -1230,7 +1268,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2.5e49, type: "gold" }, { amount: 2.5e49, type: "gold" },
{ amount: 8e45, type: "essence" }, { amount: 8e45, type: "essence" },
{ amount: 5e41, type: "crystals" }, { amount: 5e41, type: "crystals" },
{ targetId: "cosmos_knight_1", type: "upgrade" }, { targetId: "primordial_mage_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -1263,6 +1301,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 6e52, type: "gold" }, { amount: 6e52, type: "gold" },
{ amount: 2e49, type: "essence" }, { amount: 2e49, type: "essence" },
{ amount: 1.2e45, type: "crystals" }, { amount: 1.2e45, type: "crystals" },
{ targetId: "astral_sovereign_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -1329,7 +1368,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 4e63, type: "gold" }, { amount: 4e63, type: "gold" },
{ amount: 1.2e60, type: "essence" }, { amount: 1.2e60, type: "essence" },
{ amount: 7e55, type: "crystals" }, { amount: 7e55, type: "crystals" },
{ targetId: "astral_sovereign_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -1346,6 +1384,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2e66, type: "gold" }, { amount: 2e66, type: "gold" },
{ amount: 6e62, type: "essence" }, { amount: 6e62, type: "essence" },
{ amount: 3.5e58, type: "crystals" }, { amount: 3.5e58, type: "crystals" },
{ targetId: "reality_warden_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -1362,6 +1401,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 1e69, type: "gold" }, { amount: 1e69, type: "gold" },
{ amount: 3e65, type: "essence" }, { amount: 3e65, type: "essence" },
{ amount: 1.8e61, type: "crystals" }, { amount: 1.8e61, type: "crystals" },
{ targetId: "infinity_ranger_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -1428,7 +1468,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 6e83, type: "gold" }, { amount: 6e83, type: "gold" },
{ amount: 1.8e80, type: "essence" }, { amount: 1.8e80, type: "essence" },
{ amount: 1e76, type: "crystals" }, { amount: 1e76, type: "crystals" },
{ targetId: "primordial_mage_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -1510,7 +1549,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2e108, type: "gold" }, { amount: 2e108, type: "gold" },
{ amount: 6e104, type: "essence" }, { amount: 6e104, type: "essence" },
{ amount: 3e100, type: "crystals" }, { amount: 3e100, type: "crystals" },
{ targetId: "reality_warden_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "the_absolute", zoneId: "the_absolute",
@@ -1543,7 +1581,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 5e121, type: "gold" }, { amount: 5e121, type: "gold" },
{ amount: 1.5e118, type: "essence" }, { amount: 1.5e118, type: "essence" },
{ amount: 7e113, type: "crystals" }, { amount: 7e113, type: "crystals" },
{ targetId: "infinity_ranger_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "the_absolute", zoneId: "the_absolute",
+28 -3
View File
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
{ {
bonus: { type: "combat_power", value: 1.08 }, bonus: { type: "combat_power", value: 1.12 },
description: description:
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.", "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", id: "elder_bark_shield",
@@ -75,7 +75,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
}, },
{ {
bonus: { type: "gold_income", value: 1.1 }, bonus: { type: "gold_income", value: 1.15 },
description: description:
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.", "The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
id: "void_fragment_amulet", id: "void_fragment_amulet",
@@ -231,7 +231,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
bonus: { type: "essence_income", value: 1.15 }, bonus: { type: "essence_income", value: 1.2 },
description: description:
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.", "Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
id: "soul_bound_catalyst", id: "soul_bound_catalyst",
@@ -492,6 +492,19 @@ export const defaultRecipes: Array<CraftingRecipe> = [
], ],
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{
bonus: { type: "click_power", value: 1.38 },
description:
"A primeval relic submerged at the absolute boundary of existence alongside omega crystals and boundary shards — the first and last thing, unified. Every action your guild takes through it is simultaneously the most ancient and most final thing that has ever happened. It does not miss.",
id: "primal_omega_lens",
name: "Primal Omega Lens",
requiredMaterials: [
{ materialId: "primeval_relic", quantity: 2 },
{ materialId: "boundary_shard", quantity: 4 },
{ materialId: "omega_crystal", quantity: 2 },
],
zoneId: "the_absolute",
},
{ {
bonus: { type: "combat_power", value: 1.4 }, bonus: { type: "combat_power", value: 1.4 },
description: description:
@@ -508,6 +521,18 @@ export const defaultRecipes: Array<CraftingRecipe> = [
}, },
// Zone 18: the_absolute // Zone 18: the_absolute
{
bonus: { type: "click_power", value: 1.28 },
description:
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
id: "absolute_focus",
name: "Absolute Focus",
requiredMaterials: [
{ materialId: "absolute_fragment", quantity: 8 },
{ materialId: "omega_crystal", quantity: 3 },
],
zoneId: "the_absolute",
},
{ {
bonus: { type: "gold_income", value: 1.3 }, bonus: { type: "gold_income", value: 1.3 },
description: description:
+15 -15
View File
@@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Income multipliers ────────────────────────────────────────────────────── // ── Income multipliers ──────────────────────────────────────────────────────
{ {
category: "income", category: "income",
cost: 5, cost: 2,
description: description:
"The echoes of past runs linger, amplifying your guild's income by 25%.", "The echoes of past runs linger, amplifying your guild's income by 25%.",
id: "echo_income_1", id: "echo_income_1",
@@ -20,7 +20,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 10, cost: 4,
description: description:
"Your transcendent experience resonates through your guild, boosting income by 50%.", "Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2", id: "echo_income_2",
@@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 20, cost: 8,
description: description:
"The harmony of multiple timelines surges through your guild, doubling its income.", "The harmony of multiple timelines surges through your guild, doubling its income.",
id: "echo_income_3", id: "echo_income_3",
@@ -38,7 +38,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 40, cost: 16,
description: description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.", "Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4", id: "echo_income_4",
@@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 80, cost: 32,
description: description:
"The infinite chorus of every run you've ever played amplifies your guild fivefold.", "The infinite chorus of every run you've ever played amplifies your guild fivefold.",
id: "echo_income_5", id: "echo_income_5",
@@ -58,7 +58,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Combat multipliers ────────────────────────────────────────────────────── // ── Combat multipliers ──────────────────────────────────────────────────────
{ {
category: "combat", category: "combat",
cost: 5, cost: 2,
description: description:
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.", "Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
id: "echo_combat_1", id: "echo_combat_1",
@@ -67,7 +67,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "combat", category: "combat",
cost: 15, cost: 6,
description: description:
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.", "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
id: "echo_combat_2", id: "echo_combat_2",
@@ -76,7 +76,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "combat", category: "combat",
cost: 35, cost: 12,
description: description:
"Your warriors carry the strength of every fallen timeline, doubling party DPS.", "Your warriors carry the strength of every fallen timeline, doubling party DPS.",
id: "echo_combat_3", id: "echo_combat_3",
@@ -87,7 +87,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Prestige threshold reductions ────────────────────────────────────────── // ── Prestige threshold reductions ──────────────────────────────────────────
{ {
category: "prestige_threshold", category: "prestige_threshold",
cost: 8, cost: 3,
description: description:
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.", "Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
id: "echo_prestige_threshold_1", id: "echo_prestige_threshold_1",
@@ -96,7 +96,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "prestige_threshold", category: "prestige_threshold",
cost: 20, cost: 6,
description: description:
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.", "You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
id: "echo_prestige_threshold_2", id: "echo_prestige_threshold_2",
@@ -107,7 +107,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Prestige runestone multipliers ───────────────────────────────────────── // ── Prestige runestone multipliers ─────────────────────────────────────────
{ {
category: "prestige_runestones", category: "prestige_runestones",
cost: 8, cost: 3,
description: description:
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.", "Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
id: "echo_prestige_runestones_1", id: "echo_prestige_runestones_1",
@@ -116,7 +116,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "prestige_runestones", category: "prestige_runestones",
cost: 20, cost: 6,
description: description:
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.", "You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
id: "echo_prestige_runestones_2", id: "echo_prestige_runestones_2",
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ─────────────────────────────────────────────────── // ── Echo meta multipliers ───────────────────────────────────────────────────
{ {
category: "echo_meta", category: "echo_meta",
cost: 50, cost: 25,
description: description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.", "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1", id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 150, cost: 75,
description: description:
"Each loop of existence makes the next more powerful — future echo yields +50%.", "Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2", id: "echo_meta_2",
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 400, cost: 200,
description: description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.", "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3", id: "echo_meta_3",
+26 -2
View File
@@ -102,12 +102,23 @@ prestigeRouter.post("/", async(context) => {
}).length; }).length;
const now = Date.now(); const now = Date.now();
await prisma.gameState.update({ const { updatedAt } = record;
/*
* Use the record's current updatedAt as an optimistic lock — if another
* concurrent prestige request already committed, this update will match
* 0 rows and we can safely reject the duplicate without a double webhook.
*/
const updateResult = await prisma.gameState.updateMany({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now }, data: { state: finalState as object, updatedAt: now },
where: { discordId }, where: { discordId, updatedAt },
}); });
if (updateResult.count === 0) {
return context.json({ error: "Prestige already in progress" }, 409);
}
await prisma.player.update({ await prisma.player.update({
data: { data: {
characterName: state.player.characterName, characterName: state.player.characterName,
@@ -136,6 +147,18 @@ prestigeRouter.post("/", async(context) => {
const prestigeCount = prestigeData.count; const prestigeCount = prestigeData.count;
void logger.metric("prestige", 1, { discordId, prestigeCount }); void logger.metric("prestige", 1, { discordId, prestigeCount });
const playerRecord = await prisma.player.findUnique({
select: { profileSettings: true },
where: { discordId },
});
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
const playerSettings = playerRecord?.profileSettings as
Record<string, unknown> | null | undefined;
const announcementsEnabled
= playerSettings?.enablePrestigeAnnouncements !== false;
if (announcementsEnabled) {
void postMilestoneWebhook(discordId, "prestige", { void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
@@ -147,6 +170,7 @@ prestigeRouter.post("/", async(context) => {
/* v8 ignore next 2 -- @preserve */ /* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0, transcendence: prestigeState.transcendence?.count ?? 0,
}); });
}
return context.json({ return context.json({
milestoneRunestones: milestoneRunestones, milestoneRunestones: milestoneRunestones,
+2
View File
@@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
: "suffix"; : "suffix";
return { return {
enableNotifications: rawObject.enableNotifications === true, enableNotifications: rawObject.enableNotifications === true,
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
enableSounds: rawObject.enableSounds === true, enableSounds: rawObject.enableSounds === true,
numberFormat: numberFormat, numberFormat: numberFormat,
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false, showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
@@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => {
: "suffix"; : "suffix";
const profileSettings: ProfileSettings = { const profileSettings: ProfileSettings = {
enableNotifications: body.profileSettings.enableNotifications ?? false, enableNotifications: body.profileSettings.enableNotifications ?? false,
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
enableSounds: body.profileSettings.enableSounds ?? false, enableSounds: body.profileSettings.enableSounds ?? false,
numberFormat: numberFormat, numberFormat: numberFormat,
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
+7 -5
View File
@@ -71,8 +71,7 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result; return result;
}; };
const challengeTypes: Array<DailyChallengeType> = [ const progressionChallengeTypes: Array<DailyChallengeType> = [
"clicks",
"bossesDefeated", "bossesDefeated",
"questsCompleted", "questsCompleted",
"prestige", "prestige",
@@ -80,7 +79,8 @@ const challengeTypes: Array<DailyChallengeType> = [
/** /**
* Generates 3 daily challenges for the given date string, deterministically. * Generates 3 daily challenges for the given date string, deterministically.
* Picks one challenge from 3 different randomly-selected types. * Always includes a "clicks" challenge (always completable regardless of
* progression), then picks 2 more from the remaining types.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for. * @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects. * @returns An array of 3 DailyChallenge objects.
*/ */
@@ -88,8 +88,10 @@ const generateDailyChallenges = (
dateString: string, dateString: string,
): Array<DailyChallenge> => { ): Array<DailyChallenge> => {
const seed = dateSeed(dateString); const seed = dateSeed(dateString);
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed). const selectedTypes: Array<DailyChallengeType> = [
slice(0, 3); "clicks",
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2),
];
return selectedTypes.map((type, index) => { return selectedTypes.map((type, index) => {
const templates = dailyChallengeTemplates.filter((template) => { const templates = dailyChallengeTemplates.filter((template) => {
+23 -10
View File
@@ -15,14 +15,21 @@ import type {
} from "@elysium/types"; } from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000; const basePrestigeGoldThreshold = 1_000_000;
const thresholdScaleFactor = 5; const runestonesPerPrestigeLevel = 15;
const runestonesPerPrestigeLevel = 10;
const milestoneInterval = 5; const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25; 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. * Calculates the gold threshold required for the next prestige.
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder. * Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 810
* then gets easier as the production multiplier overtakes it.
* @param prestigeCount - The current number of prestiges completed. * @param prestigeCount - The current number of prestiges completed.
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold. * @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
* @returns The gold amount required to prestige. * @returns The gold amount required to prestige.
@@ -33,7 +40,7 @@ const calculatePrestigeThreshold = (
): number => { ): number => {
return ( return (
basePrestigeGoldThreshold basePrestigeGoldThreshold
* Math.pow(thresholdScaleFactor, prestigeCount) * Math.pow(prestigeCount + 1, 2)
* thresholdMultiplier * thresholdMultiplier
); );
}; };
@@ -107,7 +114,9 @@ interface RunestoneParameters {
/** /**
* Calculates how many runestones the player earns from a prestige. * Calculates how many runestones the player earns from a prestige.
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier. * 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 - The parameters for the runestone calculation.
* @param parameters.totalGoldEarned - The total gold earned in the current run. * @param parameters.totalGoldEarned - The total gold earned in the current run.
* @param parameters.prestigeCount - The current prestige count. * @param parameters.prestigeCount - The current prestige count.
@@ -123,9 +132,11 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
echoRunestoneMultiplier = 1, echoRunestoneMultiplier = 1,
} = parameters; } = parameters;
const threshold = calculatePrestigeThreshold(prestigeCount); const threshold = calculatePrestigeThreshold(prestigeCount);
const base const base = Math.min(
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) Math.floor(Math.cbrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel; * runestonesPerPrestigeLevel,
maxBaseRunestones,
);
const runestoneMult = getCategoryMultiplier( const runestoneMult = getCategoryMultiplier(
purchasedUpgradeIds, purchasedUpgradeIds,
"runestones", "runestones",
@@ -135,14 +146,15 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
/** /**
* Calculates the new prestige production multiplier. * Calculates the new prestige production multiplier.
* Formula: 1.15^prestigeCount — exponential scaling per prestige. * Formula: 1.25^prestigeCount — exponential scaling per prestige that eventually
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
* @param prestigeCount - The new prestige count. * @param prestigeCount - The new prestige count.
* @returns The production multiplier for the new prestige level. * @returns The production multiplier for the new prestige level.
*/ */
const calculateProductionMultiplier = ( const calculateProductionMultiplier = (
prestigeCount: number, prestigeCount: number,
): number => { ): number => {
return Math.pow(1.15, prestigeCount); return Math.pow(1.25, prestigeCount);
}; };
/** /**
@@ -251,6 +263,7 @@ const buildPostPrestigeState = (
* Preserve automation preferences across prestige — the player explicitly * Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset. * opted into these settings and would not expect them to silently reset.
*/ */
autoAdventurer: currentState.autoAdventurer ?? false,
autoBoss: currentState.autoBoss ?? false, autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false, autoQuest: currentState.autoQuest ?? false,
+1 -1
View File
@@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one";
/** /**
* Base constant used in the echo yield formula. * Base constant used in the echo yield formula.
*/ */
const echoFormulaConstant = 853; const echoFormulaConstant = 224;
const getCategoryMultiplier = ( const getCategoryMultiplier = (
purchasedIds: Array<string>, purchasedIds: Array<string>,
+96
View File
@@ -595,6 +595,18 @@ describe("debug route", () => {
expect(adventurer?.unlocked).toBe(true); expect(adventurer?.unlocked).toBe(true);
}); });
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 100, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number };
expect(body.adventurerStatsPatched).toBe(1);
});
it("skips adventurer stat patching for adventurers not in defaults", async () => { it("skips adventurer stat patching for adventurers not in defaults", async () => {
const state = makeState({ const state = makeState({
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"], adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
@@ -816,6 +828,18 @@ describe("debug route", () => {
expect(quest?.status).toBe("available"); expect(quest?.status).toBe("available");
}); });
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { questsPatched: number };
expect(body.questsPatched).toBe(1);
});
it("skips quest stat patching for quests not in defaults", async () => { it("skips quest stat patching for quests not in defaults", async () => {
const state = makeState({ const state = makeState({
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"], quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
@@ -845,6 +869,18 @@ describe("debug route", () => {
expect(boss?.currentHp).toBe(100); expect(boss?.currentHp).toBe(100);
}); });
it("patches boss stats when only bountyRunestones has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 0, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 99, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number };
expect(body.bossesPatched).toBe(1);
});
it("skips boss stat patching for bosses not in defaults", async () => { it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({ const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"], bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
@@ -872,6 +908,18 @@ describe("debug route", () => {
expect(zone?.status).toBe("unlocked"); expect(zone?.status).toBe("unlocked");
}); });
it("patches zone stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
zones: [{ id: "verdant_vale", status: "unlocked", name: "The Verdant Vale", description: "Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.", emoji: "🌿", unlockBossId: null, unlockQuestId: "wrong_quest" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { zonesPatched: number };
expect(body.zonesPatched).toBe(1);
});
it("skips zone stat patching for zones not in defaults", async () => { it("skips zone stat patching for zones not in defaults", async () => {
const state = makeState({ const state = makeState({
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"], zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
@@ -901,6 +949,18 @@ describe("debug route", () => {
expect(upgrade?.unlocked).toBe(true); expect(upgrade?.unlocked).toBe(true);
}); });
it("patches upgrade stats when only costCrystals has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
upgrades: [{ id: "click_2", purchased: false, unlocked: false, multiplier: 2, name: "Battle Hardened", description: "Years of combat sharpen your instincts. Doubles click power again.", target: "click", adventurerId: undefined, costGold: 1000, costEssence: 0, costCrystals: 99 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { upgradesPatched: number };
expect(body.upgradesPatched).toBe(1);
});
it("skips upgrade stat patching for upgrades not in defaults", async () => { it("skips upgrade stat patching for upgrades not in defaults", async () => {
const state = makeState({ const state = makeState({
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"], upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
@@ -929,6 +989,30 @@ describe("debug route", () => {
expect(item?.equipped).toBe(false); expect(item?.equipped).toBe(false);
}); });
it("patches equipment stats when only cost has changed (exercises name/desc/type/rarity/bonus OR conditions)", async () => {
const state = makeState({
equipment: [{ id: "shadow_dagger", owned: true, equipped: false, name: "Shadow Dagger", description: "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.", type: "weapon", rarity: "epic", bonus: { combatMultiplier: 1.65 }, cost: { crystals: 99, essence: 500, gold: 0 }, setId: "shadow_infiltrator" }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { equipmentPatched: number };
expect(body.equipmentPatched).toBe(1);
});
it("patches equipment stats when only setId has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Iron Sword", description: "A sturdy weapon issued to veterans of the guild.", type: "weapon", rarity: "rare", bonus: { combatMultiplier: 1.25 }, cost: undefined, setId: "old_set" }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { equipmentPatched: number };
expect(body.equipmentPatched).toBe(1);
});
it("skips equipment stat patching for items not in defaults", async () => { it("skips equipment stat patching for items not in defaults", async () => {
const state = makeState({ const state = makeState({
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"], equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
@@ -957,6 +1041,18 @@ describe("debug route", () => {
expect(achievement?.unlockedAt).toBeNull(); expect(achievement?.unlockedAt).toBeNull();
}); });
it("patches achievement stats when only reward has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
achievements: [{ id: "first_click", unlockedAt: null, name: "First Strike", description: "Click the Guild Hall for the first time.", icon: "👆", condition: { amount: 1, type: "totalClicks" }, reward: { crystals: 999 } }] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { achievementsPatched: number };
expect(body.achievementsPatched).toBe(1);
});
it("skips achievement stat patching for achievements not in defaults", async () => { it("skips achievement stat patching for achievements not in defaults", async () => {
const state = makeState({ const state = makeState({
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"], achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
+28 -8
View File
@@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({ vi.mock("../../src/db/client.js", () => ({
prisma: { prisma: {
player: { update: vi.fn() }, player: { findUnique: vi.fn(), update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn() }, gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
}, },
})); }));
@@ -47,8 +47,8 @@ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
describe("prestige route", () => { describe("prestige route", () => {
let app: Hono; let app: Hono;
let prisma: { let prisma: {
player: { update: ReturnType<typeof vi.fn> }; player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }; gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -83,8 +83,8 @@ describe("prestige route", () => {
it("returns runestones on successful prestige", async () => { it("returns runestones on successful prestige", async () => {
const state = makeState(); const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post(""); const res = await post("");
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -93,6 +93,14 @@ describe("prestige route", () => {
expect(body.runestones).toBeGreaterThanOrEqual(0); expect(body.runestones).toBeGreaterThanOrEqual(0);
}); });
it("returns 409 when a concurrent prestige already committed", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 0 } as never);
const res = await post("");
expect(res.status).toBe(409);
});
it("returns 500 when the database throws during prestige", async () => { it("returns 500 when the database throws during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post(""); const res = await post("");
@@ -112,14 +120,26 @@ describe("prestige route", () => {
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }], challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
} as GameState["dailyChallenges"], } as GameState["dailyChallenges"],
}); });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post(""); const res = await post("");
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json() as { runestones: number; newPrestigeCount: number }; const body = await res.json() as { runestones: number; newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1); expect(body.newPrestigeCount).toBe(1);
}); });
it("skips webhook when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
});
}); });
describe("POST /buy-upgrade", () => { describe("POST /buy-upgrade", () => {
+1 -1
View File
@@ -158,7 +158,7 @@ describe("transcendence route", () => {
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" }); const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] }; const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
expect(body.echoesRemaining).toBe(95); // 100 - 5 expect(body.echoesRemaining).toBe(98); // 100 - 2
expect(body.purchasedUpgradeIds).toContain("echo_income_1"); expect(body.purchasedUpgradeIds).toContain("echo_income_1");
}); });
+13 -2
View File
@@ -46,13 +46,24 @@ describe("generateDailyChallenges", () => {
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id)); expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
}); });
it("always includes a clicks challenge regardless of date", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16");
expect(day1.some((c) => c.type === "clicks")).toBe(true);
expect(day2.some((c) => c.type === "clicks")).toBe(true);
});
it("generates different challenges for different dates", async () => { it("generates different challenges for different dates", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15); vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js"); const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15"); const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16"); const day2 = generateDailyChallenges("2024-01-16");
// They should differ in at least one challenge ID (types vary by seed) // The 2 non-clicks types should vary by seed between dates
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type)); const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type);
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type);
expect(day1NonClicks).not.toEqual(day2NonClicks);
}); });
}); });
+22 -13
View File
@@ -55,15 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
describe("calculatePrestigeThreshold", () => { describe("calculatePrestigeThreshold", () => {
it("returns base threshold at count 0", () => { it("returns base threshold at count 0", () => {
// base × (0+1)^2 = 1_000_000 × 1 = 1_000_000
expect(calculatePrestigeThreshold(0)).toBe(1_000_000); expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
}); });
it("returns 5× at count 1", () => { it("returns 4× base at count 1", () => {
expect(calculatePrestigeThreshold(1)).toBe(5_000_000); // base × (1+1)^2 = 1_000_000 × 4 = 4_000_000
expect(calculatePrestigeThreshold(1)).toBe(4_000_000);
}); });
it("returns 25× at count 2", () => { it("returns 9× base at count 2", () => {
expect(calculatePrestigeThreshold(2)).toBe(25_000_000); // base × (2+1)^2 = 1_000_000 × 9 = 9_000_000
expect(calculatePrestigeThreshold(2)).toBe(9_000_000);
}); });
it("applies threshold multiplier correctly", () => { it("applies threshold multiplier correctly", () => {
@@ -99,21 +102,27 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => { describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => { it("calculates basic runestones formula", () => {
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20 // floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(20); expect(result).toBe(15);
}); });
it("applies echo runestone multiplier", () => { it("applies echo runestone multiplier", () => {
// floor(sqrt(4) × 10) = 20; × 2 = 40 // floor(cbrt(4)) × 15 = 15; × 2 = 30
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(40); expect(result).toBe(30);
}); });
it("applies purchased runestone upgrade multiplier", () => { it("applies purchased runestone upgrade multiplier", () => {
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25 // With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBeGreaterThan(20); expect(result).toBe(18);
});
it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200);
}); });
}); });
@@ -122,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
expect(calculateProductionMultiplier(0)).toBe(1); expect(calculateProductionMultiplier(0)).toBe(1);
}); });
it("returns 1.15 at count 1", () => { it("returns 1.25 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15); expect(calculateProductionMultiplier(1)).toBeCloseTo(1.25);
}); });
it("scales exponentially", () => { it("scales exponentially", () => {
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10)); expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.25, 10));
}); });
}); });
+11 -5
View File
@@ -97,20 +97,21 @@ describe("isEligibleForTranscendence", () => {
describe("calculateEchoes", () => { describe("calculateEchoes", () => {
it("handles prestige count of 0 by treating it as 1", () => { it("handles prestige count of 0 by treating it as 1", () => {
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853 // safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224
expect(calculateEchoes(0, 1)).toBe(853); expect(calculateEchoes(0, 1)).toBe(224);
}); });
it("calculates echoes at count 1", () => { it("calculates echoes at count 1", () => {
expect(calculateEchoes(1, 1)).toBe(853); // floor(224 / sqrt(1)) = 224
expect(calculateEchoes(1, 1)).toBe(224);
}); });
it("decreases echoes with higher prestige count", () => { it("decreases echoes with higher prestige count", () => {
const echoesAt1 = calculateEchoes(1, 1); const echoesAt1 = calculateEchoes(1, 1);
const echoesAt4 = calculateEchoes(4, 1); const echoesAt4 = calculateEchoes(4, 1);
expect(echoesAt4).toBeLessThan(echoesAt1); expect(echoesAt4).toBeLessThan(echoesAt1);
// floor(853 / sqrt(4)) = floor(853 / 2) = 426 // floor(224 / sqrt(4)) = floor(224 / 2) = 112
expect(echoesAt4).toBe(426); expect(echoesAt4).toBe(112);
}); });
it("applies echoMetaMultiplier", () => { it("applies echoMetaMultiplier", () => {
@@ -118,6 +119,11 @@ describe("calculateEchoes", () => {
const withMult = calculateEchoes(1, 2); const withMult = calculateEchoes(1, 2);
expect(withMult).toBe(base * 2); expect(withMult).toBe(base * 2);
}); });
it("returns 50 echoes at the target prestige 20", () => {
// floor(224 / sqrt(20)) = floor(224 / 4.472) = floor(50.09) = 50
expect(calculateEchoes(20, 1)).toBe(50);
});
}); });
describe("buildPostTranscendenceState", () => { describe("buildPostTranscendenceState", () => {
@@ -9,6 +9,7 @@
/* eslint-disable complexity -- Complex component with many render paths */ /* eslint-disable complexity -- Complex component with many render paths */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer } from "@elysium/types"; import type { Adventurer } from "@elysium/types";
@@ -76,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
return quantity; return quantity;
}; };
interface EffectiveAdventurerStats {
readonly combatPower: number;
readonly essencePerSecond: number;
readonly goldPerSecond: number;
}
interface AdventurerCardProperties { interface AdventurerCardProperties {
readonly adventurer: Adventurer; readonly adventurer: Adventurer;
readonly currentGold: number; readonly currentGold: number;
readonly batchSize: BatchSize; readonly batchSize: BatchSize;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
readonly effectiveStats: EffectiveAdventurerStats;
} }
/** /**
@@ -92,6 +100,7 @@ interface AdventurerCardProperties {
* @param props.batchSize - The selected batch size. * @param props.batchSize - The selected batch size.
* @param props.unlockHint - Optional quest name that unlocks this adventurer. * @param props.unlockHint - Optional quest name that unlocks this adventurer.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @param props.effectiveStats - The post-multiplier per-unit stats.
* @returns The JSX element. * @returns The JSX element.
*/ */
const AdventurerCard = ({ const AdventurerCard = ({
@@ -100,6 +109,7 @@ const AdventurerCard = ({
batchSize, batchSize,
unlockHint, unlockHint,
formatNumber, formatNumber,
effectiveStats,
}: AdventurerCardProperties): JSX.Element => { }: AdventurerCardProperties): JSX.Element => {
const { buyAdventurer } = useGame(); const { buyAdventurer } = useGame();
@@ -134,17 +144,17 @@ const AdventurerCard = ({
<div className="adventurer-info"> <div className="adventurer-info">
<h3>{adventurer.name}</h3> <h3>{adventurer.name}</h3>
<p> <p>
{formatNumber(adventurer.goldPerSecond)} {formatNumber(effectiveStats.goldPerSecond)}
{" gold/s each"} {" gold/s each"}
</p> </p>
{adventurer.essencePerSecond > 0 {adventurer.essencePerSecond > 0
&& <p> && <p>
{formatNumber(adventurer.essencePerSecond)} {formatNumber(effectiveStats.essencePerSecond)}
{" essence/s each"} {" essence/s each"}
</p> </p>
} }
<p> <p>
{formatNumber(adventurer.combatPower)} {formatNumber(effectiveStats.combatPower)}
{" combat power each"} {" combat power each"}
</p> </p>
</div> </div>
@@ -280,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
adventurer={adventurer} adventurer={adventurer}
batchSize={batchSize} batchSize={batchSize}
currentGold={state.resources.gold} currentGold={state.resources.gold}
effectiveStats={computeEffectiveAdventurerStats(
state,
adventurer.id,
)}
formatNumber={formatNumber} formatNumber={formatNumber}
key={adventurer.id} key={adventurer.id}
unlockHint={adventurerUnlockHints.get(adventurer.id)} unlockHint={adventurerUnlockHints.get(adventurer.id)}
@@ -225,6 +225,10 @@ const EditProfileModal = ({
void handleNotificationsEnable(); void handleNotificationsEnable();
} }
function handlePrestigeAnnouncementsToggle(): void {
toggleSetting("enablePrestigeAnnouncements");
}
const isSaveDisabled = saving || characterName.trim() === ""; const isSaveDisabled = saving || characterName.trim() === "";
let saveLabel = "Save Profile"; let saveLabel = "Save Profile";
@@ -417,6 +421,23 @@ const EditProfileModal = ({
} }
</span> </span>
</button> </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>
<div className="edit-profile-section"> <div className="edit-profile-section">
+9 -37
View File
@@ -12,25 +12,27 @@ import { useState, type JSX } from "react";
import { prestige } from "../../api/client.js"; import { prestige } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import {
PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS, PRESTIGE_UPGRADE_CATEGORY_LABELS,
PRESTIGE_UPGRADES,
} from "../../data/prestigeUpgrades.js"; } from "../../data/prestigeUpgrades.js";
import {
computeProjectedRunestones,
} from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { sendNotification } from "../../utils/notification.js"; import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js"; import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types"; import type { PrestigeUpgradeCategory } from "@elysium/types";
const baseThreshold = 1_000_000; const baseThreshold = 1_000_000;
const thresholdScale = 5;
const runestonesPerLevel = 10;
/** /**
* Calculates the prestige threshold for a given prestige count. * Calculates the prestige threshold for a given prestige count.
* Mirrors the server formula: BASE * (count + 1)^2.
* @param prestigeCount - The current prestige count. * @param prestigeCount - The current prestige count.
* @returns The required gold to prestige. * @returns The required gold to prestige.
*/ */
const calculateThreshold = (prestigeCount: number): number => { 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); 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> = [ const categoryOrder: Array<PrestigeUpgradeCategory> = [
"income", "income",
"click", "click",
@@ -84,7 +60,7 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
const PrestigePanel = (): JSX.Element => { const PrestigePanel = (): JSX.Element => {
const { const {
state, state,
reload, reloadSilent,
formatNumber, formatNumber,
buyPrestigeUpgrade, buyPrestigeUpgrade,
enableNotifications, enableNotifications,
@@ -114,11 +90,7 @@ const PrestigePanel = (): JSX.Element => {
const { autoAdventurer, prestige: prestigeData, player } = state; const { autoAdventurer, prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count); const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold; const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = calculateRunestonePreview( const runestonePreview = computeProjectedRunestones(state);
player.totalGoldEarned,
prestigeData.count,
prestigeData.purchasedUpgradeIds,
);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
async function handlePrestige(): Promise<void> { async function handlePrestige(): Promise<void> {
@@ -141,7 +113,7 @@ const PrestigePanel = (): JSX.Element => {
`You've reached prestige level ${data.newPrestigeCount.toString()}!`, `You've reached prestige level ${data.newPrestigeCount.toString()}!`,
); );
} }
await reload(); await reloadSilent();
} catch (error_: unknown) { } catch (error_: unknown) {
setPrestigeError( setPrestigeError(
error_ instanceof Error error_ instanceof Error
+6 -7
View File
@@ -11,7 +11,10 @@
/* eslint-disable max-statements -- Many local variables needed for quest state */ /* eslint-disable max-statements -- Many local variables needed for quest state */
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; 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 { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.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) => { const activeZone = zones.find((zone) => {
return zone.id === activeZoneId; return zone.id === activeZoneId;
@@ -226,11 +229,7 @@ const QuestPanel = (): JSX.Element => {
: quests.find((quest) => { : quests.find((quest) => {
return quest.id === activeZone.unlockQuestId; return quest.id === activeZone.unlockQuestId;
}); });
let partyCombatPower = 0; const partyCombatPower = computePartyCombatPower(state);
for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
const zoneQuests = quests.filter(({ zoneId }) => { const zoneQuests = quests.filter(({ zoneId }) => {
return zoneId === activeZoneId; return zoneId === activeZoneId;
}); });
@@ -15,6 +15,7 @@ import {
computeEssencePerSecond, computeEssencePerSecond,
computeGoldPerSecond, computeGoldPerSecond,
computePartyCombatPower, computePartyCombatPower,
computeProjectedRunestones,
} from "../../engine/tick.js"; } from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
@@ -89,10 +90,12 @@ const ResourceBar = ({
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0; let goldPerSecond = 0;
let essencePerSecond = 0; let essencePerSecond = 0;
let projectedRunestones = 0;
if (state !== null) { if (state !== null) {
partyCombatPower = computePartyCombatPower(state); partyCombatPower = computePartyCombatPower(state);
goldPerSecond = computeGoldPerSecond(state); goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state); essencePerSecond = computeEssencePerSecond(state);
projectedRunestones = computeProjectedRunestones(state);
} }
let avatarUrl: string | null = null; let avatarUrl: string | null = null;
@@ -234,6 +237,13 @@ const ResourceBar = ({
</span> </span>
<span className="resource-label">{"Runestones"}</span> <span className="resource-label">{"Runestones"}</span>
</div> </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"> <div className="resource">
<span className="resource-icon">{"⚔️"}</span> <span className="resource-icon">{"⚔️"}</span>
<span className="resource-value"> <span className="resource-value">
+70 -1
View File
@@ -53,6 +53,7 @@ import {
transcend as transcendApi, transcend as transcendApi,
} from "../api/client.js"; } from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js"; import { CODEX_ENTRIES } from "../data/codex.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { RECIPES } from "../data/recipes.js"; import { RECIPES } from "../data/recipes.js";
import { import {
RESOURCE_CAP, RESOURCE_CAP,
@@ -116,6 +117,9 @@ const applyBossResult = (
}). }).
filter(Boolean), filter(Boolean),
); );
const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => {
return z.id;
}));
const challengeUpdate const challengeUpdate
= previous.dailyChallenges === undefined = previous.dailyChallenges === undefined
@@ -216,6 +220,23 @@ const applyBossResult = (
? { ...u, unlocked: true } ? { ...u, unlocked: true }
: u; : 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;
}),
},
},
}; };
} }
@@ -289,6 +310,12 @@ interface GameContextValue {
*/ */
reload: ()=> Promise<void>; 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). * Unix timestamp of the last successful cloud save (null until first save response).
*/ */
@@ -697,6 +724,10 @@ export const GameProvider = ({
/* No-op placeholder */ /* No-op placeholder */
}); });
const reloadSilentReference = useRef<()=> Promise<void>>(async() => {
/* No-op placeholder */
});
const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
@@ -784,6 +815,32 @@ export const GameProvider = ({
reloadReference.current = reload; 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(() => { useEffect(() => {
enableSoundsReference.current = enableSounds; enableSoundsReference.current = enableSounds;
}, [ enableSounds ]); }, [ enableSounds ]);
@@ -1294,7 +1351,7 @@ export const GameProvider = ({
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!"); sendNotification("⭐ Prestige!", "You have ascended!");
} }
await reloadReference.current(); await reloadSilentReference.current();
}). }).
catch(() => { catch(() => {
@@ -1810,7 +1867,18 @@ export const GameProvider = ({
const collectExploration = useCallback( const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => { async(areaId: string): Promise<ExploreCollectResponse> => {
isSyncingReference.current = true;
const result = await collectExplorationApi({ areaId }); 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) => { setState((previous) => {
if (previous?.exploration === undefined) { if (previous?.exploration === undefined) {
return previous; return previous;
@@ -2341,6 +2409,7 @@ export const GameProvider = ({
offlineEssence, offlineEssence,
offlineGold, offlineGold,
reload, reload,
reloadSilent,
resetProgress, resetProgress,
saveSchemaVersion, saveSchemaVersion,
schemaOutdated, schemaOutdated,
+168 -1
View File
@@ -21,6 +21,7 @@ import {
getActiveCompanionBonus, getActiveCompanionBonus,
} from "@elysium/types"; } from "@elysium/types";
import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
/** /**
@@ -243,10 +244,129 @@ export const computeEssencePerSecond = (state: GameState): number => {
return essencePerSecond; 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;
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeCombatMultiplier = 1 + state.prestige.count * 0.1;
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 * Computes the party's total combat power, applying all active multipliers
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion). * (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
* This mirrors the server-side calculatePartyStats in boss.ts. * 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. * @param state - The current game state.
* @returns The total party combat power. * @returns The total party combat power.
*/ */
@@ -327,6 +447,36 @@ export const computePartyCombatPower = (state: GameState): number => {
* companionCombatMult; * 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;
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- optional chained game state field */
const echoMult: number = state.transcendence?.echoRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult);
};
/** /**
* Pure function applies one game tick to the state. * Pure function applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick. * DeltaSeconds: time elapsed since last tick.
@@ -634,6 +784,23 @@ export const applyTick = (
...updatedDailyChallenges === undefined ...updatedDailyChallenges === undefined
? {} ? {}
: { dailyChallenges: updatedDailyChallenges }, : { 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, adventurers: updatedAdventurers,
bosses: updatedBosses, bosses: updatedBosses,
equipment: updatedEquipmentReference, equipment: updatedEquipmentReference,
@@ -48,11 +48,17 @@ interface ProfileSettings {
* Whether browser system notifications are enabled. * Whether browser system notifications are enabled.
*/ */
enableNotifications: boolean; enableNotifications: boolean;
/**
* Whether prestige milestones are announced in the Discord server.
*/
enablePrestigeAnnouncements: boolean;
} }
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
enableNotifications: false, enableNotifications: false,
enablePrestigeAnnouncements: true,
enableSounds: false, enableSounds: false,
numberFormat: "suffix", numberFormat: "suffix",
showAchievementsUnlocked: true, showAchievementsUnlocked: true,