16 Commits

Author SHA1 Message Date
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
29 changed files with 524 additions and 1509 deletions
+5 -95
View File
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
{ {
condition: { amount: 18, type: "bossesDefeated" }, condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat the 18 bosses of the mortal realms.", description: "Defeat all 18 bosses across the first six zones.",
icon: "🌟", icon: "🌟",
id: "devourer_slayer", id: "devourer_slayer",
name: "World Saver", name: "World Saver",
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 78, type: "equipmentOwned" }, condition: { amount: 65, type: "equipmentOwned" },
description: "Own all 78 pieces of equipment.", description: "Own all 65 pieces of equipment.",
icon: "🛡️", icon: "🛡️",
id: "fully_equipped", id: "fully_equipped",
name: "Fully Equipped", name: "Fully Equipped",
@@ -269,33 +269,6 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 50_000 }, reward: { crystals: 50_000 },
unlockedAt: null, unlockedAt: null,
}, },
{
condition: { amount: 1e30, type: "totalGoldEarned" },
description: "Earn 1 nonillion gold in total.",
icon: "🌌",
id: "cosmic_wealthy",
name: "Cosmic Wealthy",
reward: { crystals: 100_000 },
unlockedAt: null,
},
{
condition: { amount: 1e60, type: "totalGoldEarned" },
description: "Earn a vigintillion gold in total.",
icon: "♾️",
id: "infinite_hoarder",
name: "Infinite Hoarder",
reward: { crystals: 250_000 },
unlockedAt: null,
},
{
condition: { amount: 1e90, type: "totalGoldEarned" },
description: "Earn a trigintillion gold in total.",
icon: "🔮",
id: "omniversal_tycoon",
name: "Omniversal Tycoon",
reward: { crystals: 1_000_000 },
unlockedAt: null,
},
// Higher quest milestones // Higher quest milestones
{ {
condition: { amount: 30, type: "questsCompleted" }, condition: { amount: 30, type: "questsCompleted" },
@@ -316,26 +289,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 75, type: "questsCompleted" }, condition: { amount: 95, type: "questsCompleted" },
description: "Complete 75 quests.", description: "Complete all 95 quests across the known multiverse.",
icon: "🌠",
id: "quest_hero",
name: "Quest Hero",
reward: { crystals: 10_000 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "questsCompleted" },
description: "Complete 100 quests.",
icon: "💫",
id: "quest_legend",
name: "Quest Legend",
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
condition: { amount: 112, type: "questsCompleted" },
description: "Complete all 112 quests across the known multiverse.",
icon: "🌌", icon: "🌌",
id: "quest_eternal", id: "quest_eternal",
name: "Quest Eternal", name: "Quest Eternal",
@@ -361,15 +316,6 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 5000 }, reward: { crystals: 5000 },
unlockedAt: null, unlockedAt: null,
}, },
{
condition: { amount: 50, type: "bossesDefeated" },
description: "Defeat 50 bosses.",
icon: "⚡",
id: "boss_legend",
name: "Legendary Vanquisher",
reward: { crystals: 15_000 },
unlockedAt: null,
},
{ {
condition: { amount: 72, type: "bossesDefeated" }, condition: { amount: 72, type: "bossesDefeated" },
description: "Defeat all 72 bosses across every plane of existence.", description: "Defeat all 72 bosses across every plane of existence.",
@@ -417,40 +363,4 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 25_000 }, reward: { crystals: 25_000 },
unlockedAt: null, unlockedAt: null,
}, },
{
condition: { amount: 50, type: "prestigeCount" },
description: "Prestige 50 times.",
icon: "✨",
id: "prestige_transcendent",
name: "Transcendent",
reward: { runestones: 100 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "prestigeCount" },
description: "Prestige 100 times.",
icon: "💎",
id: "prestige_eternal",
name: "Eternal Looper",
reward: { runestones: 500 },
unlockedAt: null,
},
{
condition: { amount: 150, type: "prestigeCount" },
description: "Prestige 150 times.",
icon: "🌟",
id: "prestige_immortal",
name: "Immortal Cycler",
reward: { runestones: 2000 },
unlockedAt: null,
},
{
condition: { amount: 200, type: "prestigeCount" },
description: "Prestige 200 times.",
icon: "👑",
id: "prestige_absolute",
name: "Absolute Champion",
reward: { runestones: 10_000 },
unlockedAt: null,
},
]; ];
+72 -72
View File
@@ -12,7 +12,7 @@ export const defaultBosses: Array<Boss> = [
// ── Verdant Vale ────────────────────────────────────────────────────────── // ── Verdant Vale ──────────────────────────────────────────────────────────
{ {
bountyRunestones: 1, bountyRunestones: 1,
crystalReward: 5, crystalReward: 0,
currentHp: 1000, currentHp: 1000,
damagePerSecond: 5, damagePerSecond: 5,
description: description:
@@ -122,7 +122,7 @@ export const defaultBosses: Array<Boss> = [
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
bountyRunestones: 20, bountyRunestones: 20,
crystalReward: 1500, crystalReward: 700,
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: 3000, crystalReward: 1500,
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: 6000, crystalReward: 3000,
currentHp: 20_000_000, currentHp: 20_000_000,
damagePerSecond: 4000, damagePerSecond: 4000,
description: description:
@@ -360,7 +360,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 40, bountyRunestones: 40,
crystalReward: 0, crystalReward: 40_000,
currentHp: 2_000_000_000, currentHp: 2_000_000_000,
damagePerSecond: 120_000, damagePerSecond: 120_000,
description: description:
@@ -378,7 +378,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 50, bountyRunestones: 50,
crystalReward: 0, crystalReward: 100_000,
currentHp: 8_000_000_000, currentHp: 8_000_000_000,
damagePerSecond: 350_000, damagePerSecond: 350_000,
description: description:
@@ -396,7 +396,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 60, bountyRunestones: 60,
crystalReward: 0, crystalReward: 300_000,
currentHp: 30_000_000_000, currentHp: 30_000_000_000,
damagePerSecond: 1_000_000, damagePerSecond: 1_000_000,
description: description:
@@ -414,7 +414,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 75, bountyRunestones: 75,
crystalReward: 0, crystalReward: 800_000,
currentHp: 100_000_000_000, currentHp: 100_000_000_000,
damagePerSecond: 3_000_000, damagePerSecond: 3_000_000,
description: description:
@@ -433,7 +433,7 @@ export const defaultBosses: Array<Boss> = [
// ── Abyssal Trench ──────────────────────────────────────────────────────── // ── Abyssal Trench ────────────────────────────────────────────────────────
{ {
bountyRunestones: 40, bountyRunestones: 40,
crystalReward: 0, crystalReward: 1_500_000,
currentHp: 250_000_000_000, currentHp: 250_000_000_000,
damagePerSecond: 5_000_000, damagePerSecond: 5_000_000,
description: description:
@@ -451,7 +451,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 55, bountyRunestones: 55,
crystalReward: 0, crystalReward: 4_000_000,
currentHp: 1_000_000_000_000, currentHp: 1_000_000_000_000,
damagePerSecond: 15_000_000, damagePerSecond: 15_000_000,
description: description:
@@ -469,7 +469,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 70, bountyRunestones: 70,
crystalReward: 0, crystalReward: 12_000_000,
currentHp: 4_000_000_000_000, currentHp: 4_000_000_000_000,
damagePerSecond: 50_000_000, damagePerSecond: 50_000_000,
description: description:
@@ -487,7 +487,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 85, bountyRunestones: 85,
crystalReward: 0, crystalReward: 40_000_000,
currentHp: 15_000_000_000_000, currentHp: 15_000_000_000_000,
damagePerSecond: 150_000_000, damagePerSecond: 150_000_000,
description: description:
@@ -505,7 +505,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 100, bountyRunestones: 100,
crystalReward: 0, crystalReward: 150_000_000,
currentHp: 50_000_000_000_000, currentHp: 50_000_000_000_000,
damagePerSecond: 500_000_000, damagePerSecond: 500_000_000,
description: description:
@@ -524,7 +524,7 @@ export const defaultBosses: Array<Boss> = [
// ── Infernal Court ──────────────────────────────────────────────────────── // ── Infernal Court ────────────────────────────────────────────────────────
{ {
bountyRunestones: 55, bountyRunestones: 55,
crystalReward: 0, crystalReward: 350_000_000,
currentHp: 120_000_000_000_000, currentHp: 120_000_000_000_000,
damagePerSecond: 800_000_000, damagePerSecond: 800_000_000,
description: description:
@@ -542,7 +542,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 70, bountyRunestones: 70,
crystalReward: 0, crystalReward: 1_000_000_000,
currentHp: 500_000_000_000_000, currentHp: 500_000_000_000_000,
damagePerSecond: 2_500_000_000, damagePerSecond: 2_500_000_000,
description: description:
@@ -560,7 +560,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 90, bountyRunestones: 90,
crystalReward: 0, crystalReward: 3_000_000_000,
currentHp: 2_000_000_000_000_000, currentHp: 2_000_000_000_000_000,
damagePerSecond: 8_000_000_000, damagePerSecond: 8_000_000_000,
description: description:
@@ -578,7 +578,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 110, bountyRunestones: 110,
crystalReward: 0, crystalReward: 10_000_000_000,
currentHp: 6_000_000_000_000_000, currentHp: 6_000_000_000_000_000,
damagePerSecond: 25_000_000_000, damagePerSecond: 25_000_000_000,
description: description:
@@ -596,7 +596,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 135, bountyRunestones: 135,
crystalReward: 0, crystalReward: 30_000_000_000,
currentHp: 8_000_000_000_000_000, currentHp: 8_000_000_000_000_000,
damagePerSecond: 80_000_000_000, damagePerSecond: 80_000_000_000,
description: description:
@@ -615,7 +615,7 @@ export const defaultBosses: Array<Boss> = [
// ── Crystalline Spire ───────────────────────────────────────────────────── // ── Crystalline Spire ─────────────────────────────────────────────────────
{ {
bountyRunestones: 70, bountyRunestones: 70,
crystalReward: 0, crystalReward: 8e10,
currentHp: 2e16, currentHp: 2e16,
damagePerSecond: 120_000_000_000, damagePerSecond: 120_000_000_000,
description: description:
@@ -628,12 +628,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Prism Golem", name: "The Prism Golem",
prestigeRequirement: 3, prestigeRequirement: 3,
status: "locked", status: "locked",
upgradeRewards: [ "crystal_sage_1" ], upgradeRewards: [],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
bountyRunestones: 90, bountyRunestones: 90,
crystalReward: 0, crystalReward: 3e11,
currentHp: 8e16, currentHp: 8e16,
damagePerSecond: 4e11, damagePerSecond: 4e11,
description: description:
@@ -651,7 +651,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 115, bountyRunestones: 115,
crystalReward: 0, crystalReward: 1e12,
currentHp: 3e17, currentHp: 3e17,
damagePerSecond: 1.2e12, damagePerSecond: 1.2e12,
description: description:
@@ -664,12 +664,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Faceted", name: "The Faceted",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [ "void_sentinel_1" ], upgradeRewards: [],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
bountyRunestones: 140, bountyRunestones: 140,
crystalReward: 0, crystalReward: 4e12,
currentHp: 1e18, currentHp: 1e18,
damagePerSecond: 4e12, damagePerSecond: 4e12,
description: description:
@@ -682,12 +682,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Diamond Colossus", name: "The Diamond Colossus",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [ "eternal_champion_1" ], upgradeRewards: [],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
bountyRunestones: 175, bountyRunestones: 175,
crystalReward: 0, crystalReward: 1.5e13,
currentHp: 4e18, currentHp: 4e18,
damagePerSecond: 1.5e13, damagePerSecond: 1.5e13,
description: description:
@@ -700,13 +700,13 @@ export const defaultBosses: Array<Boss> = [
name: "The Crystal Sovereign", name: "The Crystal Sovereign",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [ "cosmos_knight_1" ], upgradeRewards: [],
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
// ── Void Sanctum ────────────────────────────────────────────────────────── // ── Void Sanctum ──────────────────────────────────────────────────────────
{ {
bountyRunestones: 90, bountyRunestones: 90,
crystalReward: 0, crystalReward: 4e13,
currentHp: 1e19, currentHp: 1e19,
damagePerSecond: 4e13, damagePerSecond: 4e13,
description: description:
@@ -719,12 +719,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Herald", name: "The Void Herald",
prestigeRequirement: 4, prestigeRequirement: 4,
status: "locked", status: "locked",
upgradeRewards: [ "seraph_knight_1" ], upgradeRewards: [],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
bountyRunestones: 115, bountyRunestones: 115,
crystalReward: 0, crystalReward: 1.5e14,
currentHp: 5e19, currentHp: 5e19,
damagePerSecond: 1.5e14, damagePerSecond: 1.5e14,
description: description:
@@ -742,7 +742,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 145, bountyRunestones: 145,
crystalReward: 0, crystalReward: 5e14,
currentHp: 2e20, currentHp: 2e20,
damagePerSecond: 5e14, damagePerSecond: 5e14,
description: description:
@@ -755,12 +755,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Unmaker", name: "The Unmaker",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [ "abyss_diver_1" ], upgradeRewards: [],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
bountyRunestones: 180, bountyRunestones: 180,
crystalReward: 0, crystalReward: 2e15,
currentHp: 8e20, currentHp: 8e20,
damagePerSecond: 2e15, damagePerSecond: 2e15,
description: description:
@@ -778,7 +778,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 225, bountyRunestones: 225,
crystalReward: 0, crystalReward: 8e15,
currentHp: 3e21, currentHp: 3e21,
damagePerSecond: 8e15, damagePerSecond: 8e15,
description: description:
@@ -791,13 +791,13 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Emperor", name: "The Void Emperor",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [ "infernal_warden_1" ], upgradeRewards: [],
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
// ── Eternal Throne ──────────────────────────────────────────────────────── // ── Eternal Throne ────────────────────────────────────────────────────────
{ {
bountyRunestones: 115, bountyRunestones: 115,
crystalReward: 0, crystalReward: 2e16,
currentHp: 1e22, currentHp: 1e22,
damagePerSecond: 2e16, damagePerSecond: 2e16,
description: description:
@@ -810,12 +810,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Throne Warden", name: "The Throne Warden",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [ "infinity_ranger_1" ], upgradeRewards: [],
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
bountyRunestones: 150, bountyRunestones: 150,
crystalReward: 0, crystalReward: 8e16,
currentHp: 5e22, currentHp: 5e22,
damagePerSecond: 8e16, damagePerSecond: 8e16,
description: description:
@@ -833,7 +833,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 190, bountyRunestones: 190,
crystalReward: 0, crystalReward: 3e17,
currentHp: 2e23, currentHp: 2e23,
damagePerSecond: 3e17, damagePerSecond: 3e17,
description: description:
@@ -846,12 +846,12 @@ export const defaultBosses: Array<Boss> = [
name: "The Undying", name: "The Undying",
prestigeRequirement: 5, prestigeRequirement: 5,
status: "locked", status: "locked",
upgradeRewards: [ "reality_warden_1" ], upgradeRewards: [],
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
bountyRunestones: 235, bountyRunestones: 235,
crystalReward: 0, crystalReward: 1.2e18,
currentHp: 8e23, currentHp: 8e23,
damagePerSecond: 1.2e18, damagePerSecond: 1.2e18,
description: description:
@@ -869,7 +869,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 295, bountyRunestones: 295,
crystalReward: 0, crystalReward: 5e18,
currentHp: 3e24, currentHp: 3e24,
damagePerSecond: 5e18, damagePerSecond: 5e18,
description: description:
@@ -888,7 +888,7 @@ export const defaultBosses: Array<Boss> = [
// ── Primordial Chaos ────────────────────────────────────────────────────── // ── Primordial Chaos ──────────────────────────────────────────────────────
{ {
bountyRunestones: 150, bountyRunestones: 150,
crystalReward: 0, crystalReward: 2e20,
currentHp: 1e26, currentHp: 1e26,
damagePerSecond: 2e20, damagePerSecond: 2e20,
description: description:
@@ -906,7 +906,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 200, bountyRunestones: 200,
crystalReward: 0, crystalReward: 8e21,
currentHp: 5e27, currentHp: 5e27,
damagePerSecond: 8e21, damagePerSecond: 8e21,
description: description:
@@ -924,7 +924,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 265, bountyRunestones: 265,
crystalReward: 0, crystalReward: 4e23,
currentHp: 2e29, currentHp: 2e29,
damagePerSecond: 4e23, damagePerSecond: 4e23,
description: description:
@@ -942,7 +942,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 350, bountyRunestones: 350,
crystalReward: 0, crystalReward: 2e25,
currentHp: 8e30, currentHp: 8e30,
damagePerSecond: 2e25, damagePerSecond: 2e25,
description: description:
@@ -961,7 +961,7 @@ export const defaultBosses: Array<Boss> = [
// ── Infinite Expanse ────────────────────────────────────────────────────── // ── Infinite Expanse ──────────────────────────────────────────────────────
{ {
bountyRunestones: 200, bountyRunestones: 200,
crystalReward: 0, crystalReward: 8e27,
currentHp: 3e33, currentHp: 3e33,
damagePerSecond: 8e27, damagePerSecond: 8e27,
description: description:
@@ -979,8 +979,8 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 265, bountyRunestones: 265,
crystalReward: 0, crystalReward: 3e31,
currentHp: 2e35, currentHp: 1e37,
damagePerSecond: 3e31, damagePerSecond: 3e31,
description: description:
"A creature as wide as the observable universe — which, in the Expanse, is not a helpful measurement. It is simply everywhere the horizon is, which in this place is everywhere.", "A creature as wide as the observable universe — which, in the Expanse, is not a helpful measurement. It is simply everywhere the horizon is, which in this place is everywhere.",
@@ -988,7 +988,7 @@ export const defaultBosses: Array<Boss> = [
essenceReward: 1e34, essenceReward: 1e34,
goldReward: 1e38, goldReward: 1e38,
id: "horizon_beast", id: "horizon_beast",
maxHp: 2e35, maxHp: 1e37,
name: "The Horizon Beast", name: "The Horizon Beast",
prestigeRequirement: 8, prestigeRequirement: 8,
status: "locked", status: "locked",
@@ -997,8 +997,8 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 350, bountyRunestones: 350,
crystalReward: 0, crystalReward: 1e35,
currentHp: 5e37, currentHp: 5e40,
damagePerSecond: 1e35, damagePerSecond: 1e35,
description: description:
"A self-replicating intelligence that has filled the Expanse with copies of itself. Every copy has the same purpose: to be the last thing in the Expanse. Your guild will need to convince all of them otherwise.", "A self-replicating intelligence that has filled the Expanse with copies of itself. Every copy has the same purpose: to be the last thing in the Expanse. Your guild will need to convince all of them otherwise.",
@@ -1006,7 +1006,7 @@ export const defaultBosses: Array<Boss> = [
essenceReward: 5e37, essenceReward: 5e37,
goldReward: 5e41, goldReward: 5e41,
id: "infinity_construct", id: "infinity_construct",
maxHp: 5e37, maxHp: 5e40,
name: "The Infinity Construct", name: "The Infinity Construct",
prestigeRequirement: 8, prestigeRequirement: 8,
status: "locked", status: "locked",
@@ -1015,8 +1015,8 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 465, bountyRunestones: 465,
crystalReward: 0, crystalReward: 5e38,
currentHp: 3e39, currentHp: 2e44,
damagePerSecond: 5e38, damagePerSecond: 5e38,
description: description:
"The thing that claims the Infinite Expanse as its territory — which, given the name of the place, is an ambitious claim. It enforces this claim with power that has had infinite space to accumulate.", "The thing that claims the Infinite Expanse as its territory — which, given the name of the place, is an ambitious claim. It enforces this claim with power that has had infinite space to accumulate.",
@@ -1024,7 +1024,7 @@ export const defaultBosses: Array<Boss> = [
essenceReward: 2e41, essenceReward: 2e41,
goldReward: 2e45, goldReward: 2e45,
id: "expanse_sovereign", id: "expanse_sovereign",
maxHp: 3e39, maxHp: 2e44,
name: "The Expanse Sovereign", name: "The Expanse Sovereign",
prestigeRequirement: 9, prestigeRequirement: 9,
status: "locked", status: "locked",
@@ -1034,7 +1034,7 @@ export const defaultBosses: Array<Boss> = [
// ── Reality Forge ───────────────────────────────────────────────────────── // ── Reality Forge ─────────────────────────────────────────────────────────
{ {
bountyRunestones: 265, bountyRunestones: 265,
crystalReward: 0, crystalReward: 2e42,
currentHp: 8e47, currentHp: 8e47,
damagePerSecond: 2e42, damagePerSecond: 2e42,
description: description:
@@ -1052,7 +1052,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 350, bountyRunestones: 350,
crystalReward: 0, crystalReward: 1e47,
currentHp: 4e52, currentHp: 4e52,
damagePerSecond: 1e47, damagePerSecond: 1e47,
description: description:
@@ -1070,7 +1070,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 465, bountyRunestones: 465,
crystalReward: 0, crystalReward: 6e51,
currentHp: 2e57, currentHp: 2e57,
damagePerSecond: 6e51, damagePerSecond: 6e51,
description: description:
@@ -1088,7 +1088,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 615, bountyRunestones: 615,
crystalReward: 0, crystalReward: 2e56,
currentHp: 8e61, currentHp: 8e61,
damagePerSecond: 2e56, damagePerSecond: 2e56,
description: description:
@@ -1107,7 +1107,7 @@ export const defaultBosses: Array<Boss> = [
// ── Cosmic Maelstrom ────────────────────────────────────────────────────── // ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{ {
bountyRunestones: 350, bountyRunestones: 350,
crystalReward: 0, crystalReward: 1e60,
currentHp: 4e65, currentHp: 4e65,
damagePerSecond: 1e60, damagePerSecond: 1e60,
description: description:
@@ -1125,7 +1125,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 465, bountyRunestones: 465,
crystalReward: 0, crystalReward: 6e65,
currentHp: 2e71, currentHp: 2e71,
damagePerSecond: 6e65, damagePerSecond: 6e65,
description: description:
@@ -1143,7 +1143,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 615, bountyRunestones: 615,
crystalReward: 0, crystalReward: 3e71,
currentHp: 1e77, currentHp: 1e77,
damagePerSecond: 3e71, damagePerSecond: 3e71,
description: description:
@@ -1161,7 +1161,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 815, bountyRunestones: 815,
crystalReward: 0, crystalReward: 1e77,
currentHp: 5e82, currentHp: 5e82,
damagePerSecond: 1e77, damagePerSecond: 1e77,
description: description:
@@ -1180,7 +1180,7 @@ export const defaultBosses: Array<Boss> = [
// ── Primeval Sanctum ────────────────────────────────────────────────────── // ── Primeval Sanctum ──────────────────────────────────────────────────────
{ {
bountyRunestones: 465, bountyRunestones: 465,
crystalReward: 0, crystalReward: 5e82,
currentHp: 2e88, currentHp: 2e88,
damagePerSecond: 5e82, damagePerSecond: 5e82,
description: description:
@@ -1198,7 +1198,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 615, bountyRunestones: 615,
crystalReward: 0, crystalReward: 3e89,
currentHp: 1e95, currentHp: 1e95,
damagePerSecond: 3e89, damagePerSecond: 3e89,
description: description:
@@ -1216,7 +1216,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 815, bountyRunestones: 815,
crystalReward: 0, crystalReward: 2e96,
currentHp: 8e101, currentHp: 8e101,
damagePerSecond: 2e96, damagePerSecond: 2e96,
description: description:
@@ -1234,7 +1234,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 1080, bountyRunestones: 1080,
crystalReward: 0, crystalReward: 1e103,
currentHp: 5e108, currentHp: 5e108,
damagePerSecond: 1e103, damagePerSecond: 1e103,
description: description:
@@ -1253,7 +1253,7 @@ export const defaultBosses: Array<Boss> = [
// ── The Absolute ────────────────────────────────────────────────────────── // ── The Absolute ──────────────────────────────────────────────────────────
{ {
bountyRunestones: 615, bountyRunestones: 615,
crystalReward: 0, crystalReward: 5e110,
currentHp: 2e116, currentHp: 2e116,
damagePerSecond: 5e110, damagePerSecond: 5e110,
description: description:
@@ -1271,7 +1271,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 815, bountyRunestones: 815,
crystalReward: 0, crystalReward: 3e119,
currentHp: 1e125, currentHp: 1e125,
damagePerSecond: 3e119, damagePerSecond: 3e119,
description: description:
@@ -1289,7 +1289,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 1080, bountyRunestones: 1080,
crystalReward: 0, crystalReward: 1e129,
currentHp: 5e134, currentHp: 5e134,
damagePerSecond: 1e129, damagePerSecond: 1e129,
description: description:
@@ -1307,7 +1307,7 @@ export const defaultBosses: Array<Boss> = [
}, },
{ {
bountyRunestones: 1430, bountyRunestones: 1430,
crystalReward: 0, crystalReward: 5e139,
currentHp: 2e145, currentHp: 2e145,
damagePerSecond: 5e139, damagePerSecond: 5e139,
description: description:
+1 -163
View File
@@ -695,168 +695,6 @@ export const defaultEquipment: Array<Equipment> = [
setId: "eternal_throne", setId: "eternal_throne",
type: "trinket", type: "trinket",
}, },
// ── Primordial Chaos ──────────────────────────────────────────────────────
{
bonus: { goldMultiplier: 9 },
description:
"The Primordial Titan's carapace — formed before the concept of armour existed. It simply is what armour aspires to be.",
equipped: false,
id: "chaos_mantle",
name: "The Chaos Mantle",
owned: false,
rarity: "legendary",
setId: "primordial_chaos",
type: "armour",
},
{
bonus: { clickMultiplier: 5, combatMultiplier: 2, goldMultiplier: 2.5 },
description:
"The crystallised core of the Titan itself — the first stable thing to emerge from chaos. It radiates in every direction simultaneously.",
equipped: false,
id: "titan_core",
name: "The Titan Core",
owned: false,
rarity: "legendary",
setId: "primordial_chaos",
type: "trinket",
},
// ── Infinite Expanse ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 14 },
description:
"Forged from the Expanse Sovereign's own reach — a blade that has no beginning and no end, only edge.",
equipped: false,
id: "expanse_blade",
name: "The Expanse Blade",
owned: false,
rarity: "legendary",
setId: "infinite_expanse",
type: "weapon",
},
{
bonus: { goldMultiplier: 10 },
description:
"A second iteration of the void's armour — the first was not enough. This one has never been tested to its limit.",
equipped: false,
id: "void_armour_mk2",
name: "Void Armour Mk. II",
owned: false,
rarity: "legendary",
setId: "infinite_expanse",
type: "armour",
},
// ── Reality Forge ─────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 16 },
description:
"The Reality Architect's primary instrument — a sword that does not cut through things but rewrites what they are.",
equipped: false,
id: "cosmos_blade",
name: "The Cosmos Blade",
owned: false,
rarity: "legendary",
setId: "reality_forge",
type: "weapon",
},
{
bonus: { goldMultiplier: 12 },
description:
"Plated from the substance of reality itself — wearing it makes you feel slightly more real than everything around you.",
equipped: false,
id: "reality_plate",
name: "The Reality Plate",
owned: false,
rarity: "legendary",
setId: "reality_forge",
type: "armour",
},
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 18 },
description:
"Torn from the eye of the Cosmic Annihilator — a weapon that carries the force of an ending universe in every swing.",
equipped: false,
id: "maelstrom_edge",
name: "The Maelstrom Edge",
owned: false,
rarity: "legendary",
setId: "cosmic_maelstrom",
type: "weapon",
},
{
bonus: { goldMultiplier: 14 },
description:
"Armour that has weathered the destruction of countless realities. It has learned not to flinch.",
equipped: false,
id: "cosmic_plate",
name: "The Cosmic Plate",
owned: false,
rarity: "legendary",
setId: "cosmic_maelstrom",
type: "armour",
},
// ── Primeval Sanctum ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 22 },
description:
"The first weapon — older than the concept of war, older than the concept of a weapon. It remembers what it was made for.",
equipped: false,
id: "primeval_blade",
name: "The Primeval Blade",
owned: false,
rarity: "legendary",
setId: "primeval_sanctum",
type: "weapon",
},
{
bonus: { goldMultiplier: 17 },
description:
"The shield-form of the Primeval God — absolute protection from before the concept of harm existed.",
equipped: false,
id: "ancient_aegis",
name: "The Ancient Aegis",
owned: false,
rarity: "legendary",
setId: "primeval_sanctum",
type: "armour",
},
// ── The Absolute ──────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 28 },
description:
"There is no name for what this was before it became a sword. There is no name for what it is now. It ends things.",
equipped: false,
id: "absolute_blade",
name: "The Absolute Blade",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "weapon",
},
{
bonus: { goldMultiplier: 20 },
description:
"Eternity given the shape of armour — it has always existed, it will always exist, and it has always protected its wearer.",
equipped: false,
id: "eternity_plate",
name: "The Eternity Plate",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "armour",
},
{
bonus: { clickMultiplier: 6, combatMultiplier: 3, goldMultiplier: 3 },
description:
"The heart of everything — a thing so fundamental that its removal from the Absolute One ended all things, briefly. Briefly.",
equipped: false,
id: "omniversal_core",
name: "The Omniversal Core",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "trinket",
},
// ── Purchasable endgame sinks ───────────────────────────────────────────── // ── Purchasable endgame sinks ─────────────────────────────────────────────
{ {
bonus: { clickMultiplier: 4.25 }, bonus: { clickMultiplier: 4.25 },
@@ -919,7 +757,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour", type: "armour",
}, },
{ {
bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 }, bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
cost: { crystals: 100_000_000, essence: 0, gold: 0 }, cost: { crystals: 100_000_000, essence: 0, gold: 0 },
description: description:
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.", "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10", id: "income_10",
multiplier: 200, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 22_500, runestonesCost: 30_000,
}, },
{ {
category: "income", category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11", id: "income_11",
multiplier: 500, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 60_000, runestonesCost: 80_000,
}, },
// ── Click Power ─────────────────────────────────────────────────────────── // ── Click Power ───────────────────────────────────────────────────────────
{ {
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
{ {
bonus: { type: "combat_power", value: 1.2 }, 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",
@@ -101,7 +101,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
bonus: { type: "combat_power", value: 1.15 }, bonus: { type: "combat_power", value: 1.1 },
description: description:
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.", "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
id: "cursed_focus", id: "cursed_focus",
@@ -127,7 +127,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
bonus: { type: "combat_power", value: 1.2 }, bonus: { type: "combat_power", value: 1.12 },
description: description:
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.", "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
id: "elemental_ore_ingot", id: "elemental_ore_ingot",
@@ -193,7 +193,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 8: abyssal_trench // Zone 8: abyssal_trench
{ {
bonus: { type: "combat_power", value: 1.25 }, bonus: { type: "combat_power", value: 1.15 },
description: description:
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.", "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
id: "pressure_forged_core", id: "pressure_forged_core",
@@ -271,7 +271,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 11: void_sanctum // Zone 11: void_sanctum
{ {
bonus: { type: "combat_power", value: 1.28 }, bonus: { type: "combat_power", value: 1.18 },
description: description:
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.", "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
id: "null_field_generator", id: "null_field_generator",
@@ -309,7 +309,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
bonus: { type: "combat_power", value: 1.3 }, bonus: { type: "combat_power", value: 1.2 },
description: description:
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.", "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
id: "eternity_bound_ring", id: "eternity_bound_ring",
@@ -375,7 +375,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 15: reality_forge // Zone 15: reality_forge
{ {
bonus: { type: "combat_power", value: 1.35 }, bonus: { type: "combat_power", value: 1.22 },
description: description:
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.", "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
id: "reality_ingot", id: "reality_ingot",
@@ -427,7 +427,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 17: primeval_sanctum // Zone 17: primeval_sanctum
{ {
bonus: { type: "combat_power", value: 1.4 }, bonus: { type: "combat_power", value: 1.25 },
description: description:
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.", "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
id: "ancient_memory_array", id: "ancient_memory_array",
@@ -506,7 +506,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
bonus: { type: "combat_power", value: 1.65 }, bonus: { type: "combat_power", value: 1.4 },
description: description:
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.", "An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
id: "eternal_omega", id: "eternal_omega",
@@ -546,7 +546,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
bonus: { type: "combat_power", value: 1.55 }, bonus: { type: "combat_power", value: 1.3 },
description: description:
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.", "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
id: "omega_convergence", id: "omega_convergence",
+3 -3
View File
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ─────────────────────────────────────────────────── // ── Echo meta multipliers ───────────────────────────────────────────────────
{ {
category: "echo_meta", category: "echo_meta",
cost: 15, 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: 45, 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: 100, 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",
+3 -3
View File
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false, unlocked: false,
}, },
{ {
costCrystals: 50, costCrystals: 100,
costEssence: 0, costEssence: 0,
costGold: 0, costGold: 0,
description: description:
@@ -104,7 +104,7 @@ export const defaultUpgrades: Array<Upgrade> = [
description: description:
"Forge partnerships with mage guilds across the realm. All income +50%.", "Forge partnerships with mage guilds across the realm. All income +50%.",
id: "essence_guild", id: "essence_guild",
multiplier: 2, multiplier: 1.5,
name: "Essence Guild", name: "Essence Guild",
purchased: false, purchased: false,
target: "global", target: "global",
@@ -459,7 +459,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false, unlocked: false,
}, },
{ {
costCrystals: 50_000_000, costCrystals: 10_000_000,
costEssence: 0, costEssence: 0,
costGold: 0, costGold: 0,
description: "Transcend mortal limits through void energy. All income x3.", description: "Transcend mortal limits through void energy. All income x3.",
+2 -8
View File
@@ -24,13 +24,6 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
* Must be kept in sync with prestigeCombatBase in apps/web/src/engine/tick.ts.
*/
const prestigeCombatBase = 4;
const bossRouter = new Hono<HonoEnvironment>(); const bossRouter = new Hono<HonoEnvironment>();
bossRouter.use("*", authMiddleware); bossRouter.use("*", authMiddleware);
@@ -45,7 +38,8 @@ const calculatePartyStats = (
} }
} }
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count); // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
// Apply equipped weapon's combat bonus // Apply equipped weapon's combat bonus
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
+2 -26
View File
@@ -102,23 +102,12 @@ prestigeRouter.post("/", async(context) => {
}).length; }).length;
const now = Date.now(); const now = Date.now();
const { updatedAt } = record; await prisma.gameState.update({
/*
* 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, updatedAt }, where: { discordId },
}); });
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,
@@ -147,18 +136,6 @@ 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 */
@@ -170,7 +147,6 @@ 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,7 +47,6 @@ 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,
@@ -223,7 +222,6 @@ 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,
+5 -7
View File
@@ -71,7 +71,8 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result; return result;
}; };
const progressionChallengeTypes: Array<DailyChallengeType> = [ const challengeTypes: Array<DailyChallengeType> = [
"clicks",
"bossesDefeated", "bossesDefeated",
"questsCompleted", "questsCompleted",
"prestige", "prestige",
@@ -79,8 +80,7 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
/** /**
* Generates 3 daily challenges for the given date string, deterministically. * Generates 3 daily challenges for the given date string, deterministically.
* Always includes a "clicks" challenge (always completable regardless of * Picks one challenge from 3 different randomly-selected types.
* 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,10 +88,8 @@ const generateDailyChallenges = (
dateString: string, dateString: string,
): Array<DailyChallenge> => { ): Array<DailyChallenge> => {
const seed = dateSeed(dateString); const seed = dateSeed(dateString);
const selectedTypes: Array<DailyChallengeType> = [ const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
"clicks", slice(0, 3);
...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) => {
+5 -6
View File
@@ -15,7 +15,7 @@ import type {
} from "@elysium/types"; } from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000; const basePrestigeGoldThreshold = 1_000_000;
const runestonesPerPrestigeLevel = 15; const runestonesPerPrestigeLevel = 10;
const milestoneInterval = 5; const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25; const milestoneRunestonesPerInterval = 25;
@@ -146,7 +146,7 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
/** /**
* Calculates the new prestige production multiplier. * Calculates the new prestige production multiplier.
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually * Formula: 1.25^prestigeCount — exponential scaling per prestige that eventually
* overtakes the polynomial threshold growth, making late prestiges progressively easier. * 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.
@@ -154,12 +154,12 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
const calculateProductionMultiplier = ( const calculateProductionMultiplier = (
prestigeCount: number, prestigeCount: number,
): number => { ): number => {
return Math.pow(1.3, prestigeCount); return Math.pow(1.25, prestigeCount);
}; };
/** /**
* Returns the milestone runestone bonus for the given prestige count. * Returns the milestone runestone bonus for the given prestige count.
* Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones. * Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param prestigeCount - The prestige count after the current prestige. * @param prestigeCount - The prestige count after the current prestige.
* @returns The milestone runestone bonus, or 0 if not a milestone prestige. * @returns The milestone runestone bonus, or 0 if not a milestone prestige.
*/ */
@@ -168,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
return 0; return 0;
} }
const milestoneNumber = prestigeCount / milestoneInterval; const milestoneNumber = prestigeCount / milestoneInterval;
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval; return milestoneNumber * milestoneRunestonesPerInterval;
}; };
/** /**
@@ -263,7 +263,6 @@ 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,
-120
View File
@@ -595,18 +595,6 @@ 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"],
@@ -828,18 +816,6 @@ 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"],
@@ -869,42 +845,6 @@ 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("patches boss when only equipmentRewards differ (covers savedRewards branch)", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: [], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 1, 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("patches boss when only bountyRunestones differs with all other fields matching", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, 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"],
@@ -932,18 +872,6 @@ 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"],
@@ -973,18 +901,6 @@ 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"],
@@ -1013,30 +929,6 @@ 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"],
@@ -1065,18 +957,6 @@ 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"],
+8 -28
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: { findUnique: vi.fn(), update: vi.fn() }, player: { update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, gameState: { findUnique: vi.fn(), update: 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: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }; player: { update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> }; gameState: { findUnique: ReturnType<typeof vi.fn>; update: 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, updatedAt: 0 } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} 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,14 +93,6 @@ 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("");
@@ -120,26 +112,14 @@ 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, updatedAt: 0 } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} 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", () => {
+2 -13
View File
@@ -46,24 +46,13 @@ 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");
// The 2 non-clicks types should vary by seed between dates // They should differ in at least one challenge ID (types vary by seed)
const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type); expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type);
expect(day1NonClicks).not.toEqual(day2NonClicks);
}); });
}); });
+13 -13
View File
@@ -102,21 +102,21 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => { describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => { it("calculates basic runestones formula", () => {
// floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15 // floor(cbrt(4_000_000 / 1_000_000)) × 10 = floor(cbrt(4)) × 10 = 1 × 10 = 10
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(15); expect(result).toBe(10);
}); });
it("applies echo runestone multiplier", () => { it("applies echo runestone multiplier", () => {
// floor(cbrt(4)) × 15 = 15; × 2 = 30 // floor(cbrt(4)) × 10 = 10; × 2 = 20
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(30); expect(result).toBe(20);
}); });
it("applies purchased runestone upgrade multiplier", () => { it("applies purchased runestone upgrade multiplier", () => {
// With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18 // With "runestone_gain_1" purchased (multiplier 1.25): floor(10 × 1.25) = 12
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).toBe(18); expect(result).toBe(12);
}); });
it("caps base runestones before multipliers", () => { it("caps base runestones before multipliers", () => {
@@ -131,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
expect(calculateProductionMultiplier(0)).toBe(1); expect(calculateProductionMultiplier(0)).toBe(1);
}); });
it("returns 1.3 at count 1", () => { it("returns 1.25 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3); expect(calculateProductionMultiplier(1)).toBeCloseTo(1.25);
}); });
it("scales exponentially", () => { it("scales exponentially", () => {
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10)); expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.25, 10));
}); });
}); });
@@ -151,12 +151,12 @@ describe("calculateMilestoneBonus", () => {
expect(calculateMilestoneBonus(5)).toBe(25); expect(calculateMilestoneBonus(5)).toBe(25);
}); });
it("returns 100 at prestige 10", () => { it("returns 50 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(100); expect(calculateMilestoneBonus(10)).toBe(50);
}); });
it("returns 225 at prestige 15", () => { it("returns 75 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(225); expect(calculateMilestoneBonus(15)).toBe(75);
}); });
}); });
@@ -9,7 +9,6 @@
/* 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";
@@ -77,19 +76,12 @@ 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;
} }
/** /**
@@ -100,7 +92,6 @@ 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 = ({
@@ -109,7 +100,6 @@ const AdventurerCard = ({
batchSize, batchSize,
unlockHint, unlockHint,
formatNumber, formatNumber,
effectiveStats,
}: AdventurerCardProperties): JSX.Element => { }: AdventurerCardProperties): JSX.Element => {
const { buyAdventurer } = useGame(); const { buyAdventurer } = useGame();
@@ -144,17 +134,17 @@ const AdventurerCard = ({
<div className="adventurer-info"> <div className="adventurer-info">
<h3>{adventurer.name}</h3> <h3>{adventurer.name}</h3>
<p> <p>
{formatNumber(effectiveStats.goldPerSecond)} {formatNumber(adventurer.goldPerSecond)}
{" gold/s each"} {" gold/s each"}
</p> </p>
{adventurer.essencePerSecond > 0 {adventurer.essencePerSecond > 0
&& <p> && <p>
{formatNumber(effectiveStats.essencePerSecond)} {formatNumber(adventurer.essencePerSecond)}
{" essence/s each"} {" essence/s each"}
</p> </p>
} }
<p> <p>
{formatNumber(effectiveStats.combatPower)} {formatNumber(adventurer.combatPower)}
{" combat power each"} {" combat power each"}
</p> </p>
</div> </div>
@@ -290,10 +280,6 @@ 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)}
@@ -49,40 +49,6 @@ const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
zone: "zones", zone: "zones",
}; };
/**
* Converts a snake_case ID to a Title Case display name.
* @param id - The snake_case identifier to format.
* @returns The formatted display name.
*/
const formatId = (id: string): string => {
return id.split("_").
map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
}).
join(" ");
};
/**
* Generates a human-readable unlock hint for a locked codex entry.
* @param entry - The locked codex entry.
* @returns A string describing how to unlock the entry.
*/
const buildUnlockHint = (entry: CodexEntry): string => {
const name = formatId(entry.sourceId);
switch (entry.sourceType) {
case "boss": return `Defeat ${name}`;
case "quest": return `Complete: ${name}`;
case "equipment": return `Obtain: ${name}`;
case "adventurer": return `Recruit a ${name}`;
case "upgrade": return `Purchase: ${name}`;
case "prestige": return `Purchase runestone upgrade: ${name}`;
case "zone": return `Explore: ${name}`;
case "exploration": return `Discover: ${name}`;
case "recipe": return `Craft: ${name}`;
default: return "Keep playing to unlock";
}
};
/** /**
* Renders the codex panel with lore entries grouped by zone. * Renders the codex panel with lore entries grouped by zone.
* @returns The JSX element. * @returns The JSX element.
@@ -170,9 +136,6 @@ const CodexPanel = (): JSX.Element => {
<span className="codex-lock">{"🔒"}</span> <span className="codex-lock">{"🔒"}</span>
<span className="codex-entry-title">{"???"}</span> <span className="codex-entry-title">{"???"}</span>
</div> </div>
<p className="codex-unlock-hint">
{buildUnlockHint(entry)}
</p>
</div> </div>
); );
} }
@@ -225,10 +225,6 @@ 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";
@@ -421,23 +417,6 @@ 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">
+37 -9
View File
@@ -12,27 +12,25 @@ 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_UPGRADE_CATEGORY_LABELS,
PRESTIGE_UPGRADES, PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS,
} 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(prestigeCount + 1, 2); return baseThreshold * Math.pow(thresholdScale, prestigeCount);
}; };
/** /**
@@ -44,6 +42,32 @@ 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",
@@ -60,7 +84,7 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
const PrestigePanel = (): JSX.Element => { const PrestigePanel = (): JSX.Element => {
const { const {
state, state,
reloadSilent, reload,
formatNumber, formatNumber,
buyPrestigeUpgrade, buyPrestigeUpgrade,
enableNotifications, enableNotifications,
@@ -90,7 +114,11 @@ 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 = computeProjectedRunestones(state); const runestonePreview = calculateRunestonePreview(
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> {
@@ -113,7 +141,7 @@ const PrestigePanel = (): JSX.Element => {
`You've reached prestige level ${data.newPrestigeCount.toString()}!`, `You've reached prestige level ${data.newPrestigeCount.toString()}!`,
); );
} }
await reloadSilent(); await reload();
} catch (error_: unknown) { } catch (error_: unknown) {
setPrestigeError( setPrestigeError(
error_ instanceof Error error_ instanceof Error
+7 -6
View File
@@ -11,10 +11,7 @@
/* 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 { import { zoneFailureChance } from "../../engine/tick.js";
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";
@@ -211,7 +208,7 @@ const QuestPanel = (): JSX.Element => {
); );
} }
const { autoQuest, bosses, quests, zones } = state; const { adventurers, autoQuest, bosses, quests, zones } = state;
const activeZone = zones.find((zone) => { const activeZone = zones.find((zone) => {
return zone.id === activeZoneId; return zone.id === activeZoneId;
@@ -229,7 +226,11 @@ const QuestPanel = (): JSX.Element => {
: quests.find((quest) => { : quests.find((quest) => {
return quest.id === activeZone.unlockQuestId; return quest.id === activeZone.unlockQuestId;
}); });
const partyCombatPower = computePartyCombatPower(state); let partyCombatPower = 0;
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,7 +15,6 @@ 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";
@@ -90,12 +89,10 @@ 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;
@@ -237,13 +234,6 @@ 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">
+1 -70
View File
@@ -53,7 +53,6 @@ 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,
@@ -117,9 +116,6 @@ 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
@@ -220,23 +216,6 @@ 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;
}),
},
},
}; };
} }
@@ -310,12 +289,6 @@ 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).
*/ */
@@ -724,10 +697,6 @@ 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);
@@ -815,32 +784,6 @@ 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 ]);
@@ -1351,7 +1294,7 @@ export const GameProvider = ({
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!"); sendNotification("⭐ Prestige!", "You have ascended!");
} }
await reloadSilentReference.current(); await reloadReference.current();
}). }).
catch(() => { catch(() => {
@@ -1867,18 +1810,7 @@ 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;
@@ -2409,7 +2341,6 @@ export const GameProvider = ({
offlineEssence, offlineEssence,
offlineGold, offlineGold,
reload, reload,
reloadSilent,
resetProgress, resetProgress,
saveSchemaVersion, saveSchemaVersion,
schemaOutdated, schemaOutdated,
+9 -9
View File
@@ -24,7 +24,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
{ {
bonus: { type: "combat_power", value: 1.2 }, bonus: { type: "combat_power", value: 1.08 },
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",
@@ -102,7 +102,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
bonus: { type: "combat_power", value: 1.15 }, bonus: { type: "combat_power", value: 1.1 },
description: description:
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.", "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
id: "cursed_focus", id: "cursed_focus",
@@ -128,7 +128,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
bonus: { type: "combat_power", value: 1.2 }, bonus: { type: "combat_power", value: 1.12 },
description: description:
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.", "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
id: "elemental_ore_ingot", id: "elemental_ore_ingot",
@@ -194,7 +194,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 8: abyssal_trench // Zone 8: abyssal_trench
{ {
bonus: { type: "combat_power", value: 1.25 }, bonus: { type: "combat_power", value: 1.15 },
description: description:
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.", "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
id: "pressure_forged_core", id: "pressure_forged_core",
@@ -272,7 +272,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 11: void_sanctum // Zone 11: void_sanctum
{ {
bonus: { type: "combat_power", value: 1.28 }, bonus: { type: "combat_power", value: 1.18 },
description: description:
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.", "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
id: "null_field_generator", id: "null_field_generator",
@@ -310,7 +310,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
bonus: { type: "combat_power", value: 1.3 }, bonus: { type: "combat_power", value: 1.2 },
description: description:
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.", "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
id: "eternity_bound_ring", id: "eternity_bound_ring",
@@ -376,7 +376,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 15: reality_forge // Zone 15: reality_forge
{ {
bonus: { type: "combat_power", value: 1.35 }, bonus: { type: "combat_power", value: 1.22 },
description: description:
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.", "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
id: "reality_ingot", id: "reality_ingot",
@@ -428,7 +428,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 17: primeval_sanctum // Zone 17: primeval_sanctum
{ {
bonus: { type: "combat_power", value: 1.4 }, bonus: { type: "combat_power", value: 1.25 },
description: description:
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.", "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
id: "ancient_memory_array", id: "ancient_memory_array",
@@ -466,7 +466,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
bonus: { type: "combat_power", value: 1.55 }, bonus: { type: "combat_power", value: 1.3 },
description: description:
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.", "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
id: "omega_convergence", id: "omega_convergence",
+12 -188
View File
@@ -11,6 +11,7 @@
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */ /* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */ /* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */ /* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */ /* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
import { import {
type Achievement, type Achievement,
@@ -20,7 +21,6 @@ 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";
/** /**
@@ -83,12 +83,6 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
}); });
}; };
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
*/
export const PRESTIGE_COMBAT_BASE = 4;
/** /**
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. * Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
*/ */
@@ -249,128 +243,10 @@ 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;
const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
const craftedGoldMultiplier
= state.exploration?.craftedGoldMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionGoldMult
= companionBonus?.type === "passiveGold"
? 1 + companionBonus.value
: 1;
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
const goldPerSecond
= adventurer.goldPerSecond
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesIncome
* echoIncome
* equipmentGoldMultiplier
* setBonuses.goldMultiplier
* craftedGoldMultiplier
* companionGoldMult;
const essencePerSecond
= adventurer.essencePerSecond
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
const combatPower
= adventurer.combatPower
* upgradeMultiplier
* prestigeCombatMultiplier
* equipmentCombatMultiplier
* setBonuses.combatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
return { combatPower, essencePerSecond, goldPerSecond };
};
/** /**
* Computes the party's total combat power, applying all active multipliers * 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 and is the * This mirrors the server-side calculatePartyStats in boss.ts.
* 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.
*/ */
@@ -382,7 +258,8 @@ export const computePartyCombatPower = (state: GameState): number => {
} }
} }
const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const equipmentCombatMultiplier = state.equipment. const equipmentCombatMultiplier = state.equipment.
filter((item) => { filter((item) => {
@@ -450,36 +327,6 @@ 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;
const echoMult: number
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 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.
@@ -787,23 +634,6 @@ 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,
@@ -817,29 +647,23 @@ export const applyTick = (
zones: updatedZones, zones: updatedZones,
}; };
// Check achievements and apply crystal and runestone rewards for newly unlocked ones // Check achievements and apply crystal rewards for newly unlocked ones
const updatedAchievements = checkAchievements(partialState); const updatedAchievements = checkAchievements(partialState);
let crystalsFromAchievements = 0; const crystalsFromAchievements = updatedAchievements.reduce(
let runestonesFromAchievements = 0; (sum, achievement, index) => {
for (const [ index, achievement ] of updatedAchievements.entries()) {
const wasLocked = state.achievements[index]?.unlockedAt === null; const wasLocked = state.achievements[index]?.unlockedAt === null;
const isNowUnlocked = achievement.unlockedAt !== null; const isNowUnlocked = achievement.unlockedAt !== null;
if (wasLocked && isNowUnlocked) { if (wasLocked && isNowUnlocked) {
crystalsFromAchievements return sum + (achievement.reward?.crystals ?? 0);
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
runestonesFromAchievements
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
}
} }
return sum;
},
0,
);
return { return {
...partialState, ...partialState,
achievements: updatedAchievements, achievements: updatedAchievements,
prestige: {
...partialState.prestige,
runestones:
partialState.prestige.runestones + runestonesFromAchievements,
},
resources: { resources: {
...partialState.resources, ...partialState.resources,
crystals: capResource( crystals: capResource(
@@ -21,7 +21,6 @@ interface AchievementCondition {
interface AchievementReward { interface AchievementReward {
crystals?: number; crystals?: number;
runestones?: number;
} }
interface Achievement { interface Achievement {
@@ -48,17 +48,11 @@ 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,