diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index 4122311..b40976d 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -149,7 +149,7 @@ export const defaultAchievements: Array = [ }, { condition: { amount: 18, type: "bossesDefeated" }, - description: "Defeat all 18 bosses across the first six zones.", + description: "Defeat the 18 bosses of the mortal realms.", icon: "🌟", id: "devourer_slayer", name: "World Saver", @@ -223,8 +223,8 @@ export const defaultAchievements: Array = [ unlockedAt: null, }, { - condition: { amount: 65, type: "equipmentOwned" }, - description: "Own all 65 pieces of equipment.", + condition: { amount: 78, type: "equipmentOwned" }, + description: "Own all 78 pieces of equipment.", icon: "🛡️", id: "fully_equipped", name: "Fully Equipped", @@ -269,6 +269,33 @@ export const defaultAchievements: Array = [ reward: { crystals: 50_000 }, 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 { condition: { amount: 30, type: "questsCompleted" }, @@ -289,8 +316,26 @@ export const defaultAchievements: Array = [ unlockedAt: null, }, { - condition: { amount: 95, type: "questsCompleted" }, - description: "Complete all 95 quests across the known multiverse.", + condition: { amount: 75, type: "questsCompleted" }, + description: "Complete 75 quests.", + 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: "🌌", id: "quest_eternal", name: "Quest Eternal", @@ -316,6 +361,15 @@ export const defaultAchievements: Array = [ reward: { crystals: 5000 }, 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" }, description: "Defeat all 72 bosses across every plane of existence.", @@ -363,4 +417,40 @@ export const defaultAchievements: Array = [ reward: { crystals: 25_000 }, 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, + }, ]; diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 4079373..7294344 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -26,7 +26,7 @@ export const defaultAdventurers: Array = [ combatPower: 3, count: 0, essencePerSecond: 0, - goldPerSecond: 0.5, + goldPerSecond: 0.7, id: "militia", level: 2, name: "Militia", @@ -129,7 +129,7 @@ export const defaultAdventurers: Array = [ unlocked: false, }, { - baseCost: 2_600_000_000, + baseCost: 2_850_000_000, class: "mage", combatPower: 13_000, count: 0, @@ -141,7 +141,7 @@ export const defaultAdventurers: Array = [ unlocked: false, }, { - baseCost: 11_000_000_000, + baseCost: 13_500_000_000, class: "rogue", combatPower: 28_000, count: 0, @@ -153,7 +153,7 @@ export const defaultAdventurers: Array = [ unlocked: false, }, { - baseCost: 47_000_000_000, + baseCost: 64_000_000_000, class: "paladin", combatPower: 60_000, count: 0, @@ -165,7 +165,7 @@ export const defaultAdventurers: Array = [ unlocked: false, }, { - baseCost: 200_000_000_000, + baseCost: 300_000_000_000, class: "rogue", combatPower: 130_000, count: 0, @@ -177,7 +177,7 @@ export const defaultAdventurers: Array = [ unlocked: false, }, { - baseCost: 1_400_000_000_000, + baseCost: 1_800_000_000_000, class: "paladin", combatPower: 400_000, count: 0, diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 16f6b16..fbc7011 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -12,7 +12,7 @@ export const defaultBosses: Array = [ // ── Verdant Vale ────────────────────────────────────────────────────────── { bountyRunestones: 1, - crystalReward: 0, + crystalReward: 5, currentHp: 1000, damagePerSecond: 5, description: @@ -122,7 +122,7 @@ export const defaultBosses: Array = [ // ── Shadow Marshes ──────────────────────────────────────────────────────── { bountyRunestones: 20, - crystalReward: 700, + crystalReward: 1500, currentHp: 6_000_000, damagePerSecond: 1200, description: @@ -140,7 +140,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 25, - crystalReward: 1500, + crystalReward: 3000, currentHp: 12_000_000, damagePerSecond: 2400, description: @@ -158,7 +158,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 30, - crystalReward: 3000, + crystalReward: 6000, currentHp: 20_000_000, damagePerSecond: 4000, description: @@ -226,7 +226,7 @@ export const defaultBosses: Array = [ name: "The Void Titan", prestigeRequirement: 0, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "dark_templar_1" ], zoneId: "frozen_peaks", }, // ── Volcanic Depths ─────────────────────────────────────────────────────── @@ -353,14 +353,14 @@ export const defaultBosses: Array = [ id: "seraph_guardian", maxHp: 500_000_000, name: "The Seraph Guardian", - prestigeRequirement: 6, + prestigeRequirement: 1, status: "locked", upgradeRewards: [ "click_4" ], zoneId: "celestial_reaches", }, { bountyRunestones: 40, - crystalReward: 40_000, + crystalReward: 0, currentHp: 2_000_000_000, damagePerSecond: 120_000, description: @@ -371,14 +371,14 @@ export const defaultBosses: Array = [ id: "fallen_archangel", maxHp: 2_000_000_000, name: "The Fallen Archangel", - prestigeRequirement: 7, + prestigeRequirement: 2, status: "locked", upgradeRewards: [], zoneId: "celestial_reaches", }, { bountyRunestones: 50, - crystalReward: 100_000, + crystalReward: 0, currentHp: 8_000_000_000, damagePerSecond: 350_000, description: @@ -389,14 +389,14 @@ export const defaultBosses: Array = [ id: "divine_judge", maxHp: 8_000_000_000, name: "The Divine Judge", - prestigeRequirement: 8, + prestigeRequirement: 2, status: "locked", upgradeRewards: [ "divine_covenant" ], zoneId: "celestial_reaches", }, { bountyRunestones: 60, - crystalReward: 300_000, + crystalReward: 0, currentHp: 30_000_000_000, damagePerSecond: 1_000_000, description: @@ -407,14 +407,14 @@ export const defaultBosses: Array = [ id: "celestial_titan", maxHp: 30_000_000_000, name: "The Celestial Titan", - prestigeRequirement: 9, + prestigeRequirement: 2, status: "locked", upgradeRewards: [], zoneId: "celestial_reaches", }, { bountyRunestones: 75, - crystalReward: 800_000, + crystalReward: 0, currentHp: 100_000_000_000, damagePerSecond: 3_000_000, description: @@ -425,7 +425,7 @@ export const defaultBosses: Array = [ id: "the_first_light", maxHp: 100_000_000_000, name: "The First Light", - prestigeRequirement: 10, + prestigeRequirement: 2, status: "locked", upgradeRewards: [], zoneId: "celestial_reaches", @@ -433,7 +433,7 @@ export const defaultBosses: Array = [ // ── Abyssal Trench ──────────────────────────────────────────────────────── { bountyRunestones: 40, - crystalReward: 1_500_000, + crystalReward: 0, currentHp: 250_000_000_000, damagePerSecond: 5_000_000, description: @@ -444,14 +444,14 @@ export const defaultBosses: Array = [ id: "depth_leviathan", maxHp: 250_000_000_000, name: "The Depth Leviathan", - prestigeRequirement: 9, + prestigeRequirement: 2, status: "locked", upgradeRewards: [], zoneId: "abyssal_trench", }, { bountyRunestones: 55, - crystalReward: 4_000_000, + crystalReward: 0, currentHp: 1_000_000_000_000, damagePerSecond: 15_000_000, description: @@ -462,14 +462,14 @@ export const defaultBosses: Array = [ id: "kraken_elder", maxHp: 1_000_000_000_000, name: "The Elder Kraken", - prestigeRequirement: 10, + prestigeRequirement: 2, status: "locked", upgradeRewards: [ "abyssal_pact" ], zoneId: "abyssal_trench", }, { bountyRunestones: 70, - crystalReward: 12_000_000, + crystalReward: 0, currentHp: 4_000_000_000_000, damagePerSecond: 50_000_000, description: @@ -480,14 +480,14 @@ export const defaultBosses: Array = [ id: "abyssal_colossus", maxHp: 4_000_000_000_000, name: "The Abyssal Colossus", - prestigeRequirement: 11, + prestigeRequirement: 2, status: "locked", upgradeRewards: [], zoneId: "abyssal_trench", }, { bountyRunestones: 85, - crystalReward: 40_000_000, + crystalReward: 0, currentHp: 15_000_000_000_000, damagePerSecond: 150_000_000, description: @@ -498,14 +498,14 @@ export const defaultBosses: Array = [ id: "the_deep_one", maxHp: 15_000_000_000_000, name: "The Deep One", - prestigeRequirement: 12, + prestigeRequirement: 3, status: "locked", upgradeRewards: [ "global_4" ], zoneId: "abyssal_trench", }, { bountyRunestones: 100, - crystalReward: 150_000_000, + crystalReward: 0, currentHp: 50_000_000_000_000, damagePerSecond: 500_000_000, description: @@ -516,7 +516,7 @@ export const defaultBosses: Array = [ id: "elder_abomination", maxHp: 50_000_000_000_000, name: "The Elder Abomination", - prestigeRequirement: 13, + prestigeRequirement: 3, status: "locked", upgradeRewards: [], zoneId: "abyssal_trench", @@ -524,7 +524,7 @@ export const defaultBosses: Array = [ // ── Infernal Court ──────────────────────────────────────────────────────── { bountyRunestones: 55, - crystalReward: 350_000_000, + crystalReward: 0, currentHp: 120_000_000_000_000, damagePerSecond: 800_000_000, description: @@ -535,14 +535,14 @@ export const defaultBosses: Array = [ id: "demon_prince", maxHp: 120_000_000_000_000, name: "The Demon Prince", - prestigeRequirement: 12, + prestigeRequirement: 3, status: "locked", upgradeRewards: [], zoneId: "infernal_court", }, { bountyRunestones: 70, - crystalReward: 1_000_000_000, + crystalReward: 0, currentHp: 500_000_000_000_000, damagePerSecond: 2_500_000_000, description: @@ -553,14 +553,14 @@ export const defaultBosses: Array = [ id: "hellfire_titan", maxHp: 500_000_000_000_000, name: "The Hellfire Titan", - prestigeRequirement: 13, + prestigeRequirement: 3, status: "locked", upgradeRewards: [ "celestial_mandate" ], zoneId: "infernal_court", }, { bountyRunestones: 90, - crystalReward: 3_000_000_000, + crystalReward: 0, currentHp: 2_000_000_000_000_000, damagePerSecond: 8_000_000_000, description: @@ -571,14 +571,14 @@ export const defaultBosses: Array = [ id: "lord_of_sin", maxHp: 2_000_000_000_000_000, name: "The Lord of Sin", - prestigeRequirement: 14, + prestigeRequirement: 3, status: "locked", upgradeRewards: [], zoneId: "infernal_court", }, { bountyRunestones: 110, - crystalReward: 10_000_000_000, + crystalReward: 0, currentHp: 6_000_000_000_000_000, damagePerSecond: 25_000_000_000, description: @@ -589,14 +589,14 @@ export const defaultBosses: Array = [ id: "infernal_sovereign", maxHp: 6_000_000_000_000_000, name: "The Infernal Sovereign", - prestigeRequirement: 15, + prestigeRequirement: 3, status: "locked", upgradeRewards: [ "click_5" ], zoneId: "infernal_court", }, { bountyRunestones: 135, - crystalReward: 30_000_000_000, + crystalReward: 0, currentHp: 8_000_000_000_000_000, damagePerSecond: 80_000_000_000, description: @@ -607,7 +607,7 @@ export const defaultBosses: Array = [ id: "the_fallen", maxHp: 8_000_000_000_000_000, name: "The Fallen", - prestigeRequirement: 16, + prestigeRequirement: 4, status: "locked", upgradeRewards: [], zoneId: "infernal_court", @@ -615,7 +615,7 @@ export const defaultBosses: Array = [ // ── Crystalline Spire ───────────────────────────────────────────────────── { bountyRunestones: 70, - crystalReward: 8e10, + crystalReward: 0, currentHp: 2e16, damagePerSecond: 120_000_000_000, description: @@ -626,14 +626,14 @@ export const defaultBosses: Array = [ id: "prism_golem", maxHp: 2e16, name: "The Prism Golem", - prestigeRequirement: 15, + prestigeRequirement: 3, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "crystal_sage_1" ], zoneId: "crystalline_spire", }, { bountyRunestones: 90, - crystalReward: 3e11, + crystalReward: 0, currentHp: 8e16, damagePerSecond: 4e11, description: @@ -644,14 +644,14 @@ export const defaultBosses: Array = [ id: "crystal_drake", maxHp: 8e16, name: "The Crystal Drake", - prestigeRequirement: 16, + prestigeRequirement: 4, status: "locked", upgradeRewards: [ "void_ascendancy" ], zoneId: "crystalline_spire", }, { bountyRunestones: 115, - crystalReward: 1e12, + crystalReward: 0, currentHp: 3e17, damagePerSecond: 1.2e12, description: @@ -662,14 +662,14 @@ export const defaultBosses: Array = [ id: "the_faceted", maxHp: 3e17, name: "The Faceted", - prestigeRequirement: 17, + prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "void_sentinel_1" ], zoneId: "crystalline_spire", }, { bountyRunestones: 140, - crystalReward: 4e12, + crystalReward: 0, currentHp: 1e18, damagePerSecond: 4e12, description: @@ -680,14 +680,14 @@ export const defaultBosses: Array = [ id: "diamond_colossus", maxHp: 1e18, name: "The Diamond Colossus", - prestigeRequirement: 18, + prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "eternal_champion_1" ], zoneId: "crystalline_spire", }, { bountyRunestones: 175, - crystalReward: 1.5e13, + crystalReward: 0, currentHp: 4e18, damagePerSecond: 1.5e13, description: @@ -698,15 +698,15 @@ export const defaultBosses: Array = [ id: "crystal_sovereign", maxHp: 4e18, name: "The Crystal Sovereign", - prestigeRequirement: 19, + prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "cosmos_knight_1" ], zoneId: "crystalline_spire", }, // ── Void Sanctum ────────────────────────────────────────────────────────── { bountyRunestones: 90, - crystalReward: 4e13, + crystalReward: 0, currentHp: 1e19, damagePerSecond: 4e13, description: @@ -717,14 +717,14 @@ export const defaultBosses: Array = [ id: "void_herald", maxHp: 1e19, name: "The Void Herald", - prestigeRequirement: 18, + prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "seraph_knight_1" ], zoneId: "void_sanctum", }, { bountyRunestones: 115, - crystalReward: 1.5e14, + crystalReward: 0, currentHp: 5e19, damagePerSecond: 1.5e14, description: @@ -735,14 +735,14 @@ export const defaultBosses: Array = [ id: "eternal_shade", maxHp: 5e19, name: "The Eternal Shade", - prestigeRequirement: 19, + prestigeRequirement: 4, status: "locked", upgradeRewards: [ "divine_harmony" ], zoneId: "void_sanctum", }, { bountyRunestones: 145, - crystalReward: 5e14, + crystalReward: 0, currentHp: 2e20, damagePerSecond: 5e14, description: @@ -753,14 +753,14 @@ export const defaultBosses: Array = [ id: "the_unmaker", maxHp: 2e20, name: "The Unmaker", - prestigeRequirement: 20, + prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "abyss_diver_1" ], zoneId: "void_sanctum", }, { bountyRunestones: 180, - crystalReward: 2e15, + crystalReward: 0, currentHp: 8e20, damagePerSecond: 2e15, description: @@ -771,14 +771,14 @@ export const defaultBosses: Array = [ id: "void_progenitor", maxHp: 8e20, name: "The Void Progenitor", - prestigeRequirement: 21, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "void_sanctum", }, { bountyRunestones: 225, - crystalReward: 8e15, + crystalReward: 0, currentHp: 3e21, damagePerSecond: 8e15, description: @@ -789,15 +789,15 @@ export const defaultBosses: Array = [ id: "void_emperor", maxHp: 3e21, name: "The Void Emperor", - prestigeRequirement: 22, + prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "infernal_warden_1" ], zoneId: "void_sanctum", }, // ── Eternal Throne ──────────────────────────────────────────────────────── { bountyRunestones: 115, - crystalReward: 2e16, + crystalReward: 0, currentHp: 1e22, damagePerSecond: 2e16, description: @@ -808,14 +808,14 @@ export const defaultBosses: Array = [ id: "throne_warden", maxHp: 1e22, name: "The Throne Warden", - prestigeRequirement: 21, + prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "infinity_ranger_1" ], zoneId: "eternal_throne", }, { bountyRunestones: 150, - crystalReward: 8e16, + crystalReward: 0, currentHp: 5e22, damagePerSecond: 8e16, description: @@ -826,14 +826,14 @@ export const defaultBosses: Array = [ id: "eternal_knight", maxHp: 5e22, name: "The Eternal Knight", - prestigeRequirement: 22, + prestigeRequirement: 5, status: "locked", upgradeRewards: [ "infernal_fury" ], zoneId: "eternal_throne", }, { bountyRunestones: 190, - crystalReward: 3e17, + crystalReward: 0, currentHp: 2e23, damagePerSecond: 3e17, description: @@ -844,14 +844,14 @@ export const defaultBosses: Array = [ id: "the_undying", maxHp: 2e23, name: "The Undying", - prestigeRequirement: 23, + prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "reality_warden_1" ], zoneId: "eternal_throne", }, { bountyRunestones: 235, - crystalReward: 1.2e18, + crystalReward: 0, currentHp: 8e23, damagePerSecond: 1.2e18, description: @@ -862,14 +862,14 @@ export const defaultBosses: Array = [ id: "apex_sovereign", maxHp: 8e23, name: "The Apex Sovereign", - prestigeRequirement: 24, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "eternal_throne", }, { bountyRunestones: 295, - crystalReward: 5e18, + crystalReward: 0, currentHp: 3e24, damagePerSecond: 5e18, description: @@ -880,7 +880,7 @@ export const defaultBosses: Array = [ id: "the_apex", maxHp: 3e24, name: "The Apex", - prestigeRequirement: 25, + prestigeRequirement: 6, status: "locked", upgradeRewards: [], zoneId: "eternal_throne", @@ -888,7 +888,7 @@ export const defaultBosses: Array = [ // ── Primordial Chaos ────────────────────────────────────────────────────── { bountyRunestones: 150, - crystalReward: 2e20, + crystalReward: 0, currentHp: 1e26, damagePerSecond: 2e20, description: @@ -899,14 +899,14 @@ export const defaultBosses: Array = [ id: "chaos_wyrm", maxHp: 1e26, name: "The Chaos Wyrm", - prestigeRequirement: 26, + prestigeRequirement: 6, status: "locked", upgradeRewards: [], zoneId: "primordial_chaos", }, { bountyRunestones: 200, - crystalReward: 8e21, + crystalReward: 0, currentHp: 5e27, damagePerSecond: 8e21, description: @@ -917,14 +917,14 @@ export const defaultBosses: Array = [ id: "creation_engine", maxHp: 5e27, name: "The Creation Engine", - prestigeRequirement: 27, + prestigeRequirement: 6, status: "locked", upgradeRewards: [ "aether_weaver_1" ], zoneId: "primordial_chaos", }, { bountyRunestones: 265, - crystalReward: 4e23, + crystalReward: 0, currentHp: 2e29, damagePerSecond: 4e23, description: @@ -935,14 +935,14 @@ export const defaultBosses: Array = [ id: "entropy_avatar", maxHp: 2e29, name: "The Entropy Avatar", - prestigeRequirement: 29, + prestigeRequirement: 7, status: "locked", upgradeRewards: [], zoneId: "primordial_chaos", }, { bountyRunestones: 350, - crystalReward: 2e25, + crystalReward: 0, currentHp: 8e30, damagePerSecond: 2e25, description: @@ -953,7 +953,7 @@ export const defaultBosses: Array = [ id: "primordial_titan", maxHp: 8e30, name: "The Primordial Titan", - prestigeRequirement: 31, + prestigeRequirement: 7, status: "locked", upgradeRewards: [], zoneId: "primordial_chaos", @@ -961,7 +961,7 @@ export const defaultBosses: Array = [ // ── Infinite Expanse ────────────────────────────────────────────────────── { bountyRunestones: 200, - crystalReward: 8e27, + crystalReward: 0, currentHp: 3e33, damagePerSecond: 8e27, description: @@ -972,15 +972,15 @@ export const defaultBosses: Array = [ id: "expanse_drifter", maxHp: 3e33, name: "The Expanse Drifter", - prestigeRequirement: 33, + prestigeRequirement: 8, status: "locked", upgradeRewards: [ "titan_warrior_1" ], zoneId: "infinite_expanse", }, { bountyRunestones: 265, - crystalReward: 3e31, - currentHp: 1e37, + crystalReward: 0, + currentHp: 2e35, damagePerSecond: 3e31, 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.", @@ -988,17 +988,17 @@ export const defaultBosses: Array = [ essenceReward: 1e34, goldReward: 1e38, id: "horizon_beast", - maxHp: 1e37, + maxHp: 2e35, name: "The Horizon Beast", - prestigeRequirement: 35, + prestigeRequirement: 8, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "oblivion_paladin_1" ], zoneId: "infinite_expanse", }, { bountyRunestones: 350, - crystalReward: 1e35, - currentHp: 5e40, + crystalReward: 0, + currentHp: 5e37, damagePerSecond: 1e35, 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.", @@ -1006,17 +1006,17 @@ export const defaultBosses: Array = [ essenceReward: 5e37, goldReward: 5e41, id: "infinity_construct", - maxHp: 5e40, + maxHp: 5e37, name: "The Infinity Construct", - prestigeRequirement: 37, + prestigeRequirement: 8, status: "locked", upgradeRewards: [], zoneId: "infinite_expanse", }, { bountyRunestones: 465, - crystalReward: 5e38, - currentHp: 2e44, + crystalReward: 0, + currentHp: 3e39, damagePerSecond: 5e38, 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.", @@ -1024,9 +1024,9 @@ export const defaultBosses: Array = [ essenceReward: 2e41, goldReward: 2e45, id: "expanse_sovereign", - maxHp: 2e44, + maxHp: 3e39, name: "The Expanse Sovereign", - prestigeRequirement: 39, + prestigeRequirement: 9, status: "locked", upgradeRewards: [], zoneId: "infinite_expanse", @@ -1034,7 +1034,7 @@ export const defaultBosses: Array = [ // ── Reality Forge ───────────────────────────────────────────────────────── { bountyRunestones: 265, - crystalReward: 2e42, + crystalReward: 0, currentHp: 8e47, damagePerSecond: 2e42, description: @@ -1045,14 +1045,14 @@ export const defaultBosses: Array = [ id: "forge_guardian", maxHp: 8e47, name: "The Forge Guardian", - prestigeRequirement: 41, + prestigeRequirement: 9, status: "locked", upgradeRewards: [ "nexus_sage_1" ], zoneId: "reality_forge", }, { bountyRunestones: 350, - crystalReward: 1e47, + crystalReward: 0, currentHp: 4e52, damagePerSecond: 1e47, description: @@ -1063,14 +1063,14 @@ export const defaultBosses: Array = [ id: "reality_shaper", maxHp: 4e52, name: "The Reality Shaper", - prestigeRequirement: 44, + prestigeRequirement: 10, status: "locked", upgradeRewards: [], zoneId: "reality_forge", }, { bountyRunestones: 465, - crystalReward: 6e51, + crystalReward: 0, currentHp: 2e57, damagePerSecond: 6e51, description: @@ -1081,14 +1081,14 @@ export const defaultBosses: Array = [ id: "creation_prime", maxHp: 2e57, name: "The Creation Prime", - prestigeRequirement: 47, + prestigeRequirement: 11, status: "locked", upgradeRewards: [], zoneId: "reality_forge", }, { bountyRunestones: 615, - crystalReward: 2e56, + crystalReward: 0, currentHp: 8e61, damagePerSecond: 2e56, description: @@ -1099,7 +1099,7 @@ export const defaultBosses: Array = [ id: "reality_architect", maxHp: 8e61, name: "The Reality Architect", - prestigeRequirement: 49, + prestigeRequirement: 11, status: "locked", upgradeRewards: [], zoneId: "reality_forge", @@ -1107,7 +1107,7 @@ export const defaultBosses: Array = [ // ── Cosmic Maelstrom ────────────────────────────────────────────────────── { bountyRunestones: 350, - crystalReward: 1e60, + crystalReward: 0, currentHp: 4e65, damagePerSecond: 1e60, description: @@ -1118,14 +1118,14 @@ export const defaultBosses: Array = [ id: "storm_colossus", maxHp: 4e65, name: "The Storm Colossus", - prestigeRequirement: 51, + prestigeRequirement: 12, status: "locked", upgradeRewards: [], zoneId: "cosmic_maelstrom", }, { bountyRunestones: 465, - crystalReward: 6e65, + crystalReward: 0, currentHp: 2e71, damagePerSecond: 6e65, description: @@ -1136,14 +1136,14 @@ export const defaultBosses: Array = [ id: "force_prime", maxHp: 2e71, name: "The Force Prime", - prestigeRequirement: 54, + prestigeRequirement: 12, status: "locked", upgradeRewards: [], zoneId: "cosmic_maelstrom", }, { bountyRunestones: 615, - crystalReward: 3e71, + crystalReward: 0, currentHp: 1e77, damagePerSecond: 3e71, description: @@ -1154,14 +1154,14 @@ export const defaultBosses: Array = [ id: "maelstrom_god", maxHp: 1e77, name: "The Maelstrom God", - prestigeRequirement: 57, + prestigeRequirement: 13, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "transcendent_rogue_1" ], zoneId: "cosmic_maelstrom", }, { bountyRunestones: 815, - crystalReward: 1e77, + crystalReward: 0, currentHp: 5e82, damagePerSecond: 1e77, description: @@ -1172,7 +1172,7 @@ export const defaultBosses: Array = [ id: "cosmic_annihilator", maxHp: 5e82, name: "The Cosmic Annihilator", - prestigeRequirement: 59, + prestigeRequirement: 13, status: "locked", upgradeRewards: [], zoneId: "cosmic_maelstrom", @@ -1180,7 +1180,7 @@ export const defaultBosses: Array = [ // ── Primeval Sanctum ────────────────────────────────────────────────────── { bountyRunestones: 465, - crystalReward: 5e82, + crystalReward: 0, currentHp: 2e88, damagePerSecond: 5e82, description: @@ -1191,14 +1191,14 @@ export const defaultBosses: Array = [ id: "ancient_sentinel", maxHp: 2e88, name: "The Ancient Sentinel", - prestigeRequirement: 61, + prestigeRequirement: 14, status: "locked", upgradeRewards: [ "astral_sovereign_1" ], zoneId: "primeval_sanctum", }, { bountyRunestones: 615, - crystalReward: 3e89, + crystalReward: 0, currentHp: 1e95, damagePerSecond: 3e89, description: @@ -1209,14 +1209,14 @@ export const defaultBosses: Array = [ id: "time_elder", maxHp: 1e95, name: "The Time Elder", - prestigeRequirement: 65, + prestigeRequirement: 15, status: "locked", upgradeRewards: [], zoneId: "primeval_sanctum", }, { bountyRunestones: 815, - crystalReward: 2e96, + crystalReward: 0, currentHp: 8e101, damagePerSecond: 2e96, description: @@ -1227,14 +1227,14 @@ export const defaultBosses: Array = [ id: "origin_beast", maxHp: 8e101, name: "The Origin Beast", - prestigeRequirement: 69, + prestigeRequirement: 16, status: "locked", upgradeRewards: [], zoneId: "primeval_sanctum", }, { bountyRunestones: 1080, - crystalReward: 1e103, + crystalReward: 0, currentHp: 5e108, damagePerSecond: 1e103, description: @@ -1245,7 +1245,7 @@ export const defaultBosses: Array = [ id: "primeval_god", maxHp: 5e108, name: "The Primeval God", - prestigeRequirement: 74, + prestigeRequirement: 17, status: "locked", upgradeRewards: [], zoneId: "primeval_sanctum", @@ -1253,7 +1253,7 @@ export const defaultBosses: Array = [ // ── The Absolute ────────────────────────────────────────────────────────── { bountyRunestones: 615, - crystalReward: 5e110, + crystalReward: 0, currentHp: 2e116, damagePerSecond: 5e110, description: @@ -1264,14 +1264,14 @@ export const defaultBosses: Array = [ id: "absolute_herald", maxHp: 2e116, name: "The Absolute Herald", - prestigeRequirement: 76, + prestigeRequirement: 17, status: "locked", upgradeRewards: [ "primordial_mage_1" ], zoneId: "the_absolute", }, { bountyRunestones: 815, - crystalReward: 3e119, + crystalReward: 0, currentHp: 1e125, damagePerSecond: 3e119, description: @@ -1282,14 +1282,14 @@ export const defaultBosses: Array = [ id: "void_convergence", maxHp: 1e125, name: "The Void Convergence", - prestigeRequirement: 79, + prestigeRequirement: 18, status: "locked", upgradeRewards: [], zoneId: "the_absolute", }, { bountyRunestones: 1080, - crystalReward: 1e129, + crystalReward: 0, currentHp: 5e134, damagePerSecond: 1e129, description: @@ -1300,14 +1300,14 @@ export const defaultBosses: Array = [ id: "eternal_end", maxHp: 5e134, name: "The Eternal End", - prestigeRequirement: 83, + prestigeRequirement: 19, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "omniversal_champion_1" ], zoneId: "the_absolute", }, { bountyRunestones: 1430, - crystalReward: 5e139, + crystalReward: 0, currentHp: 2e145, damagePerSecond: 5e139, description: @@ -1318,7 +1318,7 @@ export const defaultBosses: Array = [ id: "the_absolute_one", maxHp: 2e145, name: "The Absolute One", - prestigeRequirement: 88, + prestigeRequirement: 20, status: "locked", upgradeRewards: [], zoneId: "the_absolute", diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index d0a6d86..abbe4c0 100644 --- a/apps/api/src/data/equipment.ts +++ b/apps/api/src/data/equipment.ts @@ -269,7 +269,7 @@ export const defaultEquipment: Array = [ type: "trinket", }, { - bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 }, + bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 }, description: "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", equipped: false, @@ -305,9 +305,9 @@ export const defaultEquipment: Array = [ type: "trinket", }, { - bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 }, + bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 }, description: - "The legendary stone that grants mastery over gold and combat alike.", + "The legendary stone that transmutes effort into wealth — every action fills the coffers.", equipped: false, id: "philosophers_stone", name: "Philosopher's Stone", @@ -695,9 +695,171 @@ export const defaultEquipment: Array = [ setId: "eternal_throne", 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 ───────────────────────────────────────────── { - bonus: { clickMultiplier: 3 }, + bonus: { clickMultiplier: 4.25 }, cost: { crystals: 0, essence: 20_000_000, gold: 0 }, description: "A lens of compressed celestial light that sharpens every strike with divine precision.", @@ -721,7 +883,7 @@ export const defaultEquipment: Array = [ type: "armour", }, { - bonus: { combatMultiplier: 7 }, + bonus: { combatMultiplier: 10.5 }, cost: { crystals: 0, essence: 100_000_000, gold: 0 }, description: "A weapon that channels void energy — the absence of resistance makes every strike devastating.", @@ -745,7 +907,7 @@ export const defaultEquipment: Array = [ type: "trinket", }, { - bonus: { goldMultiplier: 4.75 }, + bonus: { goldMultiplier: 7.5 }, cost: { crystals: 20_000_000, essence: 0, gold: 0 }, description: "Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.", @@ -757,7 +919,7 @@ export const defaultEquipment: Array = [ type: "armour", }, { - bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 }, + bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 }, cost: { crystals: 100_000_000, essence: 0, gold: 0 }, description: "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.", diff --git a/apps/api/src/data/explorations.ts b/apps/api/src/data/explorations.ts index d796afb..f343b66 100644 --- a/apps/api/src/data/explorations.ts +++ b/apps/api/src/data/explorations.ts @@ -13,7 +13,7 @@ export const defaultExplorations: Array = [ { description: "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", - durationSeconds: 3600, + durationSeconds: 5 * 60, events: [ { effect: { amount: 1000, type: "gold_gain" }, @@ -42,7 +42,7 @@ export const defaultExplorations: Array = [ ], id: "verdant_meadow", name: "The Verdant Meadow", - // 1h + // 5min possibleMaterials: [ { materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 3 }, ], @@ -52,7 +52,7 @@ export const defaultExplorations: Array = [ { description: "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", - durationSeconds: 7200, + durationSeconds: 10 * 60, events: [ { effect: { amount: 3000, type: "gold_gain" }, @@ -81,7 +81,7 @@ export const defaultExplorations: Array = [ ], id: "whispering_forest", name: "The Whispering Forest", - // 2h + // 10min possibleMaterials: [ { materialId: "verdant_sap", maxQuantity: 5, minQuantity: 2, weight: 3 }, { @@ -97,7 +97,7 @@ export const defaultExplorations: Array = [ { description: "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", - durationSeconds: 10_800, + durationSeconds: 15 * 60, events: [ { effect: { amount: 6000, type: "gold_gain" }, @@ -126,7 +126,7 @@ export const defaultExplorations: Array = [ ], id: "ancient_grove", name: "The Ancient Grove", - // 3h + // 15min possibleMaterials: [ { materialId: "forest_crystal", @@ -142,7 +142,7 @@ export const defaultExplorations: Array = [ { description: "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", - durationSeconds: 14_400, + durationSeconds: 20 * 60, events: [ { effect: { amount: 10_000, type: "gold_gain" }, @@ -171,7 +171,7 @@ export const defaultExplorations: Array = [ ], id: "forbidden_glen", name: "The Forbidden Glen", - // 4h + // 20min possibleMaterials: [ { materialId: "forest_crystal", @@ -189,7 +189,7 @@ export const defaultExplorations: Array = [ { description: "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", - durationSeconds: 7200, + durationSeconds: 10 * 60, events: [ { effect: { amount: 4000, type: "gold_gain" }, @@ -214,7 +214,7 @@ export const defaultExplorations: Array = [ ], id: "collapsed_outpost", name: "The Collapsed Outpost", - // 2h + // 10min possibleMaterials: [ { materialId: "ruin_dust", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], @@ -224,7 +224,7 @@ export const defaultExplorations: Array = [ { description: "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", - durationSeconds: 14_400, + durationSeconds: 20 * 60, events: [ { effect: { amount: 10_000, type: "gold_gain" }, @@ -253,7 +253,7 @@ export const defaultExplorations: Array = [ ], id: "cursed_lake", name: "The Cursed Lake", - // 4h + // 20min possibleMaterials: [ { materialId: "ruin_dust", maxQuantity: 6, minQuantity: 2, weight: 3 }, { @@ -269,7 +269,7 @@ export const defaultExplorations: Array = [ { description: "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", - durationSeconds: 21_600, + durationSeconds: 30 * 60, events: [ { effect: { amount: 20_000, type: "gold_gain" }, @@ -298,7 +298,7 @@ export const defaultExplorations: Array = [ ], id: "runic_archive", name: "The Runic Archive", - // 6h + // 30min possibleMaterials: [ { materialId: "cursed_fragment", @@ -314,7 +314,7 @@ export const defaultExplorations: Array = [ { description: "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", - durationSeconds: 28_800, + durationSeconds: 40 * 60, events: [ { effect: { amount: 40_000, type: "gold_gain" }, @@ -343,7 +343,7 @@ export const defaultExplorations: Array = [ ], id: "dragon_throne", name: "The Dragon's Throne", - // 8h + // 40min possibleMaterials: [ { materialId: "cursed_fragment", @@ -366,7 +366,7 @@ export const defaultExplorations: Array = [ { description: "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", - durationSeconds: 10_800, + durationSeconds: 15 * 60, events: [ { effect: { amount: 8000, type: "gold_gain" }, @@ -395,7 +395,7 @@ export const defaultExplorations: Array = [ ], id: "glacial_cave", name: "The Glacial Cave", - // 3h + // 15min possibleMaterials: [ { materialId: "glacial_ice", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], @@ -405,7 +405,7 @@ export const defaultExplorations: Array = [ { description: "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", - durationSeconds: 21_600, + durationSeconds: 30 * 60, events: [ { effect: { amount: 18_000, type: "gold_gain" }, @@ -434,7 +434,7 @@ export const defaultExplorations: Array = [ ], id: "frozen_tundra", name: "The Frozen Tundra", - // 6h + // 30min possibleMaterials: [ { materialId: "glacial_ice", maxQuantity: 7, minQuantity: 3, weight: 3 }, { @@ -450,7 +450,7 @@ export const defaultExplorations: Array = [ { description: "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", - durationSeconds: 32_400, + durationSeconds: 45 * 60, events: [ { effect: { amount: 35_000, type: "gold_gain" }, @@ -479,7 +479,7 @@ export const defaultExplorations: Array = [ ], id: "void_rift", name: "The Void Rift", - // 9h + // 45min possibleMaterials: [ { materialId: "frost_crystal", @@ -495,7 +495,7 @@ export const defaultExplorations: Array = [ { description: "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", - durationSeconds: 43_200, + durationSeconds: 1 * 60 * 60, events: [ { effect: { amount: 60_000, type: "gold_gain" }, @@ -524,7 +524,7 @@ export const defaultExplorations: Array = [ ], id: "summit_shrine", name: "The Summit Shrine", - // 12h + // 1h possibleMaterials: [ { materialId: "frost_crystal", @@ -542,7 +542,7 @@ export const defaultExplorations: Array = [ { description: "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", - durationSeconds: 18_000, + durationSeconds: 25 * 60, events: [ { effect: { amount: 15_000, type: "gold_gain" }, @@ -571,7 +571,7 @@ export const defaultExplorations: Array = [ ], id: "fog_hollow", name: "The Fog Hollow", - // 5h + // 25min possibleMaterials: [ { materialId: "marsh_root", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], @@ -581,7 +581,7 @@ export const defaultExplorations: Array = [ { description: "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", - durationSeconds: 36_000, + durationSeconds: 50 * 60, events: [ { effect: { amount: 35_000, type: "gold_gain" }, @@ -610,7 +610,7 @@ export const defaultExplorations: Array = [ ], id: "dark_grotto", name: "The Dark Grotto", - // 10h + // 50min possibleMaterials: [ { materialId: "marsh_root", maxQuantity: 7, minQuantity: 3, weight: 3 }, { @@ -626,7 +626,7 @@ export const defaultExplorations: Array = [ { description: "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", - durationSeconds: 54_000, + durationSeconds: 90 * 60, events: [ { effect: { amount: 70_000, type: "gold_gain" }, @@ -655,7 +655,7 @@ export const defaultExplorations: Array = [ ], id: "cursed_barrow", name: "The Cursed Barrow", - // 15h + // 1.5h possibleMaterials: [ { materialId: "shadow_essence", @@ -671,7 +671,7 @@ export const defaultExplorations: Array = [ { description: "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", - durationSeconds: 72_000, + durationSeconds: 90 * 60, events: [ { effect: { amount: 120_000, type: "gold_gain" }, @@ -700,7 +700,7 @@ export const defaultExplorations: Array = [ ], id: "marsh_depths", name: "The Marsh Depths", - // 20h + // 1.5h possibleMaterials: [ { materialId: "shadow_essence", @@ -718,7 +718,7 @@ export const defaultExplorations: Array = [ { description: "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", - durationSeconds: 25_200, + durationSeconds: 35 * 60, events: [ { effect: { amount: 30_000, type: "gold_gain" }, @@ -747,7 +747,7 @@ export const defaultExplorations: Array = [ ], id: "magma_tunnel", name: "The Magma Tunnel", - // 7h + // 35min possibleMaterials: [ { materialId: "magma_stone", maxQuantity: 5, minQuantity: 2, weight: 3 }, ], @@ -757,7 +757,7 @@ export const defaultExplorations: Array = [ { description: "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", - durationSeconds: 50_400, + durationSeconds: 1 * 60 * 60, events: [ { effect: { amount: 70_000, type: "gold_gain" }, @@ -786,7 +786,7 @@ export const defaultExplorations: Array = [ ], id: "forge_chamber", name: "The Forge Chamber", - // 14h + // 1h possibleMaterials: [ { materialId: "magma_stone", maxQuantity: 7, minQuantity: 3, weight: 3 }, { @@ -802,7 +802,7 @@ export const defaultExplorations: Array = [ { description: "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", - durationSeconds: 75_600, + durationSeconds: 2 * 60 * 60, events: [ { effect: { amount: 130_000, type: "gold_gain" }, @@ -831,7 +831,7 @@ export const defaultExplorations: Array = [ ], id: "fire_temple", name: "The Fire Temple", - // 21h + // 2h possibleMaterials: [ { materialId: "ember_crystal", @@ -852,7 +852,7 @@ export const defaultExplorations: Array = [ { description: "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", - durationSeconds: 100_800, + durationSeconds: 150 * 60, events: [ { effect: { amount: 250_000, type: "gold_gain" }, @@ -881,7 +881,7 @@ export const defaultExplorations: Array = [ ], id: "core_descent", name: "The Core Descent", - // 28h + // 2.5h possibleMaterials: [ { materialId: "ember_crystal", @@ -904,7 +904,7 @@ export const defaultExplorations: Array = [ { description: "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", - durationSeconds: 36_000, + durationSeconds: 50 * 60, events: [ { effect: { amount: 500_000, type: "gold_gain" }, @@ -929,7 +929,7 @@ export const defaultExplorations: Array = [ ], id: "star_field", name: "The Star Field", - // 10h + // 50min possibleMaterials: [ { materialId: "stardust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -939,7 +939,7 @@ export const defaultExplorations: Array = [ { description: "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", - durationSeconds: 72_000, + durationSeconds: 90 * 60, events: [ { effect: { amount: 1_000_000, type: "gold_gain" }, @@ -968,7 +968,7 @@ export const defaultExplorations: Array = [ ], id: "probability_sea", name: "The Probability Sea", - // 20h + // 1.5h possibleMaterials: [ { materialId: "stardust", maxQuantity: 8, minQuantity: 4, weight: 3 }, { @@ -984,7 +984,7 @@ export const defaultExplorations: Array = [ { description: "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", - durationSeconds: 108_000, + durationSeconds: 150 * 60, events: [ { effect: { amount: 2_000_000, type: "gold_gain" }, @@ -1013,7 +1013,7 @@ export const defaultExplorations: Array = [ ], id: "void_current", name: "The Void Current", - // 30h + // 2.5h possibleMaterials: [ { materialId: "astral_thread", @@ -1029,7 +1029,7 @@ export const defaultExplorations: Array = [ { description: "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", - durationSeconds: 144_000, + durationSeconds: 210 * 60, events: [ { effect: { amount: 4_000_000, type: "gold_gain" }, @@ -1058,7 +1058,7 @@ export const defaultExplorations: Array = [ ], id: "null_zenith", name: "The Null Zenith", - // 40h + // 3.5h possibleMaterials: [ { materialId: "astral_thread", @@ -1076,7 +1076,7 @@ export const defaultExplorations: Array = [ { description: "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", - durationSeconds: 43_200, + durationSeconds: 1 * 60 * 60, events: [ { effect: { amount: 3_000_000, type: "gold_gain" }, @@ -1105,7 +1105,7 @@ export const defaultExplorations: Array = [ ], id: "light_spire", name: "The Light Spire", - // 12h + // 1h possibleMaterials: [ { materialId: "celestial_dust", @@ -1120,7 +1120,7 @@ export const defaultExplorations: Array = [ { description: "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", - durationSeconds: 86_400, + durationSeconds: 2 * 60 * 60, events: [ { effect: { amount: 6_000_000, type: "gold_gain" }, @@ -1149,7 +1149,7 @@ export const defaultExplorations: Array = [ ], id: "choir_hall", name: "The Choir Hall", - // 24h + // 2h possibleMaterials: [ { materialId: "celestial_dust", @@ -1170,7 +1170,7 @@ export const defaultExplorations: Array = [ { description: "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", - durationSeconds: 129_600, + durationSeconds: 3 * 60 * 60, events: [ { effect: { amount: 12_000_000, type: "gold_gain" }, @@ -1199,7 +1199,7 @@ export const defaultExplorations: Array = [ ], id: "divine_court", name: "The Divine Court", - // 36h + // 3h possibleMaterials: [ { materialId: "divine_fragment", @@ -1215,7 +1215,7 @@ export const defaultExplorations: Array = [ { description: "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", - durationSeconds: 172_800, + durationSeconds: 4 * 60 * 60, events: [ { effect: { amount: 25_000_000, type: "gold_gain" }, @@ -1244,7 +1244,7 @@ export const defaultExplorations: Array = [ ], id: "celestial_vault", name: "The Celestial Vault", - // 48h + // 4h possibleMaterials: [ { materialId: "divine_fragment", @@ -1262,7 +1262,7 @@ export const defaultExplorations: Array = [ { description: "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", - durationSeconds: 50_400, + durationSeconds: 1 * 60 * 60, events: [ { effect: { amount: 8_000_000, type: "gold_gain" }, @@ -1291,7 +1291,7 @@ export const defaultExplorations: Array = [ ], id: "trench_entrance", name: "The Trench Entrance", - // 14h + // 1h possibleMaterials: [ { materialId: "trench_coral", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -1301,7 +1301,7 @@ export const defaultExplorations: Array = [ { description: "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", - durationSeconds: 100_800, + durationSeconds: 150 * 60, events: [ { effect: { amount: 18_000_000, type: "gold_gain" }, @@ -1330,7 +1330,7 @@ export const defaultExplorations: Array = [ ], id: "deep_current", name: "The Deep Current", - // 28h + // 2.5h possibleMaterials: [ { materialId: "trench_coral", maxQuantity: 9, minQuantity: 4, weight: 3 }, { materialId: "pressure_gem", maxQuantity: 2, minQuantity: 1, weight: 2 }, @@ -1341,7 +1341,7 @@ export const defaultExplorations: Array = [ { description: "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", - durationSeconds: 151_200, + durationSeconds: 210 * 60, events: [ { effect: { amount: 40_000_000, type: "gold_gain" }, @@ -1370,7 +1370,7 @@ export const defaultExplorations: Array = [ ], id: "sunless_chamber", name: "The Sunless Chamber", - // 42h + // 3.5h possibleMaterials: [ { materialId: "pressure_gem", maxQuantity: 3, minQuantity: 1, weight: 3 }, { @@ -1386,7 +1386,7 @@ export const defaultExplorations: Array = [ { description: "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", - durationSeconds: 201_600, + durationSeconds: 270 * 60, events: [ { effect: { amount: 80_000_000, type: "gold_gain" }, @@ -1415,7 +1415,7 @@ export const defaultExplorations: Array = [ ], id: "the_waiting_place", name: "The Waiting Place", - // 56h + // 4.5h possibleMaterials: [ { materialId: "pressure_gem", maxQuantity: 4, minQuantity: 2, weight: 3 }, { @@ -1433,7 +1433,7 @@ export const defaultExplorations: Array = [ { description: "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", - durationSeconds: 57_600, + durationSeconds: 90 * 60, events: [ { effect: { amount: 20_000_000, type: "gold_gain" }, @@ -1462,7 +1462,7 @@ export const defaultExplorations: Array = [ ], id: "demon_market", name: "The Demon Market", - // 16h + // 1.5h possibleMaterials: [ { materialId: "brimstone_flake", @@ -1477,7 +1477,7 @@ export const defaultExplorations: Array = [ { description: "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", - durationSeconds: 115_200, + durationSeconds: 150 * 60, events: [ { effect: { amount: 45_000_000, type: "gold_gain" }, @@ -1506,7 +1506,7 @@ export const defaultExplorations: Array = [ ], id: "torment_hall", name: "The Torment Hall", - // 32h + // 2.5h possibleMaterials: [ { materialId: "brimstone_flake", @@ -1522,7 +1522,7 @@ export const defaultExplorations: Array = [ { description: "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", - durationSeconds: 172_800, + durationSeconds: 4 * 60 * 60, events: [ { effect: { amount: 90_000_000, type: "gold_gain" }, @@ -1551,7 +1551,7 @@ export const defaultExplorations: Array = [ ], id: "soul_forge", name: "The Soul Forge", - // 48h + // 4h possibleMaterials: [ { materialId: "demon_ichor", maxQuantity: 3, minQuantity: 1, weight: 3 }, { materialId: "soul_residue", maxQuantity: 1, minQuantity: 1, weight: 1 }, @@ -1562,7 +1562,7 @@ export const defaultExplorations: Array = [ { description: "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", - durationSeconds: 230_400, + durationSeconds: 330 * 60, events: [ { effect: { amount: 180_000_000, type: "gold_gain" }, @@ -1591,7 +1591,7 @@ export const defaultExplorations: Array = [ ], id: "lords_chamber", name: "The Lords' Chamber", - // 64h + // 5.5h possibleMaterials: [ { materialId: "demon_ichor", maxQuantity: 4, minQuantity: 2, weight: 3 }, { materialId: "soul_residue", maxQuantity: 2, minQuantity: 1, weight: 2 }, @@ -1604,7 +1604,7 @@ export const defaultExplorations: Array = [ { description: "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", - durationSeconds: 64_800, + durationSeconds: 90 * 60, events: [ { effect: { amount: 60_000_000, type: "gold_gain" }, @@ -1633,7 +1633,7 @@ export const defaultExplorations: Array = [ ], id: "facet_approach", name: "The Facet Approach", - // 18h + // 1.5h possibleMaterials: [ { materialId: "prism_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -1643,7 +1643,7 @@ export const defaultExplorations: Array = [ { description: "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", - durationSeconds: 129_600, + durationSeconds: 3 * 60 * 60, events: [ { effect: { amount: 130_000_000, type: "gold_gain" }, @@ -1672,7 +1672,7 @@ export const defaultExplorations: Array = [ ], id: "calculation_chamber", name: "The Calculation Chamber", - // 36h + // 3h possibleMaterials: [ { materialId: "prism_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, { @@ -1688,7 +1688,7 @@ export const defaultExplorations: Array = [ { description: "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", - durationSeconds: 194_400, + durationSeconds: 270 * 60, events: [ { effect: { amount: 270_000_000, type: "gold_gain" }, @@ -1717,7 +1717,7 @@ export const defaultExplorations: Array = [ ], id: "mirror_hall", name: "The Mirror Hall", - // 54h + // 4.5h possibleMaterials: [ { materialId: "calculation_shard", @@ -1738,7 +1738,7 @@ export const defaultExplorations: Array = [ { description: "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", - durationSeconds: 259_200, + durationSeconds: 6 * 60 * 60, events: [ { effect: { amount: 550_000_000, type: "gold_gain" }, @@ -1767,7 +1767,7 @@ export const defaultExplorations: Array = [ ], id: "core_access", name: "The Core Access", - // 72h + // 6h possibleMaterials: [ { materialId: "calculation_shard", @@ -1790,7 +1790,7 @@ export const defaultExplorations: Array = [ { description: "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", - durationSeconds: 72_000, + durationSeconds: 90 * 60, events: [ { effect: { amount: 200_000_000, type: "gold_gain" }, @@ -1819,7 +1819,7 @@ export const defaultExplorations: Array = [ ], id: "threshold", name: "The Threshold", - // 20h + // 1.5h possibleMaterials: [ { materialId: "null_matter", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -1829,7 +1829,7 @@ export const defaultExplorations: Array = [ { description: "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", - durationSeconds: 144_000, + durationSeconds: 210 * 60, events: [ { effect: { amount: 420_000_000, type: "gold_gain" }, @@ -1858,7 +1858,7 @@ export const defaultExplorations: Array = [ ], id: "inner_silence", name: "The Inner Silence", - // 40h + // 3.5h possibleMaterials: [ { materialId: "null_matter", maxQuantity: 9, minQuantity: 4, weight: 3 }, { @@ -1874,7 +1874,7 @@ export const defaultExplorations: Array = [ { description: "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", - durationSeconds: 216_000, + durationSeconds: 5 * 60 * 60, events: [ { effect: { amount: 900_000_000, type: "gold_gain" }, @@ -1903,7 +1903,7 @@ export const defaultExplorations: Array = [ ], id: "resonance_chamber", name: "The Resonance Chamber", - // 60h + // 5h possibleMaterials: [ { materialId: "resonance_fragment", @@ -1919,7 +1919,7 @@ export const defaultExplorations: Array = [ { description: "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", - durationSeconds: 288_000, + durationSeconds: 7 * 60 * 60, events: [ { effect: { amount: 1_800_000_000, type: "gold_gain" }, @@ -1948,7 +1948,7 @@ export const defaultExplorations: Array = [ ], id: "sanctum_heart", name: "The Sanctum Heart", - // 80h + // 7h possibleMaterials: [ { materialId: "resonance_fragment", @@ -1966,7 +1966,7 @@ export const defaultExplorations: Array = [ { description: "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", - durationSeconds: 79_200, + durationSeconds: 2 * 60 * 60, events: [ { effect: { amount: 700_000_000, type: "gold_gain" }, @@ -1995,7 +1995,7 @@ export const defaultExplorations: Array = [ ], id: "throne_approach", name: "The Throne Approach", - // 22h + // 2h possibleMaterials: [ { materialId: "throne_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -2005,7 +2005,7 @@ export const defaultExplorations: Array = [ { description: "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", - durationSeconds: 158_400, + durationSeconds: 210 * 60, events: [ { effect: { amount: 1_400_000_000, type: "gold_gain" }, @@ -2034,7 +2034,7 @@ export const defaultExplorations: Array = [ ], id: "dominion_hall", name: "The Dominion Hall", - // 44h + // 3.5h possibleMaterials: [ { materialId: "throne_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, { @@ -2050,7 +2050,7 @@ export const defaultExplorations: Array = [ { description: "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", - durationSeconds: 237_600, + durationSeconds: 330 * 60, events: [ { effect: { amount: 3_000_000_000, type: "gold_gain" }, @@ -2079,7 +2079,7 @@ export const defaultExplorations: Array = [ ], id: "eternity_vault", name: "The Eternity Vault", - // 66h + // 5.5h possibleMaterials: [ { materialId: "crown_fragment", @@ -2100,7 +2100,7 @@ export const defaultExplorations: Array = [ { description: "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", - durationSeconds: 316_800, + durationSeconds: 7 * 60 * 60, events: [ { effect: { amount: 6_000_000_000, type: "gold_gain" }, @@ -2129,7 +2129,7 @@ export const defaultExplorations: Array = [ ], id: "the_seat", name: "The Seat", - // 88h + // 7h possibleMaterials: [ { materialId: "crown_fragment", @@ -2152,7 +2152,7 @@ export const defaultExplorations: Array = [ { description: "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", - durationSeconds: 86_400, + durationSeconds: 2 * 60 * 60, events: [ { effect: { amount: 2_000_000_000, type: "gold_gain" }, @@ -2181,7 +2181,7 @@ export const defaultExplorations: Array = [ ], id: "creation_storm", name: "The Creation Storm", - // 24h + // 2h possibleMaterials: [ { materialId: "chaos_fragment", @@ -2196,7 +2196,7 @@ export const defaultExplorations: Array = [ { description: "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", - durationSeconds: 172_800, + durationSeconds: 4 * 60 * 60, events: [ { effect: { amount: 4_000_000_000, type: "gold_gain" }, @@ -2225,7 +2225,7 @@ export const defaultExplorations: Array = [ ], id: "unmaking_sea", name: "The Unmaking Sea", - // 48h + // 4h possibleMaterials: [ { materialId: "chaos_fragment", @@ -2246,7 +2246,7 @@ export const defaultExplorations: Array = [ { description: "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", - durationSeconds: 259_200, + durationSeconds: 6 * 60 * 60, events: [ { effect: { amount: 8_000_000_000, type: "gold_gain" }, @@ -2275,7 +2275,7 @@ export const defaultExplorations: Array = [ ], id: "probability_void", name: "The Probability Void", - // 72h + // 6h possibleMaterials: [ { materialId: "creation_shard", @@ -2296,7 +2296,7 @@ export const defaultExplorations: Array = [ { description: "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", - durationSeconds: 345_600, + durationSeconds: 8 * 60 * 60, events: [ { effect: { amount: 16_000_000_000, type: "gold_gain" }, @@ -2325,7 +2325,7 @@ export const defaultExplorations: Array = [ ], id: "chaos_core", name: "The Chaos Core", - // 96h + // 8h possibleMaterials: [ { materialId: "creation_shard", @@ -2348,7 +2348,7 @@ export const defaultExplorations: Array = [ { description: "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", - durationSeconds: 93_600, + durationSeconds: 2 * 60 * 60, events: [ { effect: { amount: 6_000_000_000, type: "gold_gain" }, @@ -2377,7 +2377,7 @@ export const defaultExplorations: Array = [ ], id: "first_horizon", name: "The First Horizon", - // 26h + // 2h possibleMaterials: [ { materialId: "expanse_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -2387,7 +2387,7 @@ export const defaultExplorations: Array = [ { description: "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", - durationSeconds: 187_200, + durationSeconds: 270 * 60, events: [ { effect: { amount: 12_000_000_000, type: "gold_gain" }, @@ -2416,7 +2416,7 @@ export const defaultExplorations: Array = [ ], id: "middle_nowhere", name: "The Middle of Nowhere", - // 52h + // 4.5h possibleMaterials: [ { materialId: "expanse_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, { @@ -2432,7 +2432,7 @@ export const defaultExplorations: Array = [ { description: "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", - durationSeconds: 280_800, + durationSeconds: 7 * 60 * 60, events: [ { effect: { amount: 25_000_000_000, type: "gold_gain" }, @@ -2461,7 +2461,7 @@ export const defaultExplorations: Array = [ ], id: "edge_approach", name: "The Edge Approach", - // 78h + // 7h possibleMaterials: [ { materialId: "distance_crystal", @@ -2482,7 +2482,7 @@ export const defaultExplorations: Array = [ { description: "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", - durationSeconds: 374_400, + durationSeconds: 9 * 60 * 60, events: [ { effect: { amount: 50_000_000_000, type: "gold_gain" }, @@ -2511,7 +2511,7 @@ export const defaultExplorations: Array = [ ], id: "the_furthest", name: "The Furthest", - // 104h + // 9h possibleMaterials: [ { materialId: "distance_crystal", @@ -2534,7 +2534,7 @@ export const defaultExplorations: Array = [ { description: "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", - durationSeconds: 100_800, + durationSeconds: 150 * 60, events: [ { effect: { amount: 20_000_000_000, type: "gold_gain" }, @@ -2559,7 +2559,7 @@ export const defaultExplorations: Array = [ ], id: "workshop_entrance", name: "The Workshop Entrance", - // 28h + // 2.5h possibleMaterials: [ { materialId: "forge_ash", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -2569,7 +2569,7 @@ export const defaultExplorations: Array = [ { description: "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", - durationSeconds: 201_600, + durationSeconds: 270 * 60, events: [ { effect: { amount: 40_000_000_000, type: "gold_gain" }, @@ -2598,7 +2598,7 @@ export const defaultExplorations: Array = [ ], id: "creation_floor", name: "The Creation Floor", - // 56h + // 4.5h possibleMaterials: [ { materialId: "forge_ash", maxQuantity: 9, minQuantity: 4, weight: 3 }, { @@ -2614,7 +2614,7 @@ export const defaultExplorations: Array = [ { description: "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", - durationSeconds: 302_400, + durationSeconds: 7 * 60 * 60, events: [ { effect: { amount: 80_000_000_000, type: "gold_gain" }, @@ -2643,7 +2643,7 @@ export const defaultExplorations: Array = [ ], id: "master_forge", name: "The Master Forge", - // 84h + // 7h possibleMaterials: [ { materialId: "creation_tool", @@ -2664,7 +2664,7 @@ export const defaultExplorations: Array = [ { description: "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", - durationSeconds: 403_200, + durationSeconds: 9 * 60 * 60, events: [ { effect: { amount: 160_000_000_000, type: "gold_gain" }, @@ -2693,7 +2693,7 @@ export const defaultExplorations: Array = [ ], id: "forge_core", name: "The Forge Core", - // 112h + // 9h possibleMaterials: [ { materialId: "creation_tool", @@ -2716,7 +2716,7 @@ export const defaultExplorations: Array = [ { description: "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", - durationSeconds: 108_000, + durationSeconds: 150 * 60, events: [ { effect: { amount: 60_000_000_000, type: "gold_gain" }, @@ -2745,7 +2745,7 @@ export const defaultExplorations: Array = [ ], id: "outer_current", name: "The Outer Current", - // 30h + // 2.5h possibleMaterials: [ { materialId: "maelstrom_debris", @@ -2760,7 +2760,7 @@ export const defaultExplorations: Array = [ { description: "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", - durationSeconds: 216_000, + durationSeconds: 5 * 60 * 60, events: [ { effect: { amount: 120_000_000_000, type: "gold_gain" }, @@ -2789,7 +2789,7 @@ export const defaultExplorations: Array = [ ], id: "debris_field", name: "The Debris Field", - // 60h + // 5h possibleMaterials: [ { materialId: "maelstrom_debris", @@ -2810,7 +2810,7 @@ export const defaultExplorations: Array = [ { description: "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", - durationSeconds: 324_000, + durationSeconds: 8 * 60 * 60, events: [ { effect: { amount: 250_000_000_000, type: "gold_gain" }, @@ -2839,7 +2839,7 @@ export const defaultExplorations: Array = [ ], id: "force_confluence", name: "The Force Confluence", - // 90h + // 8h possibleMaterials: [ { materialId: "force_crystal", @@ -2860,7 +2860,7 @@ export const defaultExplorations: Array = [ { description: "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", - durationSeconds: 432_000, + durationSeconds: 10 * 60 * 60, events: [ { effect: { amount: 500_000_000_000, type: "gold_gain" }, @@ -2889,7 +2889,7 @@ export const defaultExplorations: Array = [ ], id: "eye_approach", name: "The Eye Approach", - // 120h + // 10h possibleMaterials: [ { materialId: "force_crystal", @@ -2912,7 +2912,7 @@ export const defaultExplorations: Array = [ { description: "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", - durationSeconds: 115_200, + durationSeconds: 150 * 60, events: [ { effect: { amount: 200_000_000_000, type: "gold_gain" }, @@ -2941,7 +2941,7 @@ export const defaultExplorations: Array = [ ], id: "first_steps", name: "The First Steps", - // 32h + // 2.5h possibleMaterials: [ { materialId: "ancient_dust", maxQuantity: 7, minQuantity: 3, weight: 3 }, ], @@ -2951,7 +2951,7 @@ export const defaultExplorations: Array = [ { description: "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", - durationSeconds: 230_400, + durationSeconds: 330 * 60, events: [ { effect: { amount: 400_000_000_000, type: "gold_gain" }, @@ -2980,7 +2980,7 @@ export const defaultExplorations: Array = [ ], id: "ancient_archive", name: "The Ancient Archive", - // 64h + // 5.5h possibleMaterials: [ { materialId: "ancient_dust", maxQuantity: 9, minQuantity: 4, weight: 3 }, { materialId: "memory_shard", maxQuantity: 2, minQuantity: 1, weight: 2 }, @@ -2991,7 +2991,7 @@ export const defaultExplorations: Array = [ { description: "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", - durationSeconds: 345_600, + durationSeconds: 8 * 60 * 60, events: [ { effect: { amount: 800_000_000_000, type: "gold_gain" }, @@ -3020,7 +3020,7 @@ export const defaultExplorations: Array = [ ], id: "memory_chamber", name: "The Memory Chamber", - // 96h + // 8h possibleMaterials: [ { materialId: "memory_shard", maxQuantity: 3, minQuantity: 1, weight: 3 }, { @@ -3036,7 +3036,7 @@ export const defaultExplorations: Array = [ { description: "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", - durationSeconds: 460_800, + durationSeconds: 11 * 60 * 60, events: [ { effect: { amount: 1_600_000_000_000, type: "gold_gain" }, @@ -3065,7 +3065,7 @@ export const defaultExplorations: Array = [ ], id: "the_oldest_place", name: "The Oldest Place", - // 128h + // 11h possibleMaterials: [ { materialId: "memory_shard", maxQuantity: 4, minQuantity: 2, weight: 3 }, { @@ -3083,7 +3083,7 @@ export const defaultExplorations: Array = [ { description: "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", - durationSeconds: 129_600, + durationSeconds: 3 * 60 * 60, events: [ { effect: { amount: 600_000_000_000, type: "gold_gain" }, @@ -3112,7 +3112,7 @@ export const defaultExplorations: Array = [ ], id: "edge_of_everything", name: "The Edge of Everything", - // 36h + // 3h possibleMaterials: [ { materialId: "absolute_fragment", @@ -3127,7 +3127,7 @@ export const defaultExplorations: Array = [ { description: "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", - durationSeconds: 259_200, + durationSeconds: 6 * 60 * 60, events: [ { effect: { amount: 1_200_000_000_000, type: "gold_gain" }, @@ -3156,7 +3156,7 @@ export const defaultExplorations: Array = [ ], id: "truth_approach", name: "The Truth Approach", - // 72h + // 6h possibleMaterials: [ { materialId: "absolute_fragment", @@ -3177,7 +3177,7 @@ export const defaultExplorations: Array = [ { description: "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", - durationSeconds: 388_800, + durationSeconds: 9 * 60 * 60, events: [ { effect: { amount: 2_500_000_000_000, type: "gold_gain" }, @@ -3206,7 +3206,7 @@ export const defaultExplorations: Array = [ ], id: "final_antechamber", name: "The Final Antechamber", - // 108h + // 9h possibleMaterials: [ { materialId: "boundary_shard", @@ -3227,7 +3227,7 @@ export const defaultExplorations: Array = [ { description: "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", - durationSeconds: 518_400, + durationSeconds: 12 * 60 * 60, events: [ { effect: { amount: 5_000_000_000_000, type: "gold_gain" }, @@ -3256,7 +3256,7 @@ export const defaultExplorations: Array = [ ], id: "the_absolute_heart", name: "The Absolute Heart", - // 144h + // 12h possibleMaterials: [ { materialId: "boundary_shard", diff --git a/apps/api/src/data/prestigeUpgrades.ts b/apps/api/src/data/prestigeUpgrades.ts index 1982c60..6d33eb0 100644 --- a/apps/api/src/data/prestigeUpgrades.ts +++ b/apps/api/src/data/prestigeUpgrades.ts @@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array = [ id: "income_10", multiplier: 200, name: "Eternal Rune I", - runestonesCost: 30_000, + runestonesCost: 22_500, }, { category: "income", @@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array = [ id: "income_11", multiplier: 500, name: "Eternal Rune II", - runestonesCost: 80_000, + runestonesCost: 60_000, }, // ── Click Power ─────────────────────────────────────────────────────────── { diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 2799402..5058d60 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -34,6 +34,7 @@ export const defaultQuests: Array = [ { amount: 2000, type: "gold" }, { amount: 5, type: "essence" }, { targetId: "peasant_1", type: "upgrade" }, + { targetId: "apprentice_1", type: "upgrade" }, { targetId: "apprentice", type: "adventurer" }, ], status: "locked", @@ -50,6 +51,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 10, type: "crystals" }, { targetId: "global_1", type: "upgrade" }, + { targetId: "militia_1", type: "upgrade" }, { targetId: "scout", type: "adventurer" }, ], status: "locked", @@ -75,14 +77,13 @@ export const defaultQuests: Array = [ combatPowerRequired: 500, description: "A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.", - durationSeconds: 25 * 60, + durationSeconds: 5 * 60, id: "necromancer_tower", name: "Necromancer's Tower", prerequisiteIds: [], rewards: [ { amount: 15_000, type: "gold" }, { amount: 20, type: "essence" }, - { targetId: "militia_1", type: "upgrade" }, { targetId: "acolyte_1", type: "upgrade" }, { targetId: "ranger", type: "adventurer" }, ], @@ -93,7 +94,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 2000, description: "An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.", - durationSeconds: 45 * 60, + durationSeconds: 5 * 60, id: "crumbling_fortress", name: "The Crumbling Fortress", prerequisiteIds: [ "necromancer_tower" ], @@ -110,14 +111,13 @@ export const defaultQuests: Array = [ combatPowerRequired: 8000, description: "A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.", - durationSeconds: 60 * 60, + durationSeconds: 10 * 60, id: "cursed_library", name: "The Cursed Library", prerequisiteIds: [ "crumbling_fortress" ], rewards: [ { amount: 300, type: "essence" }, { amount: 30, type: "crystals" }, - { targetId: "apprentice_1", type: "upgrade" }, { targetId: "archmage", type: "adventurer" }, ], status: "locked", @@ -127,7 +127,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 30_000, description: "The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.", - durationSeconds: 90 * 60, + durationSeconds: 15 * 60, id: "dragon_lair", name: "Dragon's Lair", prerequisiteIds: [ "cursed_library" ], @@ -145,7 +145,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 100_000, description: "A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.", - durationSeconds: 2 * 60 * 60, + durationSeconds: 15 * 60, id: "frozen_wastes", name: "The Frozen Wastes", prerequisiteIds: [], @@ -153,6 +153,23 @@ export const defaultQuests: Array = [ { amount: 5_000_000, type: "gold" }, { amount: 100, type: "crystals" }, { targetId: "global_3", type: "upgrade" }, + { targetId: "knight_1", type: "upgrade" }, + ], + status: "locked", + zoneId: "frozen_peaks", + }, + { + combatPowerRequired: 200_000, + description: + "A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.", + durationSeconds: 20 * 60, + id: "glacier_tomb", + name: "The Glacier Tomb", + prerequisiteIds: [ "frozen_wastes" ], + rewards: [ + { amount: 10_000_000, type: "gold" }, + { amount: 3000, type: "essence" }, + { targetId: "peasant_2", type: "upgrade" }, ], status: "locked", zoneId: "frozen_peaks", @@ -161,10 +178,10 @@ export const defaultQuests: Array = [ combatPowerRequired: 400_000, description: "A labyrinthine network of crystal caverns that descend for miles. The cold here is a presence, not just a temperature.", - durationSeconds: 3 * 60 * 60, + durationSeconds: 25 * 60, id: "ice_caves", name: "The Ice Caves", - prerequisiteIds: [ "frozen_wastes" ], + prerequisiteIds: [ "glacier_tomb" ], rewards: [ { amount: 5000, type: "essence" }, { amount: 200, type: "crystals" }, @@ -177,7 +194,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1_500_000, description: "A fortress suspended in a permanent blizzard, built by a mage who wanted to be left alone — and succeeded for three hundred years.", - durationSeconds: 5 * 60 * 60, + durationSeconds: 45 * 60, id: "storm_citadel", name: "The Storm Citadel", prerequisiteIds: [ "ice_caves" ], @@ -188,17 +205,36 @@ export const defaultQuests: Array = [ status: "locked", zoneId: "frozen_peaks", }, + { + combatPowerRequired: 3_000_000, + description: + "Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.", + durationSeconds: 1 * 60 * 60, + id: "frozen_throne", + name: "The Frozen Throne", + prerequisiteIds: [ "storm_citadel" ], + rewards: [ + { amount: 60_000_000, type: "gold" }, + { amount: 25_000, type: "essence" }, + { amount: 400, type: "crystals" }, + ], + status: "locked", + zoneId: "frozen_peaks", + }, // ── Shadow Marshes ──────────────────────────────────────────────────────── { - combatPowerRequired: 5_000_000, + combatPowerRequired: 2_000_000, description: "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", - durationSeconds: 45 * 60, + durationSeconds: 5 * 60, id: "shadow_mere", name: "The Shadow Mere", prerequisiteIds: [], rewards: [ - { amount: 150, type: "essence" }, + { amount: 5_000_000, type: "gold" }, + { amount: 5000, type: "essence" }, + { amount: 150, type: "crystals" }, + { targetId: "peasant_3", type: "upgrade" }, ], status: "locked", zoneId: "shadow_marshes", @@ -207,12 +243,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 20_000_000, description: "Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.", - durationSeconds: 90 * 60, + durationSeconds: 15 * 60, id: "witch_coven", name: "The Witch Coven", prerequisiteIds: [ "shadow_mere" ], rewards: [ - { amount: 500, type: "essence" }, + { amount: 20_000_000, type: "gold" }, + { amount: 20_000, type: "essence" }, + { amount: 500, type: "crystals" }, { targetId: "shadow_assassin", type: "adventurer" }, ], status: "locked", @@ -222,16 +260,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 80_000_000, description: "An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.", - durationSeconds: 2 * 60 * 60, + durationSeconds: 15 * 60, id: "sunken_temple", name: "The Sunken Temple", prerequisiteIds: [ "witch_coven" ], rewards: [ - { amount: 2_000_000, type: "gold" }, - { amount: 1500, type: "essence" }, - { amount: 75, type: "crystals" }, - { targetId: "knight_1", type: "upgrade" }, - { targetId: "peasant_2", type: "upgrade" }, + { amount: 60_000_000, type: "gold" }, + { amount: 25_000, type: "essence" }, + { amount: 400, type: "crystals" }, ], status: "locked", zoneId: "shadow_marshes", @@ -240,14 +276,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 300_000_000, description: "A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.", - durationSeconds: 3 * 60 * 60, + durationSeconds: 25 * 60, id: "plague_ruins", name: "The Plague Ruins", prerequisiteIds: [ "sunken_temple" ], rewards: [ - { amount: 8_000_000, type: "gold" }, - { amount: 2000, type: "essence" }, - { amount: 150, type: "crystals" }, + { amount: 100_000_000, type: "gold" }, + { amount: 30_000, type: "essence" }, + { amount: 500, type: "crystals" }, { targetId: "dark_templar", type: "adventurer" }, ], status: "locked", @@ -258,7 +294,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1_200_000_000, description: "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", - durationSeconds: 3 * 60 * 60, + durationSeconds: 25 * 60, id: "lava_flows", name: "The Lava Flows", prerequisiteIds: [], @@ -274,7 +310,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 4_800_000_000, description: "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", - durationSeconds: 5 * 60 * 60, + durationSeconds: 45 * 60, id: "fire_temple", name: "The Temple of the Flame", prerequisiteIds: [ "lava_flows" ], @@ -282,7 +318,6 @@ export const defaultQuests: Array = [ { amount: 40_000_000, type: "gold" }, { amount: 12_000, type: "essence" }, { amount: 300, type: "crystals" }, - { targetId: "peasant_3", type: "upgrade" }, ], status: "locked", zoneId: "volcanic_depths", @@ -291,7 +326,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 18_000_000_000, description: "Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.", - durationSeconds: 7 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "magma_caverns", name: "The Magma Caverns", prerequisiteIds: [ "fire_temple" ], @@ -307,7 +342,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 72_000_000_000, description: "The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", - durationSeconds: 10 * 60 * 60, + durationSeconds: 90 * 60, id: "the_forge", name: "The Primordial Forge", prerequisiteIds: [ "magma_caverns" ], @@ -324,13 +359,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 300_000_000_000, description: "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", - durationSeconds: 4 * 60 * 60, + durationSeconds: 35 * 60, id: "void_rift", name: "Void Rift", prerequisiteIds: [], rewards: [ - { amount: 500, type: "crystals" }, - { amount: 5000, type: "essence" }, + { amount: 2_000_000_000, type: "gold" }, + { amount: 300_000, type: "essence" }, + { amount: 1000, type: "crystals" }, ], status: "locked", zoneId: "astral_void", @@ -339,14 +375,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1_200_000_000_000, description: "A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.", - durationSeconds: 8 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "star_graveyard", name: "The Star Graveyard", prerequisiteIds: [ "void_rift" ], rewards: [ - { amount: 1_000_000_000, type: "gold" }, - { amount: 100_000, type: "essence" }, - { amount: 1000, type: "crystals" }, + { amount: 8_000_000_000, type: "gold" }, + { amount: 800_000, type: "essence" }, + { amount: 3000, type: "crystals" }, ], status: "locked", zoneId: "astral_void", @@ -355,13 +391,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4_800_000_000_000, description: "The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", - durationSeconds: 12 * 60 * 60, + durationSeconds: 90 * 60, id: "between_worlds", name: "Between Worlds", prerequisiteIds: [ "star_graveyard" ], rewards: [ - { amount: 250_000, type: "essence" }, - { amount: 2000, type: "crystals" }, + { amount: 25_000_000_000, type: "gold" }, + { amount: 2_000_000, type: "essence" }, + { amount: 8000, type: "crystals" }, { targetId: "divine_champion", type: "adventurer" }, ], status: "locked", @@ -371,14 +408,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 18_000_000_000_000, description: "There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", - durationSeconds: 24 * 60 * 60, + durationSeconds: 210 * 60, id: "the_end", name: "The End of All Things", prerequisiteIds: [ "between_worlds" ], rewards: [ - { amount: 10_000_000_000, type: "gold" }, - { amount: 1_000_000, type: "essence" }, - { amount: 10_000, type: "crystals" }, + { amount: 80_000_000_000, type: "gold" }, + { amount: 5_000_000, type: "essence" }, + { amount: 20_000, type: "crystals" }, ], status: "locked", zoneId: "astral_void", @@ -388,7 +425,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e13, description: "The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.", - durationSeconds: Math.round(1.5 * 60 * 60), + durationSeconds: 90 * 60, id: "heavens_gate", name: "The Heaven's Gate", prerequisiteIds: [], @@ -404,7 +441,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e14, description: "A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.", - durationSeconds: 3 * 60 * 60, + durationSeconds: 25 * 60, id: "angelic_choir", name: "The Angelic Choir", prerequisiteIds: [ "heavens_gate" ], @@ -419,7 +456,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e15, description: "Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.", - durationSeconds: 5 * 60 * 60, + durationSeconds: 45 * 60, id: "divine_library", name: "The Divine Library", prerequisiteIds: [ "angelic_choir" ], @@ -435,7 +472,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e15, description: "A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.", - durationSeconds: 8 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "cloud_citadel", name: "The Cloud Citadel", prerequisiteIds: [ "divine_library" ], @@ -451,7 +488,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e16, description: "The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.", - durationSeconds: 12 * 60 * 60, + durationSeconds: 90 * 60, id: "trial_of_virtue", name: "The Trial of Virtue", prerequisiteIds: [ "cloud_citadel" ], @@ -468,7 +505,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e16, description: "The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.", - durationSeconds: 20 * 60 * 60, + durationSeconds: 3 * 60 * 60, id: "celestial_archive", name: "The Celestial Archive", prerequisiteIds: [ "trial_of_virtue" ], @@ -485,7 +522,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e17, description: "The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.", - durationSeconds: 2 * 60 * 60, + durationSeconds: 15 * 60, id: "the_dark_waters", name: "The Dark Waters", prerequisiteIds: [], @@ -501,7 +538,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e18, description: "The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.", - durationSeconds: 4 * 60 * 60, + durationSeconds: 35 * 60, id: "bioluminescent_ruins", name: "The Bioluminescent Ruins", prerequisiteIds: [ "the_dark_waters" ], @@ -517,7 +554,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e18, description: "Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.", - durationSeconds: 7 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "pressure_caves", name: "The Pressure Caves", prerequisiteIds: [ "bioluminescent_ruins" ], @@ -533,7 +570,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e19, description: "Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.", - durationSeconds: 12 * 60 * 60, + durationSeconds: 90 * 60, id: "leviathan_graveyard", name: "The Leviathan Graveyard", prerequisiteIds: [ "pressure_caves" ], @@ -549,7 +586,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e19, description: "A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.", - durationSeconds: 18 * 60 * 60, + durationSeconds: 150 * 60, id: "black_throne", name: "The Black Throne", prerequisiteIds: [ "leviathan_graveyard" ], @@ -566,7 +603,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e20, description: "The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.", - durationSeconds: 30 * 60 * 60, + durationSeconds: 270 * 60, id: "abyssal_chronicle", name: "The Abyssal Chronicle", prerequisiteIds: [ "black_throne" ], @@ -583,7 +620,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e21, description: "The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.", - durationSeconds: 3 * 60 * 60, + durationSeconds: 25 * 60, id: "brimstone_wastes", name: "The Brimstone Wastes", prerequisiteIds: [], @@ -599,14 +636,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e21, description: "The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.", - durationSeconds: 6 * 60 * 60, + durationSeconds: 50 * 60, id: "pit_of_souls", name: "The Pit of Souls", prerequisiteIds: [ "brimstone_wastes" ], rewards: [ { amount: 2_000_000_000_000_000, type: "gold" }, { amount: 600_000_000_000, type: "essence" }, - { amount: 1_000_000_000, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infernal_court", @@ -615,14 +652,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e22, description: "The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.", - durationSeconds: 10 * 60 * 60, + durationSeconds: 90 * 60, id: "court_of_blood", name: "The Court of Blood", prerequisiteIds: [ "pit_of_souls" ], rewards: [ { amount: 6_000_000_000_000_000, type: "gold" }, { amount: 2_000_000_000_000, type: "essence" }, - { amount: 3_000_000_000, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infernal_court", @@ -631,14 +668,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e22, description: "Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.", - durationSeconds: 16 * 60 * 60, + durationSeconds: 150 * 60, id: "nine_hells", name: "The Nine Hells", prerequisiteIds: [ "court_of_blood" ], rewards: [ { amount: 2e16, type: "gold" }, { amount: 6_000_000_000_000, type: "essence" }, - { amount: 8_000_000_000, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infernal_court", @@ -647,14 +684,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e23, description: "The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.", - durationSeconds: 24 * 60 * 60, + durationSeconds: 210 * 60, id: "demon_forge", name: "The Demon Forge", prerequisiteIds: [ "nine_hells" ], rewards: [ { amount: 6e16, type: "gold" }, { amount: 2e13, type: "essence" }, - { amount: 2.5e10, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "infernal_warden_1", type: "upgrade" }, ], status: "locked", @@ -664,14 +701,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e24, description: "The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.", - durationSeconds: 40 * 60 * 60, + durationSeconds: 330 * 60, id: "infernal_codex", name: "The Infernal Codex", prerequisiteIds: [ "demon_forge" ], rewards: [ { amount: 2e17, type: "gold" }, { amount: 6e13, type: "essence" }, - { amount: 8e10, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infernal_court", @@ -681,7 +718,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e24, description: "The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.", - durationSeconds: 4 * 60 * 60, + durationSeconds: 35 * 60, id: "prism_gate", name: "The Prism Gate", prerequisiteIds: [], @@ -697,14 +734,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e25, description: "A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.", - durationSeconds: 8 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "crystal_labyrinth", name: "The Crystal Labyrinth", prerequisiteIds: [ "prism_gate" ], rewards: [ { amount: 2e18, type: "gold" }, { amount: 8e14, type: "essence" }, - { amount: 3e12, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -713,14 +750,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e25, description: "A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.", - durationSeconds: 14 * 60 * 60, + durationSeconds: 2 * 60 * 60, id: "faceted_realm", name: "The Faceted Realm", prerequisiteIds: [ "crystal_labyrinth" ], rewards: [ { amount: 8e18, type: "gold" }, { amount: 3e15, type: "essence" }, - { amount: 1e13, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -729,14 +766,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e26, description: "The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.", - durationSeconds: 20 * 60 * 60, + durationSeconds: 3 * 60 * 60, id: "diamond_vault", name: "The Diamond Vault", prerequisiteIds: [ "faceted_realm" ], rewards: [ { amount: 3e19, type: "gold" }, { amount: 1e16, type: "essence" }, - { amount: 4e13, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -745,14 +782,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e27, description: "The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.", - durationSeconds: 32 * 60 * 60, + durationSeconds: 270 * 60, id: "sovereign_spire", name: "The Sovereign's Spire", prerequisiteIds: [ "diamond_vault" ], rewards: [ { amount: 1e20, type: "gold" }, { amount: 4e16, type: "essence" }, - { amount: 1.5e14, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "crystal_sage_1", type: "upgrade" }, ], status: "locked", @@ -762,14 +799,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e27, description: "The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.", - durationSeconds: 50 * 60 * 60, + durationSeconds: 7 * 60 * 60, id: "the_prism_vault", name: "The Prism Vault", prerequisiteIds: [ "sovereign_spire" ], rewards: [ { amount: 4e20, type: "gold" }, { amount: 1.5e17, type: "essence" }, - { amount: 5e14, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -779,7 +816,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e28, description: "The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.", - durationSeconds: 6 * 60 * 60, + durationSeconds: 50 * 60, id: "void_threshold", name: "The Void Threshold", prerequisiteIds: [], @@ -795,14 +832,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e28, description: "Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.", - durationSeconds: 12 * 60 * 60, + durationSeconds: 90 * 60, id: "eternal_dark", name: "The Eternal Dark", prerequisiteIds: [ "void_threshold" ], rewards: [ { amount: 5e21, type: "gold" }, { amount: 2e18, type: "essence" }, - { amount: 2e15, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -811,14 +848,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e29, description: "The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.", - durationSeconds: 20 * 60 * 60, + durationSeconds: 3 * 60 * 60, id: "sanctum_depths", name: "The Sanctum Depths", prerequisiteIds: [ "eternal_dark" ], rewards: [ { amount: 2e22, type: "gold" }, { amount: 8e18, type: "essence" }, - { amount: 8e15, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -827,14 +864,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e30, description: "Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.", - durationSeconds: 30 * 60 * 60, + durationSeconds: 270 * 60, id: "unmaking_grounds", name: "The Unmaking Grounds", prerequisiteIds: [ "sanctum_depths" ], rewards: [ { amount: 8e22, type: "gold" }, { amount: 3e19, type: "essence" }, - { amount: 3e16, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -843,14 +880,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e30, description: "The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.", - durationSeconds: 48 * 60 * 60, + durationSeconds: 7 * 60 * 60, id: "emperor_approach", name: "The Emperor's Approach", prerequisiteIds: [ "unmaking_grounds" ], rewards: [ { amount: 3e23, type: "gold" }, { amount: 1e20, type: "essence" }, - { amount: 1e17, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "void_sentinel_1", type: "upgrade" }, ], status: "locked", @@ -860,14 +897,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e31, description: "The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.", - durationSeconds: 72 * 60 * 60, + durationSeconds: 10 * 60 * 60, id: "heart_of_void", name: "The Heart of the Void", prerequisiteIds: [ "emperor_approach" ], rewards: [ { amount: 1e24, type: "gold" }, { amount: 4e20, type: "essence" }, - { amount: 4e17, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -877,7 +914,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e31, description: "The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.", - durationSeconds: 8 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "throne_antechamber", name: "The Throne Antechamber", prerequisiteIds: [], @@ -893,14 +930,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e32, description: "A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.", - durationSeconds: 16 * 60 * 60, + durationSeconds: 150 * 60, id: "eternal_gauntlet", name: "The Eternal Gauntlet", prerequisiteIds: [ "throne_antechamber" ], rewards: [ { amount: 1e25, type: "gold" }, { amount: 4e21, type: "essence" }, - { amount: 1.5e18, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -909,14 +946,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e33, description: "The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.", - durationSeconds: 28 * 60 * 60, + durationSeconds: 4 * 60 * 60, id: "apex_trials", name: "The Apex Trials", prerequisiteIds: [ "eternal_gauntlet" ], rewards: [ { amount: 4e25, type: "gold" }, { amount: 1.5e22, type: "essence" }, - { amount: 5e18, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -925,14 +962,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e33, description: "The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.", - durationSeconds: 40 * 60 * 60, + durationSeconds: 330 * 60, id: "sovereign_hall", name: "The Sovereign's Hall", prerequisiteIds: [ "apex_trials" ], rewards: [ { amount: 1.5e26, type: "gold" }, { amount: 6e22, type: "essence" }, - { amount: 2e19, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "eternal_champion_1", type: "upgrade" }, ], status: "locked", @@ -942,14 +979,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e34, description: "The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.", - durationSeconds: 60 * 60 * 60, + durationSeconds: 9 * 60 * 60, id: "the_final_ascent", name: "The Final Ascent", prerequisiteIds: [ "sovereign_hall" ], rewards: [ { amount: 6e26, type: "gold" }, { amount: 2.5e23, type: "essence" }, - { amount: 8e19, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -958,14 +995,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e34, description: "The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.", - durationSeconds: 96 * 60 * 60, + durationSeconds: 14 * 60 * 60, id: "eternal_dominion", name: "Eternal Dominion", prerequisiteIds: [ "the_final_ascent" ], rewards: [ { amount: 3e27, type: "gold" }, { amount: 1e24, type: "essence" }, - { amount: 4e20, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -975,7 +1012,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e35, description: "Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.", - durationSeconds: 10 * 60 * 60, + durationSeconds: 90 * 60, id: "chaos_entry", name: "Into the Chaos", prerequisiteIds: [], @@ -991,14 +1028,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e36, description: "Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.", - durationSeconds: 18 * 60 * 60, + durationSeconds: 150 * 60, id: "chaos_currents", name: "The Chaos Currents", prerequisiteIds: [ "chaos_entry" ], rewards: [ { amount: 4e28, type: "gold" }, { amount: 1.5e25, type: "essence" }, - { amount: 5e21, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primordial_chaos", @@ -1007,14 +1044,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e36, description: "A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.", - durationSeconds: 30 * 60 * 60, + durationSeconds: 270 * 60, id: "unformed_wastes", name: "The Unformed Wastes", prerequisiteIds: [ "chaos_currents" ], rewards: [ { amount: 2e29, type: "gold" }, { amount: 8e25, type: "essence" }, - { amount: 2e22, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "titan_warrior", type: "adventurer" }, ], status: "locked", @@ -1024,14 +1061,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e37, description: "Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.", - durationSeconds: 45 * 60 * 60, + durationSeconds: 6 * 60 * 60, id: "potential_vaults", name: "The Vaults of Potential", prerequisiteIds: [ "unformed_wastes" ], rewards: [ { amount: 1e30, type: "gold" }, { amount: 4e26, type: "essence" }, - { amount: 8e22, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primordial_chaos", @@ -1040,14 +1077,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e37, description: "The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.", - durationSeconds: 65 * 60 * 60, + durationSeconds: 9 * 60 * 60, id: "creation_cradle", name: "The Creation Cradle", prerequisiteIds: [ "potential_vaults" ], rewards: [ { amount: 6e30, type: "gold" }, { amount: 2e27, type: "essence" }, - { amount: 4e23, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "titan_warrior_1", type: "upgrade" }, ], status: "locked", @@ -1057,14 +1094,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e38, description: "The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.", - durationSeconds: 90 * 60 * 60, + durationSeconds: 13 * 60 * 60, id: "chaos_chronicle", name: "The Chaos Chronicle", prerequisiteIds: [ "creation_cradle" ], rewards: [ { amount: 3e31, type: "gold" }, { amount: 1e28, type: "essence" }, - { amount: 2e24, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primordial_chaos", @@ -1074,7 +1111,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e39, description: "The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.", - durationSeconds: 12 * 60 * 60, + durationSeconds: 90 * 60, id: "first_horizon", name: "The First Horizon", prerequisiteIds: [], @@ -1090,14 +1127,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e39, description: "An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.", - durationSeconds: 22 * 60 * 60, + durationSeconds: 3 * 60 * 60, id: "endless_sea", name: "The Endless Sea", prerequisiteIds: [ "first_horizon" ], rewards: [ { amount: 6e34, type: "gold" }, { amount: 2e31, type: "essence" }, - { amount: 5e27, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infinite_expanse", @@ -1106,14 +1143,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e40, description: "Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.", - durationSeconds: 36 * 60 * 60, + durationSeconds: 5 * 60 * 60, id: "expanse_ruins", name: "The Expanse Ruins", prerequisiteIds: [ "endless_sea" ], rewards: [ { amount: 3e36, type: "gold" }, { amount: 1e33, type: "essence" }, - { amount: 2.5e29, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "cosmos_knight", type: "adventurer" }, ], status: "locked", @@ -1123,14 +1160,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e40, description: "A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.", - durationSeconds: 55 * 60 * 60, + durationSeconds: 8 * 60 * 60, id: "infinite_archive", name: "The Infinite Archive", prerequisiteIds: [ "expanse_ruins" ], rewards: [ { amount: 1.5e38, type: "gold" }, { amount: 5e34, type: "essence" }, - { amount: 1e31, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "nexus_sage_1", type: "upgrade" }, ], status: "locked", @@ -1140,14 +1177,15 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e41, description: "A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.", - durationSeconds: 80 * 60 * 60, + durationSeconds: 11 * 60 * 60, id: "paradox_plains", name: "The Paradox Plains", prerequisiteIds: [ "infinite_archive" ], rewards: [ { amount: 8e39, type: "gold" }, { amount: 2.5e36, type: "essence" }, - { amount: 5e32, type: "crystals" }, + { amount: 0, type: "crystals" }, + { targetId: "cosmos_knight_1", type: "upgrade" }, ], status: "locked", zoneId: "infinite_expanse", @@ -1156,14 +1194,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e42, description: "The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.", - durationSeconds: 110 * 60 * 60, + durationSeconds: 16 * 60 * 60, id: "expanse_codex", name: "The Expanse Codex", prerequisiteIds: [ "paradox_plains" ], rewards: [ { amount: 4e41, type: "gold" }, { amount: 1.2e38, type: "essence" }, - { amount: 2.5e34, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infinite_expanse", @@ -1173,7 +1211,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e42, description: "The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.", - durationSeconds: 14 * 60 * 60, + durationSeconds: 2 * 60 * 60, id: "forge_entrance", name: "The Forge Entrance", prerequisiteIds: [], @@ -1189,14 +1227,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e43, description: "The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.", - durationSeconds: 25 * 60 * 60, + durationSeconds: 210 * 60, id: "blueprint_vault", name: "The Blueprint Vault", prerequisiteIds: [ "forge_entrance" ], rewards: [ { amount: 1e46, type: "gold" }, { amount: 3e42, type: "essence" }, - { amount: 2e38, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1205,14 +1243,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e43, description: "The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.", - durationSeconds: 40 * 60 * 60, + durationSeconds: 330 * 60, id: "creation_workshop", name: "The Creation Workshop", prerequisiteIds: [ "blueprint_vault" ], rewards: [ { amount: 5e47, type: "gold" }, { amount: 1.5e44, type: "essence" }, - { amount: 1e40, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "primordial_mage", type: "adventurer" }, ], status: "locked", @@ -1222,15 +1260,15 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e44, description: "The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.", - durationSeconds: 60 * 60 * 60, + durationSeconds: 9 * 60 * 60, id: "laws_engine", name: "The Laws Engine", prerequisiteIds: [ "creation_workshop" ], rewards: [ { amount: 2.5e49, type: "gold" }, { amount: 8e45, type: "essence" }, - { amount: 5e41, type: "crystals" }, - { targetId: "cosmos_knight_1", type: "upgrade" }, + { amount: 0, type: "crystals" }, + { targetId: "primordial_mage_1", type: "upgrade" }, ], status: "locked", zoneId: "reality_forge", @@ -1239,14 +1277,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e45, description: "The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.", - durationSeconds: 85 * 60 * 60, + durationSeconds: 12 * 60 * 60, id: "forge_heart", name: "The Forge Heart", prerequisiteIds: [ "laws_engine" ], rewards: [ { amount: 1.2e51, type: "gold" }, { amount: 4e47, type: "essence" }, - { amount: 2.5e43, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1255,14 +1293,63 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e45, description: "The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.", - durationSeconds: 120 * 60 * 60, + durationSeconds: 17 * 60 * 60, id: "forge_chronicle", name: "The Forge Chronicle", prerequisiteIds: [ "forge_heart" ], rewards: [ { amount: 6e52, type: "gold" }, { amount: 2e49, type: "essence" }, - { amount: 1.2e45, type: "crystals" }, + { amount: 0, type: "crystals" }, + { targetId: "astral_sovereign_1", type: "upgrade" }, + ], + status: "locked", + zoneId: "reality_forge", + }, + { + combatPowerRequired: 1e59, + description: + "The deep levels of the Forge, where the most experimental realities are stored in a state of near-completion. Your guild discovers that several of these abandoned projects are disturbingly familiar.", + durationSeconds: 24 * 60 * 60, + id: "forge_depths", + name: "The Forge Depths", + prerequisiteIds: [ "forge_chronicle" ], + rewards: [ + { amount: 5e62, type: "gold" }, + { amount: 1.5e59, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "reality_forge", + }, + { + combatPowerRequired: 3e64, + description: + "The underlying structure that defines the laws for every reality the Forge produces — a lattice of constraints so fundamental that violating them would undo everything. Your guild crosses it carefully.", + durationSeconds: 24 * 60 * 60, + id: "prime_matrix", + name: "The Prime Matrix", + prerequisiteIds: [ "forge_depths" ], + rewards: [ + { amount: 2e67, type: "gold" }, + { amount: 6e63, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "reality_forge", + }, + { + combatPowerRequired: 8e69, + description: + "The complete record of every reality the Forge has ever produced, indexed, annotated, and preserved. Your universe has a surprisingly detailed entry with several editorial notes.", + durationSeconds: 24 * 60 * 60, + id: "creation_archive", + name: "The Creation Archive", + prerequisiteIds: [ "prime_matrix" ], + rewards: [ + { amount: 1e72, type: "gold" }, + { amount: 3e68, type: "essence" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1272,7 +1359,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e46, description: "The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.", - durationSeconds: 16 * 60 * 60, + durationSeconds: 150 * 60, id: "maelstrom_entry", name: "The Maelstrom's Edge", prerequisiteIds: [], @@ -1288,14 +1375,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e46, description: "The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.", - durationSeconds: 28 * 60 * 60, + durationSeconds: 4 * 60 * 60, id: "force_nexus", name: "The Force Nexus", prerequisiteIds: [ "maelstrom_entry" ], rewards: [ { amount: 1.5e58, type: "gold" }, { amount: 5e54, type: "essence" }, - { amount: 3e50, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1304,14 +1391,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e47, description: "A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.", - durationSeconds: 45 * 60 * 60, + durationSeconds: 6 * 60 * 60, id: "storm_cauldron", name: "The Storm Cauldron", prerequisiteIds: [ "force_nexus" ], rewards: [ { amount: 8e60, type: "gold" }, { amount: 2.5e57, type: "essence" }, - { amount: 1.5e53, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "infinity_ranger", type: "adventurer" }, ], status: "locked", @@ -1321,15 +1408,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e48, description: "Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.", - durationSeconds: 65 * 60 * 60, + durationSeconds: 9 * 60 * 60, id: "annihilation_fields", name: "The Annihilation Fields", prerequisiteIds: [ "storm_cauldron" ], rewards: [ { amount: 4e63, type: "gold" }, { amount: 1.2e60, type: "essence" }, - { amount: 7e55, type: "crystals" }, - { targetId: "astral_sovereign_1", type: "upgrade" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1338,14 +1424,15 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e48, description: "The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.", - durationSeconds: 90 * 60 * 60, + durationSeconds: 13 * 60 * 60, id: "convergence_point", name: "The Convergence Point", prerequisiteIds: [ "annihilation_fields" ], rewards: [ { amount: 2e66, type: "gold" }, { amount: 6e62, type: "essence" }, - { amount: 3.5e58, type: "crystals" }, + { amount: 0, type: "crystals" }, + { targetId: "reality_warden_1", type: "upgrade" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1354,14 +1441,63 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e49, description: "The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.", - durationSeconds: 130 * 60 * 60, + durationSeconds: 19 * 60 * 60, id: "maelstrom_codex", name: "The Maelstrom Codex", prerequisiteIds: [ "convergence_point" ], rewards: [ { amount: 1e69, type: "gold" }, { amount: 3e65, type: "essence" }, - { amount: 1.8e61, type: "crystals" }, + { amount: 0, type: "crystals" }, + { targetId: "infinity_ranger_1", type: "upgrade" }, + ], + status: "locked", + zoneId: "cosmic_maelstrom", + }, + { + combatPowerRequired: 2e73, + description: + "The deepest layer of the maelstrom — where the storms have been spiralling for so long they have created something resembling order. Your guild navigates it by learning to read the shape of chaos.", + durationSeconds: 24 * 60 * 60, + id: "maelstrom_deep", + name: "The Deep Maelstrom", + prerequisiteIds: [ "maelstrom_codex" ], + rewards: [ + { amount: 5e76, type: "gold" }, + { amount: 1.5e73, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "cosmic_maelstrom", + }, + { + combatPowerRequired: 5e79, + description: + "The point at which all the storm currents converge — not because they are drawn there, but because there is nowhere else left to go. Your guild stands in the geometric centre of cosmic fury.", + durationSeconds: 24 * 60 * 60, + id: "maelstrom_nexus", + name: "The Storm Nexus", + prerequisiteIds: [ "maelstrom_deep" ], + rewards: [ + { amount: 2e83, type: "gold" }, + { amount: 6e79, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "cosmic_maelstrom", + }, + { + combatPowerRequired: 1e86, + description: + "The record of every storm that has ever been — an archive written in lightning, indexed in thunder, preserved in the kind of silence that only exists at the exact centre of infinite noise.", + durationSeconds: 24 * 60 * 60, + id: "storm_chronicle", + name: "The Storm Chronicle", + prerequisiteIds: [ "maelstrom_nexus" ], + rewards: [ + { amount: 1e90, type: "gold" }, + { amount: 3e86, type: "essence" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1371,7 +1507,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e49, description: "The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.", - durationSeconds: 18 * 60 * 60, + durationSeconds: 150 * 60, id: "sanctum_gate", name: "The Sanctum Gate", prerequisiteIds: [], @@ -1387,14 +1523,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e50, description: "The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.", - durationSeconds: 32 * 60 * 60, + durationSeconds: 270 * 60, id: "memory_vaults", name: "The Memory Vaults", prerequisiteIds: [ "sanctum_gate" ], rewards: [ { amount: 2.5e76, type: "gold" }, { amount: 7e72, type: "essence" }, - { amount: 4e68, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1403,14 +1539,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e51, description: "The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.", - durationSeconds: 50 * 60 * 60, + durationSeconds: 7 * 60 * 60, id: "origin_halls", name: "The Origin Halls", prerequisiteIds: [ "memory_vaults" ], rewards: [ { amount: 1.2e80, type: "gold" }, { amount: 3.5e76, type: "essence" }, - { amount: 2e72, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "transcendent_rogue", type: "adventurer" }, ], status: "locked", @@ -1420,15 +1556,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e51, description: "The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.", - durationSeconds: 72 * 60 * 60, + durationSeconds: 10 * 60 * 60, id: "first_light_hall", name: "The Hall of First Light", prerequisiteIds: [ "origin_halls" ], rewards: [ { amount: 6e83, type: "gold" }, { amount: 1.8e80, type: "essence" }, - { amount: 1e76, type: "crystals" }, - { targetId: "primordial_mage_1", type: "upgrade" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1437,14 +1572,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e52, description: "A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.", - durationSeconds: 100 * 60 * 60, + durationSeconds: 14 * 60 * 60, id: "before_time", name: "Before Time", prerequisiteIds: [ "first_light_hall" ], rewards: [ { amount: 3e87, type: "gold" }, { amount: 9e83, type: "essence" }, - { amount: 5e79, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1453,14 +1588,62 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e52, description: "The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.", - durationSeconds: 144 * 60 * 60, + durationSeconds: 21 * 60 * 60, id: "sanctum_chronicle", name: "The Sanctum Chronicle", prerequisiteIds: [ "before_time" ], rewards: [ { amount: 1.5e91, type: "gold" }, { amount: 4.5e87, type: "essence" }, - { amount: 2.5e83, type: "crystals" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "primeval_sanctum", + }, + { + combatPowerRequired: 3e92, + description: + "The deepest chambers of the sanctum — where the primordia are not preserved but still occurring, still being, still becoming for the first and only time. The floor hums with unfinished creation.", + durationSeconds: 24 * 60 * 60, + id: "sanctum_deep", + name: "The Deep Sanctum", + prerequisiteIds: [ "sanctum_chronicle" ], + rewards: [ + { amount: 8e95, type: "gold" }, + { amount: 2.5e92, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "primeval_sanctum", + }, + { + combatPowerRequired: 8e98, + description: + "The crossroads between everything primeval and everything that came after — a threshold so old that every subsequent age of the universe is, from its perspective, still ongoing.", + durationSeconds: 24 * 60 * 60, + id: "sanctum_nexus", + name: "The Primeval Nexus", + prerequisiteIds: [ "sanctum_deep" ], + rewards: [ + { amount: 4e102, type: "gold" }, + { amount: 1.2e99, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "primeval_sanctum", + }, + { + combatPowerRequired: 2e105, + description: + "The sanctum's final gift to those who reached its depths: a full accounting of what it means to have existed before time had opinions about how things should go. Your guild is the first to read it.", + durationSeconds: 24 * 60 * 60, + id: "primeval_archive", + name: "The Primeval Archive", + prerequisiteIds: [ "sanctum_nexus" ], + rewards: [ + { amount: 2e109, type: "gold" }, + { amount: 6e105, type: "essence" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1470,7 +1653,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e53, description: "The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.", - durationSeconds: 20 * 60 * 60, + durationSeconds: 3 * 60 * 60, id: "absolute_threshold", name: "The Absolute Threshold", prerequisiteIds: [], @@ -1486,14 +1669,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.2e54, description: "Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.", - durationSeconds: 36 * 60 * 60, + durationSeconds: 5 * 60 * 60, id: "nothing_wastes", name: "The Nothing Wastes", prerequisiteIds: [ "absolute_threshold" ], rewards: [ { amount: 4e101, type: "gold" }, { amount: 1.2e98, type: "essence" }, - { amount: 6e93, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1502,15 +1685,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 4.8e54, description: "A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.", - durationSeconds: 56 * 60 * 60, + durationSeconds: 8 * 60 * 60, id: "final_paradox", name: "The Final Paradox", prerequisiteIds: [ "nothing_wastes" ], rewards: [ { amount: 2e108, type: "gold" }, { amount: 6e104, type: "essence" }, - { amount: 3e100, type: "crystals" }, - { targetId: "reality_warden_1", type: "upgrade" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1519,14 +1701,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 1.8e55, description: "Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.", - durationSeconds: 80 * 60 * 60, + durationSeconds: 11 * 60 * 60, id: "end_vault", name: "The Vault of Ends", prerequisiteIds: [ "final_paradox" ], rewards: [ { amount: 1e115, type: "gold" }, { amount: 3e111, type: "essence" }, - { amount: 1.5e107, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1535,15 +1717,14 @@ export const defaultQuests: Array = [ combatPowerRequired: 7.2e55, description: "The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.", - durationSeconds: 120 * 60 * 60, + durationSeconds: 17 * 60 * 60, id: "terminal_approach", name: "The Terminal Approach", prerequisiteIds: [ "end_vault" ], rewards: [ { amount: 5e121, type: "gold" }, { amount: 1.5e118, type: "essence" }, - { amount: 7e113, type: "crystals" }, - { targetId: "infinity_ranger_1", type: "upgrade" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1552,14 +1733,110 @@ export const defaultQuests: Array = [ combatPowerRequired: 3e56, description: "This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.", - durationSeconds: 168 * 60 * 60, + durationSeconds: 24 * 60 * 60, id: "absolute_dominion", name: "Absolute Dominion", prerequisiteIds: [ "terminal_approach" ], rewards: [ { amount: 3e130, type: "gold" }, { amount: 9e126, type: "essence" }, - { amount: 4e122, type: "crystals" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, + { + combatPowerRequired: 5e111, + description: + "Beyond the end of everything, there is more. Not in contradiction — but in the way that answers, once found, reveal the next question. Your guild goes further than the concept of further was designed to accommodate.", + durationSeconds: 24 * 60 * 60, + id: "absolute_beyond", + name: "Beyond the Absolute", + prerequisiteIds: [ "absolute_dominion" ], + rewards: [ + { amount: 1e118, type: "gold" }, + { amount: 3e114, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, + { + combatPowerRequired: 1e118, + description: + "The region that exists past the end of existence — a space defined not by what it contains but by being the place where containment no longer applies. Your guild navigates it by not needing it to make sense.", + durationSeconds: 24 * 60 * 60, + id: "absolute_depth", + name: "The Absolute Depth", + prerequisiteIds: [ "absolute_beyond" ], + rewards: [ + { amount: 5e124, type: "gold" }, + { amount: 1.5e121, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, + { + combatPowerRequired: 3e124, + description: + "The final record: not of what happened, but of the fact that it happened at all. A guild from a mortal realm reached the end of all things and chose to keep going. The universe notes this with something that is not quite surprise.", + durationSeconds: 24 * 60 * 60, + id: "absolute_chronicle", + name: "The Absolute Chronicle", + prerequisiteIds: [ "absolute_depth" ], + rewards: [ + { amount: 2e131, type: "gold" }, + { amount: 6e127, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, + { + combatPowerRequired: 2e130, + description: + "A region beyond the final record — where even the concept of record has ended and only raw, unwitnessed existence remains. Your guild walks it anyway, because that is what your guild does.", + durationSeconds: 24 * 60 * 60, + id: "post_absolute_wastes", + name: "The Post-Absolute Wastes", + prerequisiteIds: [ "absolute_chronicle" ], + rewards: [ + { amount: 1e137, type: "gold" }, + { amount: 3e133, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, + { + combatPowerRequired: 1e137, + description: + "The space between the final end and whatever follows it — a silence so complete that your guild's presence is the loudest thing that has ever existed here. They proceed in hushed awe.", + durationSeconds: 24 * 60 * 60, + id: "terminal_silence", + name: "The Terminal Silence", + prerequisiteIds: [ "post_absolute_wastes" ], + rewards: [ + { amount: 5e143, type: "gold" }, + { amount: 1.5e140, type: "essence" }, + { amount: 0, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, + { + combatPowerRequired: 5e141, + description: + "The last thing your guild will ever witness before the Absolute One. Not a place. Not a moment. A threshold so final that crossing it means there is only one thing left in existence worth confronting. Your guild crosses it.", + durationSeconds: 24 * 60 * 60, + id: "final_threshold", + name: "The Final Threshold", + prerequisiteIds: [ "terminal_silence" ], + rewards: [ + { amount: 2e150, type: "gold" }, + { amount: 6e146, type: "essence" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 8564a2b..28ab3be 100644 --- a/apps/api/src/data/recipes.ts +++ b/apps/api/src/data/recipes.ts @@ -23,7 +23,7 @@ export const defaultRecipes: Array = [ zoneId: "verdant_vale", }, { - bonus: { type: "combat_power", value: 1.08 }, + bonus: { type: "combat_power", value: 1.2 }, description: "A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.", id: "elder_bark_shield", @@ -75,7 +75,7 @@ export const defaultRecipes: Array = [ zoneId: "frozen_peaks", }, { - bonus: { type: "gold_income", value: 1.1 }, + bonus: { type: "gold_income", value: 1.15 }, description: "The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.", id: "void_fragment_amulet", @@ -101,7 +101,7 @@ export const defaultRecipes: Array = [ zoneId: "shadow_marshes", }, { - bonus: { type: "combat_power", value: 1.1 }, + bonus: { type: "combat_power", value: 1.15 }, description: "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.", id: "cursed_focus", @@ -127,7 +127,7 @@ export const defaultRecipes: Array = [ zoneId: "volcanic_depths", }, { - bonus: { type: "combat_power", value: 1.12 }, + bonus: { type: "combat_power", value: 1.2 }, description: "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.", id: "elemental_ore_ingot", @@ -193,7 +193,7 @@ export const defaultRecipes: Array = [ // Zone 8: abyssal_trench { - bonus: { type: "combat_power", value: 1.15 }, + bonus: { type: "combat_power", value: 1.25 }, description: "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.", id: "pressure_forged_core", @@ -231,7 +231,7 @@ export const defaultRecipes: Array = [ zoneId: "infernal_court", }, { - bonus: { type: "essence_income", value: 1.15 }, + bonus: { type: "essence_income", value: 1.2 }, description: "Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.", id: "soul_bound_catalyst", @@ -271,7 +271,7 @@ export const defaultRecipes: Array = [ // Zone 11: void_sanctum { - bonus: { type: "combat_power", value: 1.18 }, + bonus: { type: "combat_power", value: 1.28 }, description: "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.", id: "null_field_generator", @@ -309,7 +309,7 @@ export const defaultRecipes: Array = [ zoneId: "eternal_throne", }, { - bonus: { type: "combat_power", value: 1.2 }, + bonus: { type: "combat_power", value: 1.3 }, description: "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.", id: "eternity_bound_ring", @@ -375,7 +375,7 @@ export const defaultRecipes: Array = [ // Zone 15: reality_forge { - bonus: { type: "combat_power", value: 1.22 }, + bonus: { type: "combat_power", value: 1.35 }, description: "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.", id: "reality_ingot", @@ -427,7 +427,7 @@ export const defaultRecipes: Array = [ // Zone 17: primeval_sanctum { - bonus: { type: "combat_power", value: 1.25 }, + bonus: { type: "combat_power", value: 1.4 }, description: "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.", id: "ancient_memory_array", @@ -493,7 +493,20 @@ export const defaultRecipes: Array = [ zoneId: "abyssal_trench", }, { - bonus: { type: "combat_power", value: 1.4 }, + bonus: { type: "click_power", value: 1.38 }, + description: + "A primeval relic submerged at the absolute boundary of existence alongside omega crystals and boundary shards — the first and last thing, unified. Every action your guild takes through it is simultaneously the most ancient and most final thing that has ever happened. It does not miss.", + id: "primal_omega_lens", + name: "Primal Omega Lens", + requiredMaterials: [ + { materialId: "primeval_relic", quantity: 2 }, + { materialId: "boundary_shard", quantity: 4 }, + { materialId: "omega_crystal", quantity: 2 }, + ], + zoneId: "the_absolute", + }, + { + bonus: { type: "combat_power", value: 1.65 }, 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.", id: "eternal_omega", @@ -508,6 +521,18 @@ export const defaultRecipes: Array = [ }, // Zone 18: the_absolute + { + bonus: { type: "click_power", value: 1.28 }, + description: + "Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.", + id: "absolute_focus", + name: "Absolute Focus", + requiredMaterials: [ + { materialId: "absolute_fragment", quantity: 8 }, + { materialId: "omega_crystal", quantity: 3 }, + ], + zoneId: "the_absolute", + }, { bonus: { type: "gold_income", value: 1.3 }, description: @@ -521,7 +546,7 @@ export const defaultRecipes: Array = [ zoneId: "the_absolute", }, { - bonus: { type: "combat_power", value: 1.3 }, + bonus: { type: "combat_power", value: 1.55 }, description: "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.", id: "omega_convergence", diff --git a/apps/api/src/data/transcendenceUpgrades.ts b/apps/api/src/data/transcendenceUpgrades.ts index cd4f301..dd06d45 100644 --- a/apps/api/src/data/transcendenceUpgrades.ts +++ b/apps/api/src/data/transcendenceUpgrades.ts @@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array = [ // ── Income multipliers ────────────────────────────────────────────────────── { category: "income", - cost: 5, + cost: 2, description: "The echoes of past runs linger, amplifying your guild's income by 25%.", id: "echo_income_1", @@ -20,7 +20,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "income", - cost: 10, + cost: 4, description: "Your transcendent experience resonates through your guild, boosting income by 50%.", id: "echo_income_2", @@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "income", - cost: 20, + cost: 8, description: "The harmony of multiple timelines surges through your guild, doubling its income.", id: "echo_income_3", @@ -38,7 +38,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "income", - cost: 40, + cost: 16, description: "Ethereal energy overflows from your transcendence, tripling your guild's income.", id: "echo_income_4", @@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "income", - cost: 80, + cost: 32, description: "The infinite chorus of every run you've ever played amplifies your guild fivefold.", id: "echo_income_5", @@ -58,7 +58,7 @@ export const defaultTranscendenceUpgrades: Array = [ // ── Combat multipliers ────────────────────────────────────────────────────── { category: "combat", - cost: 5, + cost: 2, description: "Memories of countless battles harden your adventurers, increasing party DPS by 25%.", id: "echo_combat_1", @@ -67,7 +67,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "combat", - cost: 15, + cost: 6, description: "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.", id: "echo_combat_2", @@ -76,7 +76,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "combat", - cost: 35, + cost: 12, description: "Your warriors carry the strength of every fallen timeline, doubling party DPS.", id: "echo_combat_3", @@ -87,7 +87,7 @@ export const defaultTranscendenceUpgrades: Array = [ // ── Prestige threshold reductions ────────────────────────────────────────── { category: "prestige_threshold", - cost: 8, + cost: 3, description: "Experience from past lives shortens the road to prestige — threshold reduced by 10%.", id: "echo_prestige_threshold_1", @@ -96,7 +96,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "prestige_threshold", - cost: 20, + cost: 6, description: "You've walked this path so many times you know every shortcut — threshold reduced by 20%.", id: "echo_prestige_threshold_2", @@ -107,7 +107,7 @@ export const defaultTranscendenceUpgrades: Array = [ // ── Prestige runestone multipliers ───────────────────────────────────────── { category: "prestige_runestones", - cost: 8, + cost: 3, description: "Transcendent insight attunes you to the runestones, earning 50% more per prestige.", id: "echo_prestige_runestones_1", @@ -116,7 +116,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "prestige_runestones", - cost: 20, + cost: 6, description: "You have mastered the art of runestone crafting, doubling your prestige runestone yield.", id: "echo_prestige_runestones_2", @@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array = [ // ── Echo meta multipliers ─────────────────────────────────────────────────── { category: "echo_meta", - cost: 50, + cost: 15, description: "Your transcendence resonates deeper, amplifying future echo yields by 25%.", id: "echo_meta_1", @@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "echo_meta", - cost: 150, + cost: 45, description: "Each loop of existence makes the next more powerful — future echo yields +50%.", id: "echo_meta_2", @@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array = [ }, { category: "echo_meta", - cost: 400, + cost: 100, description: "You have mastered the infinite spiral of transcendence, doubling all future echo yields.", id: "echo_meta_3", diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 28e14eb..afbd696 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -48,7 +48,7 @@ export const defaultUpgrades: Array = [ unlocked: false, }, { - costCrystals: 100, + costCrystals: 50, costEssence: 0, costGold: 0, description: @@ -104,7 +104,7 @@ export const defaultUpgrades: Array = [ description: "Forge partnerships with mage guilds across the realm. All income +50%.", id: "essence_guild", - multiplier: 1.5, + multiplier: 2, name: "Essence Guild", purchased: false, target: "global", @@ -459,7 +459,7 @@ export const defaultUpgrades: Array = [ unlocked: false, }, { - costCrystals: 10_000_000, + costCrystals: 50_000_000, costEssence: 0, costGold: 0, description: "Transcend mortal limits through void energy. All income x3.", diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index fe41a29..47a4754 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -24,6 +24,13 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { logger } from "../services/logger.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(); bossRouter.use("*", authMiddleware); @@ -38,8 +45,7 @@ const calculatePartyStats = ( } } - // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear - const prestigeMultiplier = 1 + state.prestige.count * 0.1; + const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count); // Apply equipped weapon's combat bonus // eslint-disable-next-line capitalized-comments -- v8 ignore diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 080e5f5..b50318f 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -642,6 +642,14 @@ const patchAdventurerStats = (state: GameState): number => { if (defaultAdventurer === undefined) { continue; } + const hasChanged + = savedAdventurer.baseCost !== defaultAdventurer.baseCost + || savedAdventurer.class !== defaultAdventurer.class + || savedAdventurer.combatPower !== defaultAdventurer.combatPower + || savedAdventurer.essencePerSecond !== defaultAdventurer.essencePerSecond + || savedAdventurer.goldPerSecond !== defaultAdventurer.goldPerSecond + || savedAdventurer.level !== defaultAdventurer.level + || savedAdventurer.name !== defaultAdventurer.name; savedAdventurer.baseCost = defaultAdventurer.baseCost; savedAdventurer.class = defaultAdventurer.class; savedAdventurer.combatPower = defaultAdventurer.combatPower; @@ -649,7 +657,9 @@ const patchAdventurerStats = (state: GameState): number => { savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond; savedAdventurer.level = defaultAdventurer.level; savedAdventurer.name = defaultAdventurer.name; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -670,6 +680,15 @@ const patchQuestStats = (state: GameState): number => { if (defaultQuest === undefined) { continue; } + const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds); + const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds); + const hasChanged + = savedQuest.name !== defaultQuest.name + || savedQuest.description !== defaultQuest.description + || savedQuest.durationSeconds !== defaultQuest.durationSeconds + || savedPrereqs !== defaultPrereqs + || savedQuest.zoneId !== defaultQuest.zoneId + || savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired; savedQuest.name = defaultQuest.name; savedQuest.description = defaultQuest.description; savedQuest.durationSeconds = defaultQuest.durationSeconds; @@ -678,7 +697,9 @@ const patchQuestStats = (state: GameState): number => { if (defaultQuest.combatPowerRequired !== undefined) { savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired; } - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -689,6 +710,7 @@ const patchQuestStats = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns The number of boss entries whose stats were updated. */ +/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */ const patchBossStats = (state: GameState): number => { const defaultBossMap = new Map(defaultBosses.map((boss) => { return [ boss.id, boss ] as const; @@ -699,6 +721,20 @@ const patchBossStats = (state: GameState): number => { if (defaultBoss === undefined) { continue; } + const savedRewards = JSON.stringify(savedBoss.equipmentRewards); + const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards); + const hasChanged + = savedBoss.name !== defaultBoss.name + || savedBoss.description !== defaultBoss.description + || savedBoss.maxHp !== defaultBoss.maxHp + || savedBoss.damagePerSecond !== defaultBoss.damagePerSecond + || savedBoss.goldReward !== defaultBoss.goldReward + || savedBoss.essenceReward !== defaultBoss.essenceReward + || savedBoss.crystalReward !== defaultBoss.crystalReward + || savedRewards !== defaultRewards + || savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement + || savedBoss.zoneId !== defaultBoss.zoneId + || savedBoss.bountyRunestones !== defaultBoss.bountyRunestones; savedBoss.name = defaultBoss.name; savedBoss.description = defaultBoss.description; savedBoss.maxHp = defaultBoss.maxHp; @@ -710,7 +746,9 @@ const patchBossStats = (state: GameState): number => { savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement; savedBoss.zoneId = defaultBoss.zoneId; savedBoss.bountyRunestones = defaultBoss.bountyRunestones; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -731,12 +769,20 @@ const patchZoneStats = (state: GameState): number => { if (defaultZone === undefined) { continue; } + const hasChanged + = savedZone.name !== defaultZone.name + || savedZone.description !== defaultZone.description + || savedZone.emoji !== defaultZone.emoji + || savedZone.unlockBossId !== defaultZone.unlockBossId + || savedZone.unlockQuestId !== defaultZone.unlockQuestId; savedZone.name = defaultZone.name; savedZone.description = defaultZone.description; savedZone.emoji = defaultZone.emoji; savedZone.unlockBossId = defaultZone.unlockBossId; savedZone.unlockQuestId = defaultZone.unlockQuestId; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -747,6 +793,7 @@ const patchZoneStats = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns The number of upgrade entries whose stats were updated. */ +/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */ const patchUpgradeStats = (state: GameState): number => { const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => { return [ upgrade.id, upgrade ] as const; @@ -757,6 +804,15 @@ const patchUpgradeStats = (state: GameState): number => { if (defaultUpgrade === undefined) { continue; } + const hasChanged + = savedUpgrade.name !== defaultUpgrade.name + || savedUpgrade.description !== defaultUpgrade.description + || savedUpgrade.target !== defaultUpgrade.target + || savedUpgrade.adventurerId !== defaultUpgrade.adventurerId + || savedUpgrade.multiplier !== defaultUpgrade.multiplier + || savedUpgrade.costGold !== defaultUpgrade.costGold + || savedUpgrade.costEssence !== defaultUpgrade.costEssence + || savedUpgrade.costCrystals !== defaultUpgrade.costCrystals; savedUpgrade.name = defaultUpgrade.name; savedUpgrade.description = defaultUpgrade.description; savedUpgrade.target = defaultUpgrade.target; @@ -767,7 +823,9 @@ const patchUpgradeStats = (state: GameState): number => { savedUpgrade.costGold = defaultUpgrade.costGold; savedUpgrade.costEssence = defaultUpgrade.costEssence; savedUpgrade.costCrystals = defaultUpgrade.costCrystals; - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -778,6 +836,7 @@ const patchUpgradeStats = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns The number of equipment entries whose stats were updated. */ +/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */ const patchEquipmentStats = (state: GameState): number => { const defaultEquipmentMap = new Map(defaultEquipment.map((item) => { return [ item.id, item ] as const; @@ -788,6 +847,18 @@ const patchEquipmentStats = (state: GameState): number => { if (defaultItem === undefined) { continue; } + const savedBonus = JSON.stringify(savedItem.bonus); + const defaultBonus = JSON.stringify(defaultItem.bonus); + const savedCost = JSON.stringify(savedItem.cost); + const defaultCost = JSON.stringify(defaultItem.cost); + const hasChanged + = savedItem.name !== defaultItem.name + || savedItem.description !== defaultItem.description + || savedItem.type !== defaultItem.type + || savedItem.rarity !== defaultItem.rarity + || savedBonus !== defaultBonus + || savedCost !== defaultCost + || savedItem.setId !== defaultItem.setId; savedItem.name = defaultItem.name; savedItem.description = defaultItem.description; savedItem.type = defaultItem.type; @@ -799,7 +870,9 @@ const patchEquipmentStats = (state: GameState): number => { if (defaultItem.setId !== undefined) { savedItem.setId = defaultItem.setId; } - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; @@ -820,6 +893,16 @@ const patchAchievementStats = (state: GameState): number => { if (defaultAchievement === undefined) { continue; } + const savedCondition = JSON.stringify(savedAchievement.condition); + const defaultCondition = JSON.stringify(defaultAchievement.condition); + const savedReward = JSON.stringify(savedAchievement.reward); + const defaultReward = JSON.stringify(defaultAchievement.reward); + const hasChanged + = savedAchievement.name !== defaultAchievement.name + || savedAchievement.description !== defaultAchievement.description + || savedAchievement.icon !== defaultAchievement.icon + || savedCondition !== defaultCondition + || savedReward !== defaultReward; savedAchievement.name = defaultAchievement.name; savedAchievement.description = defaultAchievement.description; savedAchievement.icon = defaultAchievement.icon; @@ -827,7 +910,9 @@ const patchAchievementStats = (state: GameState): number => { if (defaultAchievement.reward !== undefined) { savedAchievement.reward = { ...defaultAchievement.reward }; } - patched = patched + 1; + if (hasChanged) { + patched = patched + 1; + } } return patched; }; diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index f482d5a..f205411 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -102,12 +102,23 @@ prestigeRouter.post("/", async(context) => { }).length; const now = Date.now(); - await prisma.gameState.update({ + const { updatedAt } = record; + + /* + * Use the record's current updatedAt as an optimistic lock — if another + * concurrent prestige request already committed, this update will match + * 0 rows and we can safely reject the duplicate without a double webhook. + */ + const updateResult = await prisma.gameState.updateMany({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: finalState as object, updatedAt: now }, - where: { discordId }, + where: { discordId, updatedAt }, }); + if (updateResult.count === 0) { + return context.json({ error: "Prestige already in progress" }, 409); + } + await prisma.player.update({ data: { characterName: state.player.characterName, @@ -136,17 +147,30 @@ prestigeRouter.post("/", async(context) => { const prestigeCount = prestigeData.count; void logger.metric("prestige", 1, { discordId, prestigeCount }); - void postMilestoneWebhook(discordId, "prestige", { - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - apotheosis: prestigeState.apotheosis?.count ?? 0, - prestige: prestigeData.count, - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 2 -- @preserve */ - transcendence: prestigeState.transcendence?.count ?? 0, + 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 | null | undefined; + const announcementsEnabled + = playerSettings?.enablePrestigeAnnouncements !== false; + + if (announcementsEnabled) { + void postMilestoneWebhook(discordId, "prestige", { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + apotheosis: prestigeState.apotheosis?.count ?? 0, + + prestige: prestigeData.count, + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + transcendence: prestigeState.transcendence?.count ?? 0, + }); + } return context.json({ milestoneRunestones: milestoneRunestones, diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index e1e6414..8bb1428 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => { : "suffix"; return { enableNotifications: rawObject.enableNotifications === true, + enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false, enableSounds: rawObject.enableSounds === true, numberFormat: numberFormat, showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false, @@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => { : "suffix"; const profileSettings: ProfileSettings = { enableNotifications: body.profileSettings.enableNotifications ?? false, + enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true, enableSounds: body.profileSettings.enableSounds ?? false, numberFormat: numberFormat, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, diff --git a/apps/api/src/services/dailyChallenges.ts b/apps/api/src/services/dailyChallenges.ts index 4c4aad1..598ebc3 100644 --- a/apps/api/src/services/dailyChallenges.ts +++ b/apps/api/src/services/dailyChallenges.ts @@ -71,8 +71,7 @@ const shuffleWithSeed = (array: Array, seed: number): Array => { return result; }; -const challengeTypes: Array = [ - "clicks", +const progressionChallengeTypes: Array = [ "bossesDefeated", "questsCompleted", "prestige", @@ -80,7 +79,8 @@ const challengeTypes: Array = [ /** * Generates 3 daily challenges for the given date string, deterministically. - * Picks one challenge from 3 different randomly-selected types. + * Always includes a "clicks" challenge (always completable regardless of + * progression), then picks 2 more from the remaining types. * @param dateString - The date string (YYYY-MM-DD) to generate challenges for. * @returns An array of 3 DailyChallenge objects. */ @@ -88,8 +88,10 @@ const generateDailyChallenges = ( dateString: string, ): Array => { const seed = dateSeed(dateString); - const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed). - slice(0, 3); + const selectedTypes: Array = [ + "clicks", + ...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2), + ]; return selectedTypes.map((type, index) => { const templates = dailyChallengeTemplates.filter((template) => { diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 27cd75a..ac08e89 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -15,14 +15,21 @@ import type { } from "@elysium/types"; const basePrestigeGoldThreshold = 1_000_000; -const thresholdScaleFactor = 5; -const runestonesPerPrestigeLevel = 10; +const runestonesPerPrestigeLevel = 15; const milestoneInterval = 5; const milestoneRunestonesPerInterval = 25; +/* + * Hard cap on the base runestone yield (before multipliers) to prevent + * extreme AFK accumulation from producing game-breaking runestone counts. + * With all upgrades (5.625× max) this caps out at ~1,125 per prestige. + */ +const maxBaseRunestones = 200; + /** * Calculates the gold threshold required for the next prestige. - * Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder. + * Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 8–10 + * then gets easier as the production multiplier overtakes it. * @param prestigeCount - The current number of prestiges completed. * @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold. * @returns The gold amount required to prestige. @@ -33,7 +40,7 @@ const calculatePrestigeThreshold = ( ): number => { return ( basePrestigeGoldThreshold - * Math.pow(thresholdScaleFactor, prestigeCount) + * Math.pow(prestigeCount + 1, 2) * thresholdMultiplier ); }; @@ -107,7 +114,9 @@ interface RunestoneParameters { /** * Calculates how many runestones the player earns from a prestige. - * Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier. + * Formula: min(floor(cbrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL, MAX_BASE) * multipliers. + * Uses cube root for stronger diminishing returns than sqrt, and caps the base before multipliers + * to prevent extended AFK sessions from producing runestone windfalls. * @param parameters - The parameters for the runestone calculation. * @param parameters.totalGoldEarned - The total gold earned in the current run. * @param parameters.prestigeCount - The current prestige count. @@ -123,9 +132,11 @@ const calculateRunestones = (parameters: RunestoneParameters): number => { echoRunestoneMultiplier = 1, } = parameters; const threshold = calculatePrestigeThreshold(prestigeCount); - const base - = Math.floor(Math.sqrt(totalGoldEarned / threshold)) - * runestonesPerPrestigeLevel; + const base = Math.min( + Math.floor(Math.cbrt(totalGoldEarned / threshold)) + * runestonesPerPrestigeLevel, + maxBaseRunestones, + ); const runestoneMult = getCategoryMultiplier( purchasedUpgradeIds, "runestones", @@ -135,19 +146,20 @@ const calculateRunestones = (parameters: RunestoneParameters): number => { /** * Calculates the new prestige production multiplier. - * Formula: 1.15^prestigeCount — exponential scaling per prestige. + * Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually + * overtakes the polynomial threshold growth, making late prestiges progressively easier. * @param prestigeCount - The new prestige count. * @returns The production multiplier for the new prestige level. */ const calculateProductionMultiplier = ( prestigeCount: number, ): number => { - return Math.pow(1.15, prestigeCount); + return Math.pow(1.3, prestigeCount); }; /** * Returns the milestone runestone bonus for the given prestige count. - * Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones. + * Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones. * @param prestigeCount - The prestige count after the current prestige. * @returns The milestone runestone bonus, or 0 if not a milestone prestige. */ @@ -156,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => { return 0; } const milestoneNumber = prestigeCount / milestoneInterval; - return milestoneNumber * milestoneRunestonesPerInterval; + return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval; }; /** @@ -251,7 +263,8 @@ const buildPostPrestigeState = ( * Preserve automation preferences across prestige — the player explicitly * opted into these settings and would not expect them to silently reset. */ - autoBoss: currentState.autoBoss ?? false, + autoAdventurer: currentState.autoAdventurer ?? false, + autoBoss: currentState.autoBoss ?? false, autoQuest: currentState.autoQuest ?? false, // Boss statuses reset for gameplay, but first-kill claimed flag is preserved diff --git a/apps/api/src/services/transcendence.ts b/apps/api/src/services/transcendence.ts index ebda467..463ef62 100644 --- a/apps/api/src/services/transcendence.ts +++ b/apps/api/src/services/transcendence.ts @@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one"; /** * Base constant used in the echo yield formula. */ -const echoFormulaConstant = 853; +const echoFormulaConstant = 224; const getCategoryMultiplier = ( purchasedIds: Array, diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 2c8d051..dcdcdfe 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -595,6 +595,18 @@ describe("debug route", () => { 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 () => { 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"], @@ -816,6 +828,18 @@ describe("debug route", () => { 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 () => { 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"], @@ -845,6 +869,42 @@ describe("debug route", () => { 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 () => { 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"], @@ -872,6 +932,18 @@ describe("debug route", () => { 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 () => { const state = makeState({ zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"], @@ -901,6 +973,18 @@ describe("debug route", () => { 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 () => { 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"], @@ -929,6 +1013,30 @@ describe("debug route", () => { 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 () => { 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"], @@ -957,6 +1065,18 @@ describe("debug route", () => { 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 () => { 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"], diff --git a/apps/api/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index b7ee04d..5c6546a 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types"; vi.mock("../../src/db/client.js", () => ({ prisma: { - player: { update: vi.fn() }, - gameState: { findUnique: vi.fn(), update: vi.fn() }, + player: { findUnique: vi.fn(), update: vi.fn() }, + gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })); @@ -47,8 +47,8 @@ const makeState = (overrides: Partial = {}): GameState => ({ describe("prestige route", () => { let app: Hono; let prisma: { - player: { update: ReturnType }; - gameState: { findUnique: ReturnType; update: ReturnType }; + player: { findUnique: ReturnType; update: ReturnType }; + gameState: { findUnique: ReturnType; update: ReturnType; updateMany: ReturnType }; }; beforeEach(async () => { @@ -83,8 +83,8 @@ describe("prestige route", () => { it("returns runestones on successful prestige", async () => { const state = makeState(); - vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); - vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + 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); const res = await post(""); expect(res.status).toBe(200); @@ -93,6 +93,14 @@ describe("prestige route", () => { 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 () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); const res = await post(""); @@ -112,14 +120,26 @@ describe("prestige route", () => { challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }], } as GameState["dailyChallenges"], }); - vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); - vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + 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); const res = await post(""); expect(res.status).toBe(200); const body = await res.json() as { runestones: number; newPrestigeCount: number }; 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", () => { diff --git a/apps/api/test/routes/transcendence.spec.ts b/apps/api/test/routes/transcendence.spec.ts index 270c5e5..9aba6e2 100644 --- a/apps/api/test/routes/transcendence.spec.ts +++ b/apps/api/test/routes/transcendence.spec.ts @@ -158,7 +158,7 @@ describe("transcendence route", () => { const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" }); expect(res.status).toBe(200); const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] }; - expect(body.echoesRemaining).toBe(95); // 100 - 5 + expect(body.echoesRemaining).toBe(98); // 100 - 2 expect(body.purchasedUpgradeIds).toContain("echo_income_1"); }); diff --git a/apps/api/test/services/dailyChallenges.spec.ts b/apps/api/test/services/dailyChallenges.spec.ts index ee5cd8a..d0b43a9 100644 --- a/apps/api/test/services/dailyChallenges.spec.ts +++ b/apps/api/test/services/dailyChallenges.spec.ts @@ -46,13 +46,24 @@ describe("generateDailyChallenges", () => { 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 () => { 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"); - // They should differ in at least one challenge ID (types vary by seed) - expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type)); + // The 2 non-clicks types should vary by seed between dates + const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type); + const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type); + expect(day1NonClicks).not.toEqual(day2NonClicks); }); }); diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 40657a2..2cedc49 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -55,15 +55,18 @@ const makeMinimalState = (overrides: Partial = {}): GameState => describe("calculatePrestigeThreshold", () => { it("returns base threshold at count 0", () => { + // base × (0+1)^2 = 1_000_000 × 1 = 1_000_000 expect(calculatePrestigeThreshold(0)).toBe(1_000_000); }); - it("returns 5× at count 1", () => { - expect(calculatePrestigeThreshold(1)).toBe(5_000_000); + it("returns 4× base at count 1", () => { + // base × (1+1)^2 = 1_000_000 × 4 = 4_000_000 + expect(calculatePrestigeThreshold(1)).toBe(4_000_000); }); - it("returns 25× at count 2", () => { - expect(calculatePrestigeThreshold(2)).toBe(25_000_000); + it("returns 9× base at count 2", () => { + // base × (2+1)^2 = 1_000_000 × 9 = 9_000_000 + expect(calculatePrestigeThreshold(2)).toBe(9_000_000); }); it("applies threshold multiplier correctly", () => { @@ -99,21 +102,27 @@ describe("isEligibleForPrestige", () => { describe("calculateRunestones", () => { it("calculates basic runestones formula", () => { - // floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20 + // floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); - expect(result).toBe(20); + expect(result).toBe(15); }); it("applies echo runestone multiplier", () => { - // floor(sqrt(4) × 10) = 20; × 2 = 40 + // floor(cbrt(4)) × 15 = 15; × 2 = 30 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 }); - expect(result).toBe(40); + expect(result).toBe(30); }); it("applies purchased runestone upgrade multiplier", () => { - // With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25 + // With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] }); - expect(result).toBeGreaterThan(20); + expect(result).toBe(18); + }); + + it("caps base runestones before multipliers", () => { + // cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200 + const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); + expect(result).toBe(200); }); }); @@ -122,12 +131,12 @@ describe("calculateProductionMultiplier", () => { expect(calculateProductionMultiplier(0)).toBe(1); }); - it("returns 1.15 at count 1", () => { - expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15); + it("returns 1.3 at count 1", () => { + expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3); }); it("scales exponentially", () => { - expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10)); + expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10)); }); }); @@ -142,12 +151,12 @@ describe("calculateMilestoneBonus", () => { expect(calculateMilestoneBonus(5)).toBe(25); }); - it("returns 50 at prestige 10", () => { - expect(calculateMilestoneBonus(10)).toBe(50); + it("returns 100 at prestige 10", () => { + expect(calculateMilestoneBonus(10)).toBe(100); }); - it("returns 75 at prestige 15", () => { - expect(calculateMilestoneBonus(15)).toBe(75); + it("returns 225 at prestige 15", () => { + expect(calculateMilestoneBonus(15)).toBe(225); }); }); diff --git a/apps/api/test/services/transcendence.spec.ts b/apps/api/test/services/transcendence.spec.ts index 8876f3a..f9a47ac 100644 --- a/apps/api/test/services/transcendence.spec.ts +++ b/apps/api/test/services/transcendence.spec.ts @@ -97,20 +97,21 @@ describe("isEligibleForTranscendence", () => { describe("calculateEchoes", () => { it("handles prestige count of 0 by treating it as 1", () => { - // safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853 - expect(calculateEchoes(0, 1)).toBe(853); + // safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224 + expect(calculateEchoes(0, 1)).toBe(224); }); it("calculates echoes at count 1", () => { - expect(calculateEchoes(1, 1)).toBe(853); + // floor(224 / sqrt(1)) = 224 + expect(calculateEchoes(1, 1)).toBe(224); }); it("decreases echoes with higher prestige count", () => { const echoesAt1 = calculateEchoes(1, 1); const echoesAt4 = calculateEchoes(4, 1); expect(echoesAt4).toBeLessThan(echoesAt1); - // floor(853 / sqrt(4)) = floor(853 / 2) = 426 - expect(echoesAt4).toBe(426); + // floor(224 / sqrt(4)) = floor(224 / 2) = 112 + expect(echoesAt4).toBe(112); }); it("applies echoMetaMultiplier", () => { @@ -118,6 +119,11 @@ describe("calculateEchoes", () => { const withMult = calculateEchoes(1, 2); expect(withMult).toBe(base * 2); }); + + it("returns 50 echoes at the target prestige 20", () => { + // floor(224 / sqrt(20)) = floor(224 / 4.472) = floor(50.09) = 50 + expect(calculateEchoes(20, 1)).toBe(50); + }); }); describe("buildPostTranscendenceState", () => { diff --git a/apps/web/src/components/game/adventurerPanel.tsx b/apps/web/src/components/game/adventurerPanel.tsx index d0f9a95..94ff206 100644 --- a/apps/web/src/components/game/adventurerPanel.tsx +++ b/apps/web/src/components/game/adventurerPanel.tsx @@ -9,6 +9,7 @@ /* eslint-disable complexity -- Complex component with many render paths */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; +import { computeEffectiveAdventurerStats } from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { LockToggle } from "../ui/lockToggle.js"; import type { Adventurer } from "@elysium/types"; @@ -76,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => { return quantity; }; +interface EffectiveAdventurerStats { + readonly combatPower: number; + readonly essencePerSecond: number; + readonly goldPerSecond: number; +} + interface AdventurerCardProperties { - readonly adventurer: Adventurer; - readonly currentGold: number; - readonly batchSize: BatchSize; - readonly unlockHint: string | undefined; - readonly formatNumber: (n: number)=> string; + readonly adventurer: Adventurer; + readonly currentGold: number; + readonly batchSize: BatchSize; + readonly unlockHint: string | undefined; + readonly formatNumber: (n: number)=> string; + readonly effectiveStats: EffectiveAdventurerStats; } /** @@ -92,6 +100,7 @@ interface AdventurerCardProperties { * @param props.batchSize - The selected batch size. * @param props.unlockHint - Optional quest name that unlocks this adventurer. * @param props.formatNumber - The number formatting utility function. + * @param props.effectiveStats - The post-multiplier per-unit stats. * @returns The JSX element. */ const AdventurerCard = ({ @@ -100,6 +109,7 @@ const AdventurerCard = ({ batchSize, unlockHint, formatNumber, + effectiveStats, }: AdventurerCardProperties): JSX.Element => { const { buyAdventurer } = useGame(); @@ -134,17 +144,17 @@ const AdventurerCard = ({

{adventurer.name}

- {formatNumber(adventurer.goldPerSecond)} + {formatNumber(effectiveStats.goldPerSecond)} {" gold/s each"}

{adventurer.essencePerSecond > 0 &&

- {formatNumber(adventurer.essencePerSecond)} + {formatNumber(effectiveStats.essencePerSecond)} {" essence/s each"}

}

- {formatNumber(adventurer.combatPower)} + {formatNumber(effectiveStats.combatPower)} {" combat power each"}

@@ -280,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => { adventurer={adventurer} batchSize={batchSize} currentGold={state.resources.gold} + effectiveStats={computeEffectiveAdventurerStats( + state, + adventurer.id, + )} formatNumber={formatNumber} key={adventurer.id} unlockHint={adventurerUnlockHints.get(adventurer.id)} diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx index e676142..3167a28 100644 --- a/apps/web/src/components/game/bossPanel.tsx +++ b/apps/web/src/components/game/bossPanel.tsx @@ -11,10 +11,11 @@ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; +import { computePartyCombatPower } from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { LockToggle } from "../ui/lockToggle.js"; import { ZoneSelector } from "./zoneSelector.js"; -import type { Boss, GameState } from "@elysium/types"; +import type { Boss } from "@elysium/types"; interface BossCardProperties { readonly boss: Boss; @@ -157,72 +158,6 @@ const BossCard = ({ ); }; -/** - * Computes party DPS and HP from the current game state. - * @param state - The full game state. - * @returns The computed party DPS and HP values. - */ -const computePartyStats = ( - state: GameState, -): { - partyDps: number; - partyHp: number; -} => { - const { upgrades, adventurers, equipment, prestige } = state; - let globalMultiplier = 1; - for (const upgrade of upgrades) { - const { purchased, target, multiplier } = upgrade; - if (purchased && target === "global") { - globalMultiplier = globalMultiplier * multiplier; - } - } - const prestigeBonus = prestige.count * 0.1; - const prestigeMultiplier = 1 + prestigeBonus; - const equipmentCombatMultiplier = equipment. - filter((item) => { - return item.equipped && item.bonus.combatMultiplier !== undefined; - }). - reduce((multiplier, item) => { - return multiplier * (item.bonus.combatMultiplier ?? 1); - }, 1); - - let partyDps = 0; - let partyHp = 0; - for (const adventurer of adventurers) { - const { count, id: adventurerId, combatPower, level } = adventurer; - if (count === 0) { - continue; - } - let adventurerMultiplier = 1; - for (const upgrade of upgrades) { - const { - purchased, - target, - multiplier, - adventurerId: upgradeAdventurerId, - } = upgrade; - if ( - purchased - && target === "adventurer" - && upgradeAdventurerId === adventurerId - ) { - adventurerMultiplier = adventurerMultiplier * multiplier; - } - } - const dps - = combatPower - * count - * adventurerMultiplier - * globalMultiplier - * prestigeMultiplier; - partyDps = partyDps + dps; - const hp = level * 50 * count; - partyHp = partyHp + hp; - } - partyDps = partyDps * equipmentCombatMultiplier; - return { partyDps, partyHp }; -}; - /** * Renders the boss panel with zone selection and boss list. * @returns The JSX element. @@ -266,7 +201,14 @@ const BossPanel = (): JSX.Element => { void handleChallenge(bossId); } - const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; + const { + adventurers, + autoBoss, + bosses, + prestige: playerPrestige, + quests, + zones, + } = state; const activeZone = zones.find((zone) => { return zone.id === activeZoneId; @@ -349,7 +291,12 @@ const BossPanel = (): JSX.Element => { } const autoBossOn = autoBoss === true; - const { partyDps, partyHp } = computePartyStats(state); + const partyDps = computePartyCombatPower(state); + let partyHp = 0; + for (const { level, count } of adventurers) { + // eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear + partyHp = partyHp + level * 50 * count; + } const { count: prestigeCount } = playerPrestige; return ( diff --git a/apps/web/src/components/game/codexPanel.tsx b/apps/web/src/components/game/codexPanel.tsx index 74a1e9a..a3bac2b 100644 --- a/apps/web/src/components/game/codexPanel.tsx +++ b/apps/web/src/components/game/codexPanel.tsx @@ -49,6 +49,40 @@ const sourceTypeFolder: Record = { zone: "zones", }; +/** + * Converts a snake_case ID to a Title Case display name. + * @param id - The snake_case identifier to format. + * @returns The formatted display name. + */ +const formatId = (id: string): string => { + return id.split("_"). + map((word) => { + return word.charAt(0).toUpperCase() + word.slice(1); + }). + join(" "); +}; + +/** + * Generates a human-readable unlock hint for a locked codex entry. + * @param entry - The locked codex entry. + * @returns A string describing how to unlock the entry. + */ +const buildUnlockHint = (entry: CodexEntry): string => { + const name = formatId(entry.sourceId); + switch (entry.sourceType) { + case "boss": return `Defeat ${name}`; + case "quest": return `Complete: ${name}`; + case "equipment": return `Obtain: ${name}`; + case "adventurer": return `Recruit a ${name}`; + case "upgrade": return `Purchase: ${name}`; + case "prestige": return `Purchase runestone upgrade: ${name}`; + case "zone": return `Explore: ${name}`; + case "exploration": return `Discover: ${name}`; + case "recipe": return `Craft: ${name}`; + default: return "Keep playing to unlock"; + } +}; + /** * Renders the codex panel with lore entries grouped by zone. * @returns The JSX element. @@ -136,6 +170,9 @@ const CodexPanel = (): JSX.Element => { {"🔒"} {"???"} +

+ {buildUnlockHint(entry)} +

); } diff --git a/apps/web/src/components/game/editProfileModal.tsx b/apps/web/src/components/game/editProfileModal.tsx index 3be4e74..1b96c78 100644 --- a/apps/web/src/components/game/editProfileModal.tsx +++ b/apps/web/src/components/game/editProfileModal.tsx @@ -225,6 +225,10 @@ const EditProfileModal = ({ void handleNotificationsEnable(); } + function handlePrestigeAnnouncementsToggle(): void { + toggleSetting("enablePrestigeAnnouncements"); + } + const isSaveDisabled = saving || characterName.trim() === ""; let saveLabel = "Save Profile"; @@ -417,6 +421,23 @@ const EditProfileModal = ({ } +
diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index 9addb63..980ca54 100644 --- a/apps/web/src/components/game/prestigePanel.tsx +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -12,25 +12,27 @@ import { useState, type JSX } from "react"; import { prestige } from "../../api/client.js"; import { useGame } from "../../context/gameContext.js"; import { - PRESTIGE_UPGRADES, PRESTIGE_UPGRADE_CATEGORY_LABELS, + PRESTIGE_UPGRADES, } from "../../data/prestigeUpgrades.js"; +import { + computeProjectedRunestones, +} from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { sendNotification } from "../../utils/notification.js"; import { playSound } from "../../utils/sound.js"; import type { PrestigeUpgradeCategory } from "@elysium/types"; const baseThreshold = 1_000_000; -const thresholdScale = 5; -const runestonesPerLevel = 10; /** * Calculates the prestige threshold for a given prestige count. + * Mirrors the server formula: BASE * (count + 1)^2. * @param prestigeCount - The current prestige count. * @returns The required gold to prestige. */ const calculateThreshold = (prestigeCount: number): number => { - return baseThreshold * Math.pow(thresholdScale, prestigeCount); + return baseThreshold * Math.pow(prestigeCount + 1, 2); }; /** @@ -42,32 +44,6 @@ const calculateProductionMultiplier = (prestigeCount: number): number => { return Math.pow(1.15, prestigeCount); }; -/** - * Calculates the runestone preview for a prestige. - * @param totalGoldEarned - Total gold earned this run. - * @param prestigeCount - The current prestige count. - * @param purchasedUpgradeIds - IDs of purchased prestige upgrades. - * @returns The predicted runestone reward. - */ -const calculateRunestonePreview = ( - totalGoldEarned: number, - prestigeCount: number, - purchasedUpgradeIds: Array, -): 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 = [ "income", "click", @@ -84,7 +60,7 @@ const categoryOrder: Array = [ const PrestigePanel = (): JSX.Element => { const { state, - reload, + reloadSilent, formatNumber, buyPrestigeUpgrade, enableNotifications, @@ -114,11 +90,7 @@ const PrestigePanel = (): JSX.Element => { const { autoAdventurer, prestige: prestigeData, player } = state; const threshold = calculateThreshold(prestigeData.count); const isEligible = player.totalGoldEarned >= threshold; - const runestonePreview = calculateRunestonePreview( - player.totalGoldEarned, - prestigeData.count, - prestigeData.purchasedUpgradeIds, - ); + const runestonePreview = computeProjectedRunestones(state); const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); async function handlePrestige(): Promise { @@ -141,7 +113,7 @@ const PrestigePanel = (): JSX.Element => { `You've reached prestige level ${data.newPrestigeCount.toString()}!`, ); } - await reload(); + await reloadSilent(); } catch (error_: unknown) { setPrestigeError( error_ instanceof Error diff --git a/apps/web/src/components/game/questPanel.tsx b/apps/web/src/components/game/questPanel.tsx index 64b6596..02ee9a9 100644 --- a/apps/web/src/components/game/questPanel.tsx +++ b/apps/web/src/components/game/questPanel.tsx @@ -11,7 +11,10 @@ /* eslint-disable max-statements -- Many local variables needed for quest state */ import { useState, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; -import { zoneFailureChance } from "../../engine/tick.js"; +import { + computePartyCombatPower, + zoneFailureChance, +} from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { LockToggle } from "../ui/lockToggle.js"; import { ZoneSelector } from "./zoneSelector.js"; @@ -208,7 +211,7 @@ const QuestPanel = (): JSX.Element => { ); } - const { adventurers, autoQuest, bosses, quests, zones } = state; + const { autoQuest, bosses, quests, zones } = state; const activeZone = zones.find((zone) => { return zone.id === activeZoneId; @@ -226,11 +229,7 @@ const QuestPanel = (): JSX.Element => { : quests.find((quest) => { return quest.id === activeZone.unlockQuestId; }); - let partyCombatPower = 0; - for (const adventurer of adventurers) { - const contribution = adventurer.combatPower * adventurer.count; - partyCombatPower = partyCombatPower + contribution; - } + const partyCombatPower = computePartyCombatPower(state); const zoneQuests = quests.filter(({ zoneId }) => { return zoneId === activeZoneId; }); diff --git a/apps/web/src/components/game/upgradePanel.tsx b/apps/web/src/components/game/upgradePanel.tsx index 19a9117..63db25c 100644 --- a/apps/web/src/components/game/upgradePanel.tsx +++ b/apps/web/src/components/game/upgradePanel.tsx @@ -7,6 +7,8 @@ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ +/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */ +/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; import { cdnImage } from "../../utils/cdn.js"; @@ -238,6 +240,22 @@ const UpgradePanel = (): JSX.Element => { } } } + for (const upgrade of locked) { + if ( + !upgradeUnlockHints.has(upgrade.id) + && upgrade.adventurerId !== undefined + ) { + const adventurerForHint = adventurers.find((a) => { + return a.id === upgrade.adventurerId; + }); + if (adventurerForHint !== undefined) { + upgradeUnlockHints.set( + upgrade.id, + `🗡️ Recruit: ${adventurerForHint.name}`, + ); + } + } + } function handleToggle(): void { setShowLocked((current) => { diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index cb76ba0..18ffbf7 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -10,7 +10,13 @@ /* eslint-disable complexity -- Many conditional resource and badge render paths */ import { useState, type FocusEvent, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; -import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; +import { + RESOURCE_CAP, + computeEssencePerSecond, + computeGoldPerSecond, + computePartyCombatPower, + computeProjectedRunestones, +} from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; interface ResourceBarProperties { @@ -83,12 +89,13 @@ const ResourceBar = ({ const { gold, essence, crystals } = resources; let partyCombatPower = 0; let goldPerSecond = 0; + let essencePerSecond = 0; + let projectedRunestones = 0; if (state !== null) { - for (const adventurer of state.adventurers) { - const contribution = adventurer.combatPower * adventurer.count; - partyCombatPower = partyCombatPower + contribution; - } + partyCombatPower = computePartyCombatPower(state); goldPerSecond = computeGoldPerSecond(state); + essencePerSecond = computeEssencePerSecond(state); + projectedRunestones = computeProjectedRunestones(state); } let avatarUrl: string | null = null; @@ -182,6 +189,13 @@ const ResourceBar = ({ {"Gold/s"}
+
+ {"⚡"} + + {formatNumber(essencePerSecond)} + + {"Essence/s"} +
@@ -223,6 +237,13 @@ const ResourceBar = ({ {"Runestones"}
+
+ {"⭐"} + + {`+${formatNumber(projectedRunestones)}`} + + {"On Prestige"} +
{"⚔️"} diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index fd89b84..a35d47c 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -53,11 +53,13 @@ import { transcend as transcendApi, } from "../api/client.js"; import { CODEX_ENTRIES } from "../data/codex.js"; +import { EXPLORATION_AREAS } from "../data/explorations.js"; import { RECIPES } from "../data/recipes.js"; import { RESOURCE_CAP, applyTick, calculateClickPower, + computePartyCombatPower, } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; @@ -115,6 +117,9 @@ const applyBossResult = ( }). filter(Boolean), ); + const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => { + return z.id; + })); const challengeUpdate = previous.dailyChallenges === undefined @@ -215,6 +220,23 @@ const applyBossResult = ( ? { ...u, unlocked: true } : u; }), + ...newlyUnlockedZoneIds.size === 0 || previous.exploration === undefined + ? {} + : { + exploration: { + ...previous.exploration, + areas: previous.exploration.areas.map((area) => { + const areaDefinition = EXPLORATION_AREAS.find((definition) => { + return definition.id === area.id; + }); + return areaDefinition !== undefined + && newlyUnlockedZoneIds.has(areaDefinition.zoneId) + && area.status === "locked" + ? { ...area, status: "available" as const } + : area; + }), + }, + }, }; } @@ -288,6 +310,12 @@ interface GameContextValue { */ reload: ()=> Promise; + /** + * Reload state from the server without showing the loading screen (used + * after prestige to avoid the visible flash/hang). + */ + reloadSilent: ()=> Promise; + /** * Unix timestamp of the last successful cloud save (null until first save response). */ @@ -696,6 +724,10 @@ export const GameProvider = ({ /* No-op placeholder */ }); + const reloadSilentReference = useRef<()=> Promise>(async() => { + + /* No-op placeholder */ + }); const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); @@ -783,6 +815,32 @@ export const GameProvider = ({ reloadReference.current = reload; + const reloadSilent = useCallback(async() => { + setError(null); + try { + const data = await loadGame(); + setState(data.state); + setLastSavedAt(data.state.player.lastSavedAt); + if (data.signature !== undefined) { + signatureReference.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + setLoginStreak(data.loginStreak); + setSchemaOutdated(data.schemaOutdated); + setSaveSchemaVersion(data.state.schemaVersion ?? 0); + setCurrentSchemaVersion(data.currentSchemaVersion); + setInGuild(data.inGuild); + } catch (error_: unknown) { + setError( + error_ instanceof Error + ? error_.message + : "Failed to load game", + ); + } + }, []); + + reloadSilentReference.current = reloadSilent; + useEffect(() => { enableSoundsReference.current = enableSounds; }, [ enableSounds ]); @@ -1078,11 +1136,7 @@ export const GameProvider = ({ return q.status === "active"; }); if (!hasActiveQuest) { - // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! - const partyCombatPower = next.adventurers.reduce((total, a) => { - const power = total + a.combatPower; - return power * a.count; - }, 0); + const partyCombatPower = computePartyCombatPower(next); const zoneOrder = new Map( next.zones.map((z, index) => { return [ z.id, index ]; @@ -1120,14 +1174,31 @@ export const GameProvider = ({ next.autoAdventurer === true && next.prestige.purchasedUpgradeIds.includes("auto_adventurer") ) { + const maxAdventurerLevel = Math.max( + ...next.adventurers. + filter((a) => { + return a.unlocked; + }). + map((a) => { + return a.level; + }), + ); + const autoBuyCap = 100; const [ bestAdventurer ] = next.adventurers. filter((adventurer) => { const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count); - return adventurer.unlocked && next.resources.gold >= cost; + const isMaxTier = adventurer.level === maxAdventurerLevel; + const withinCap + = isMaxTier || adventurer.count < autoBuyCap; + return ( + adventurer.unlocked + && next.resources.gold >= cost + && withinCap + ); }). sort((adventurerA, adventurerB) => { - return adventurerB.combatPower - adventurerA.combatPower; + return adventurerB.level - adventurerA.level; }); if (bestAdventurer !== undefined) { const purchaseCost @@ -1280,7 +1351,7 @@ export const GameProvider = ({ if (enableNotificationsReference.current) { sendNotification("⭐ Prestige!", "You have ascended!"); } - await reloadReference.current(); + await reloadSilentReference.current(); }). catch(() => { @@ -1346,6 +1417,13 @@ export const GameProvider = ({ } return afterBoss; }); + + /* + * Boss fight modifies server state; clear stale signature so + * the next pre-save or auto-save does not send a mismatched one. + */ + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); setAutoBossLastResult({ at: Date.now(), bossName: bossName, @@ -1789,7 +1867,18 @@ export const GameProvider = ({ const collectExploration = useCallback( async(areaId: string): Promise => { + isSyncingReference.current = true; const result = await collectExplorationApi({ areaId }); + + /* + * Collect mutates server state outside the normal save flow — clear the + * stale HMAC signature and reset the timer so the next auto-save fires + * after React has re-rendered with the new materials in stateReference. + */ + signatureReference.current = null; + localStorage.removeItem("elysium_save_signature"); + lastSaveReference.current = Date.now(); + isSyncingReference.current = false; setState((previous) => { if (previous?.exploration === undefined) { return previous; @@ -2320,6 +2409,7 @@ export const GameProvider = ({ offlineEssence, offlineGold, reload, + reloadSilent, resetProgress, saveSchemaVersion, schemaOutdated, diff --git a/apps/web/src/data/recipes.ts b/apps/web/src/data/recipes.ts index 16b454b..eb35d3c 100644 --- a/apps/web/src/data/recipes.ts +++ b/apps/web/src/data/recipes.ts @@ -24,7 +24,7 @@ export const RECIPES: Array = [ zoneId: "verdant_vale", }, { - bonus: { type: "combat_power", value: 1.08 }, + bonus: { type: "combat_power", value: 1.2 }, description: "A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.", id: "elder_bark_shield", @@ -102,7 +102,7 @@ export const RECIPES: Array = [ zoneId: "shadow_marshes", }, { - bonus: { type: "combat_power", value: 1.1 }, + bonus: { type: "combat_power", value: 1.15 }, description: "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.", id: "cursed_focus", @@ -128,7 +128,7 @@ export const RECIPES: Array = [ zoneId: "volcanic_depths", }, { - bonus: { type: "combat_power", value: 1.12 }, + bonus: { type: "combat_power", value: 1.2 }, description: "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.", id: "elemental_ore_ingot", @@ -194,7 +194,7 @@ export const RECIPES: Array = [ // Zone 8: abyssal_trench { - bonus: { type: "combat_power", value: 1.15 }, + bonus: { type: "combat_power", value: 1.25 }, description: "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.", id: "pressure_forged_core", @@ -272,7 +272,7 @@ export const RECIPES: Array = [ // Zone 11: void_sanctum { - bonus: { type: "combat_power", value: 1.18 }, + bonus: { type: "combat_power", value: 1.28 }, description: "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.", id: "null_field_generator", @@ -310,7 +310,7 @@ export const RECIPES: Array = [ zoneId: "eternal_throne", }, { - bonus: { type: "combat_power", value: 1.2 }, + bonus: { type: "combat_power", value: 1.3 }, description: "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.", id: "eternity_bound_ring", @@ -376,7 +376,7 @@ export const RECIPES: Array = [ // Zone 15: reality_forge { - bonus: { type: "combat_power", value: 1.22 }, + bonus: { type: "combat_power", value: 1.35 }, description: "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.", id: "reality_ingot", @@ -428,7 +428,7 @@ export const RECIPES: Array = [ // Zone 17: primeval_sanctum { - bonus: { type: "combat_power", value: 1.25 }, + bonus: { type: "combat_power", value: 1.4 }, description: "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.", id: "ancient_memory_array", @@ -466,7 +466,7 @@ export const RECIPES: Array = [ zoneId: "the_absolute", }, { - bonus: { type: "combat_power", value: 1.3 }, + bonus: { type: "combat_power", value: 1.55 }, description: "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.", id: "omega_convergence", diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index c80f8e5..f299be9 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -11,7 +11,6 @@ /* eslint-disable max-lines -- Engine file necessarily exceeds line limit */ /* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */ /* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */ -/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */ /* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */ import { type Achievement, @@ -21,6 +20,7 @@ import { getActiveCompanionBonus, } from "@elysium/types"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; +import { EXPLORATION_AREAS } from "../data/explorations.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; /** @@ -83,6 +83,12 @@ const checkAchievements = (state: GameState): Array => { }); }; +/** + * Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount). + * Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression. + */ +export const PRESTIGE_COMBAT_BASE = 4; + /** * Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */ @@ -195,6 +201,285 @@ export const computeGoldPerSecond = (state: GameState): number => { return goldPerSecond; }; +/** + * Computes the current essence per second for the given game state, + * applying all relevant multipliers (upgrades, prestige, echo, crafted, companion). + * @param state - The current game state. + * @returns The total essence per second. + */ +export const computeEssencePerSecond = (state: GameState): number => { + const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; + const craftedEssenceMultiplier + = state.exploration?.craftedEssenceMultiplier ?? 1; + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionEssenceMult + = companionBonus?.type === "essenceIncome" + ? 1 + companionBonus.value + : 1; + + let essencePerSecond = 0; + for (const adventurer of state.adventurers) { + if (!adventurer.unlocked || adventurer.count === 0) { + continue; + } + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + const contribution + = adventurer.essencePerSecond + * adventurer.count + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesEssence + * craftedEssenceMultiplier + * companionEssenceMult; + essencePerSecond = essencePerSecond + contribution; + } + return essencePerSecond; +}; + +/** + * Computes the effective per-unit stats for a single adventurer type, + * applying all active multipliers (upgrades, prestige, equipment, echo, + * crafted, companion). The returned values represent what a single + * adventurer of this type currently contributes per second, matching the + * per-unit contribution used by computeGoldPerSecond and + * computeEssencePerSecond. + * @param state - The current game state. + * @param adventurerId - The ID of the adventurer to compute stats for. + * @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower. + */ +export const computeEffectiveAdventurerStats = ( + state: GameState, + adventurerId: string, +): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => { + const adventurer = state.adventurers.find((a) => { + return a.id === adventurerId; + }); + + /* V8 ignore next 3 -- @preserve */ + if (adventurer === undefined) { + return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 }; + } + + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurerId; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + + const equippedItems = state.equipment.filter((item) => { + return item.equipped; + }); + const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.goldMultiplier ?? 1); + }, 1); + const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.combatMultiplier ?? 1); + }, 1); + const equippedItemIds = equippedItems.map((item) => { + return item.id; + }); + const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS); + + const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; + const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; + const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; + const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; + const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1; + const craftedGoldMultiplier + = state.exploration?.craftedGoldMultiplier ?? 1; + const craftedEssenceMultiplier + = state.exploration?.craftedEssenceMultiplier ?? 1; + const craftedCombatMultiplier + = state.exploration?.craftedCombatMultiplier ?? 1; + + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionGoldMult + = companionBonus?.type === "passiveGold" + ? 1 + companionBonus.value + : 1; + const companionEssenceMult + = companionBonus?.type === "essenceIncome" + ? 1 + companionBonus.value + : 1; + const companionCombatMult + = companionBonus?.type === "bossDamage" + ? 1 + companionBonus.value + : 1; + + const goldPerSecond + = adventurer.goldPerSecond + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesIncome + * echoIncome + * equipmentGoldMultiplier + * setBonuses.goldMultiplier + * craftedGoldMultiplier + * companionGoldMult; + + const essencePerSecond + = adventurer.essencePerSecond + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesEssence + * craftedEssenceMultiplier + * companionEssenceMult; + + const combatPower + = adventurer.combatPower + * upgradeMultiplier + * prestigeCombatMultiplier + * equipmentCombatMultiplier + * setBonuses.combatMultiplier + * echoCombatMultiplier + * craftedCombatMultiplier + * companionCombatMult; + + return { combatPower, essencePerSecond, goldPerSecond }; +}; + +/** + * Computes the party's total combat power, applying all active multipliers + * (upgrades, prestige, equipment, set bonuses, echo, crafted, companion). + * This mirrors the server-side calculatePartyStats in boss.ts and is the + * single source of truth for all combat-power checks in the client: + * - Displayed as "Combat Power" in the resource bar + * - Displayed as "Party DPS" in the boss panel + * - Used to gate quest availability + * Note: the active companion's bossDamage bonus is intentionally included + * here, as it applies to the full combat power calculation (boss fights and + * quest gating alike), matching the server-side behaviour. + * @param state - The current game state. + * @returns The total party combat power. + */ +export const computePartyCombatPower = (state: GameState): number => { + let globalMultiplier = 1; + for (const upgrade of state.upgrades) { + if (upgrade.purchased && upgrade.target === "global") { + globalMultiplier = globalMultiplier * upgrade.multiplier; + } + } + + const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; + + const equipmentCombatMultiplier = state.equipment. + filter((item) => { + return item.equipped && item.bonus.combatMultiplier !== undefined; + }). + reduce((mult, item) => { + return mult * (item.bonus.combatMultiplier ?? 1); + }, 1); + + const equippedItemIds = state.equipment. + filter((item) => { + return item.equipped; + }). + map((item) => { + return item.id; + }); + const { combatMultiplier: setCombatMultiplier } = computeSetBonuses( + equippedItemIds, + EQUIPMENT_SETS, + ); + + const echoCombatMultiplier + = state.transcendence?.echoCombatMultiplier ?? 1; + const craftedCombatMultiplier + = state.exploration?.craftedCombatMultiplier ?? 1; + + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionCombatMult + = companionBonus?.type === "bossDamage" + ? 1 + companionBonus.value + : 1; + + let partyCombatPower = 0; + for (const adventurer of state.adventurers) { + if (adventurer.count === 0) { + continue; + } + let adventurerMultiplier = 1; + for (const upgrade of state.upgrades) { + if ( + upgrade.purchased + && upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id + ) { + adventurerMultiplier = adventurerMultiplier * upgrade.multiplier; + } + } + const contribution + = adventurer.combatPower + * adventurer.count + * adventurerMultiplier + * globalMultiplier + * prestigeMultiplier; + partyCombatPower = partyCombatPower + contribution; + } + + return partyCombatPower + * equipmentCombatMultiplier + * setCombatMultiplier + * echoCombatMultiplier + * craftedCombatMultiplier + * companionCombatMult; +}; + +const basePrestigeThreshold = 1_000_000; +const runestonesPerPrestigeLevelClient = 15; +const maxBaseRunestones = 200; + +/** + * Computes the projected runestone reward if the player were to prestige right now. + * Mirrors the server-side calculateRunestones formula exactly. + * @param state - The current game state. + * @returns The number of runestones the player would earn from a prestige now. + */ +export const computeProjectedRunestones = (state: GameState): number => { + const { count, purchasedUpgradeIds } = state.prestige; + const threshold = basePrestigeThreshold * Math.pow(count + 1, 2); + const base = Math.min( + Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold)) + * runestonesPerPrestigeLevelClient, + maxBaseRunestones, + ); + const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1") + ? 1.25 + : 1; + const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2") + ? 1.5 + : 1; + const runestoneMult = gain1Mult * gain2Mult; + const echoMult: number + = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; + return Math.floor(base * runestoneMult * echoMult); +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. @@ -469,6 +754,19 @@ export const applyTick = ( challengeCrystals = result.crystalsAwarded; } + // Auto-unlock adventurer-specific upgrades when their adventurer is recruited + updatedUpgrades = updatedUpgrades.map((upgrade) => { + if (upgrade.unlocked || upgrade.adventurerId === undefined) { + return upgrade; + } + const adventurer = updatedAdventurers.find((a) => { + return a.id === upgrade.adventurerId; + }); + return adventurer !== undefined && adventurer.count > 0 + ? { ...upgrade, unlocked: true } + : upgrade; + }); + const goldValue = capResource(state.resources.gold + goldGained + questGold); const essenceValue = capResource( state.resources.essence + essenceGained + questEssence, @@ -489,6 +787,23 @@ export const applyTick = ( ...updatedDailyChallenges === undefined ? {} : { dailyChallenges: updatedDailyChallenges }, + ...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined + ? {} + : { + exploration: { + ...state.exploration, + areas: state.exploration.areas.map((area) => { + const areaDefinition = EXPLORATION_AREAS.find((definition) => { + return definition.id === area.id; + }); + return areaDefinition !== undefined + && newlyUnlockedZoneIds.has(areaDefinition.zoneId) + && area.status === "locked" + ? { ...area, status: "available" as const } + : area; + }), + }, + }, adventurers: updatedAdventurers, bosses: updatedBosses, equipment: updatedEquipmentReference, @@ -502,24 +817,30 @@ export const applyTick = ( zones: updatedZones, }; - // Check achievements and apply crystal rewards for newly unlocked ones + // Check achievements and apply crystal and runestone rewards for newly unlocked ones const updatedAchievements = checkAchievements(partialState); - const crystalsFromAchievements = updatedAchievements.reduce( - (sum, achievement, index) => { - const wasLocked = state.achievements[index]?.unlockedAt === null; - const isNowUnlocked = achievement.unlockedAt !== null; - if (wasLocked && isNowUnlocked) { - return sum + (achievement.reward?.crystals ?? 0); - } - return sum; - }, - 0, - ); + let crystalsFromAchievements = 0; + let runestonesFromAchievements = 0; + for (const [ index, achievement ] of updatedAchievements.entries()) { + const wasLocked = state.achievements[index]?.unlockedAt === null; + const isNowUnlocked = achievement.unlockedAt !== null; + if (wasLocked && isNowUnlocked) { + crystalsFromAchievements + = crystalsFromAchievements + (achievement.reward?.crystals ?? 0); + runestonesFromAchievements + = runestonesFromAchievements + (achievement.reward?.runestones ?? 0); + } + } return { ...partialState, achievements: updatedAchievements, - resources: { + prestige: { + ...partialState.prestige, + runestones: + partialState.prestige.runestones + runestonesFromAchievements, + }, + resources: { ...partialState.resources, crystals: capResource( partialState.resources.crystals + crystalsFromAchievements, diff --git a/packages/types/src/interfaces/achievement.ts b/packages/types/src/interfaces/achievement.ts index b40bb44..dd862eb 100644 --- a/packages/types/src/interfaces/achievement.ts +++ b/packages/types/src/interfaces/achievement.ts @@ -20,7 +20,8 @@ interface AchievementCondition { } interface AchievementReward { - crystals?: number; + crystals?: number; + runestones?: number; } interface Achievement { diff --git a/packages/types/src/interfaces/profileSettings.ts b/packages/types/src/interfaces/profileSettings.ts index 9b4dbc0..b5cf8f2 100644 --- a/packages/types/src/interfaces/profileSettings.ts +++ b/packages/types/src/interfaces/profileSettings.ts @@ -48,11 +48,17 @@ interface ProfileSettings { * Whether browser system notifications are enabled. */ enableNotifications: boolean; + + /** + * Whether prestige milestones are announced in the Discord server. + */ + enablePrestigeAnnouncements: boolean; } // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { enableNotifications: false, + enablePrestigeAnnouncements: true, enableSounds: false, numberFormat: "suffix", showAchievementsUnlocked: true,