From 959b86fa8b55c1ef717069648ce8360561b7205f Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 20:01:22 -0700 Subject: [PATCH 01/53] fix: apply cbrt and cap to runestone formula to prevent AFK windfalls --- apps/api/src/services/prestige.ts | 19 +++++++++++++++---- apps/api/test/services/prestige.spec.ts | 18 ++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 27cd75a..c9462cf 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -20,6 +20,13 @@ const runestonesPerPrestigeLevel = 10; 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 ~281 per prestige. + */ +const maxBaseRunestones = 50; + /** * Calculates the gold threshold required for the next prestige. * Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder. @@ -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", diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 40657a2..4b98278 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -99,21 +99,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)) × 10 = floor(cbrt(4)) × 10 = 1 × 10 = 10 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); - expect(result).toBe(20); + expect(result).toBe(10); }); it("applies echo runestone multiplier", () => { - // floor(sqrt(4) × 10) = 20; × 2 = 40 + // floor(cbrt(4)) × 10 = 10; × 2 = 20 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 }); - expect(result).toBe(40); + expect(result).toBe(20); }); 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(10 × 1.25) = 12 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] }); - expect(result).toBeGreaterThan(20); + expect(result).toBe(12); + }); + + it("caps base runestones before multipliers", () => { + // cbrt(300_000_000 / 1_000_000) = cbrt(300) ≈ 6.67 → floor = 6 → 6 × 10 = 60, capped at 50 + const result = calculateRunestones({ totalGoldEarned: 300_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); + expect(result).toBe(50); }); }); -- 2.52.0 From 74dd3bf463ba096e90fde02a61315a3769ec82f7 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 20:03:17 -0700 Subject: [PATCH 02/53] chore: raise runestone base cap to 100 --- apps/api/src/services/prestige.ts | 4 ++-- apps/api/test/services/prestige.spec.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index c9462cf..0e6990b 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -23,9 +23,9 @@ 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 ~281 per prestige. + * With all upgrades (5.625× max) this caps out at ~562 per prestige. */ -const maxBaseRunestones = 50; +const maxBaseRunestones = 100; /** * Calculates the gold threshold required for the next prestige. diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 4b98278..38d8882 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -117,9 +117,9 @@ describe("calculateRunestones", () => { }); it("caps base runestones before multipliers", () => { - // cbrt(300_000_000 / 1_000_000) = cbrt(300) ≈ 6.67 → floor = 6 → 6 × 10 = 60, capped at 50 - const result = calculateRunestones({ totalGoldEarned: 300_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); - expect(result).toBe(50); + // cbrt(1_331_000_000 / 1_000_000) = cbrt(1331) = 11 → 11 × 10 = 110, capped at 100 + const result = calculateRunestones({ totalGoldEarned: 1_331_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); + expect(result).toBe(100); }); }); -- 2.52.0 From 0d6d05e50b07efce411ed3ebf4ca0dec05c8cdfb Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 20:08:53 -0700 Subject: [PATCH 03/53] chore: raise runestone base cap to 200 --- apps/api/src/services/prestige.ts | 4 ++-- apps/api/test/services/prestige.spec.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 0e6990b..41dbd43 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -23,9 +23,9 @@ 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 ~562 per prestige. + * With all upgrades (5.625× max) this caps out at ~1,125 per prestige. */ -const maxBaseRunestones = 100; +const maxBaseRunestones = 200; /** * Calculates the gold threshold required for the next prestige. diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 38d8882..b3a9c39 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -117,9 +117,9 @@ describe("calculateRunestones", () => { }); it("caps base runestones before multipliers", () => { - // cbrt(1_331_000_000 / 1_000_000) = cbrt(1331) = 11 → 11 × 10 = 110, capped at 100 - const result = calculateRunestones({ totalGoldEarned: 1_331_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); - expect(result).toBe(100); + // 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); }); }); -- 2.52.0 From 0ae6aa12b2d09e281f45b5b48ece5172e534bdc0 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 20:44:25 -0700 Subject: [PATCH 04/53] fix: rewrite prestige/transcendence formula and rebalance progression --- apps/api/src/data/bosses.ts | 108 +++++++++---------- apps/api/src/data/transcendenceUpgrades.ts | 30 +++--- apps/api/src/services/prestige.ts | 11 +- apps/api/src/services/transcendence.ts | 2 +- apps/api/test/routes/transcendence.spec.ts | 2 +- apps/api/test/services/prestige.spec.ts | 17 +-- apps/api/test/services/transcendence.spec.ts | 16 ++- 7 files changed, 98 insertions(+), 88 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 16f6b16..b8323ee 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -353,7 +353,7 @@ 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", @@ -371,7 +371,7 @@ 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", @@ -389,7 +389,7 @@ 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", @@ -407,7 +407,7 @@ 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", @@ -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", @@ -444,7 +444,7 @@ 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", @@ -462,7 +462,7 @@ 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", @@ -480,7 +480,7 @@ 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", @@ -498,7 +498,7 @@ 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", @@ -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", @@ -535,7 +535,7 @@ 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", @@ -553,7 +553,7 @@ 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", @@ -571,7 +571,7 @@ 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", @@ -589,7 +589,7 @@ 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", @@ -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", @@ -626,7 +626,7 @@ export const defaultBosses: Array = [ id: "prism_golem", maxHp: 2e16, name: "The Prism Golem", - prestigeRequirement: 15, + prestigeRequirement: 3, status: "locked", upgradeRewards: [], zoneId: "crystalline_spire", @@ -644,7 +644,7 @@ 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", @@ -662,7 +662,7 @@ export const defaultBosses: Array = [ id: "the_faceted", maxHp: 3e17, name: "The Faceted", - prestigeRequirement: 17, + prestigeRequirement: 4, status: "locked", upgradeRewards: [], zoneId: "crystalline_spire", @@ -680,7 +680,7 @@ export const defaultBosses: Array = [ id: "diamond_colossus", maxHp: 1e18, name: "The Diamond Colossus", - prestigeRequirement: 18, + prestigeRequirement: 4, status: "locked", upgradeRewards: [], zoneId: "crystalline_spire", @@ -698,7 +698,7 @@ export const defaultBosses: Array = [ id: "crystal_sovereign", maxHp: 4e18, name: "The Crystal Sovereign", - prestigeRequirement: 19, + prestigeRequirement: 4, status: "locked", upgradeRewards: [], zoneId: "crystalline_spire", @@ -717,7 +717,7 @@ export const defaultBosses: Array = [ id: "void_herald", maxHp: 1e19, name: "The Void Herald", - prestigeRequirement: 18, + prestigeRequirement: 4, status: "locked", upgradeRewards: [], zoneId: "void_sanctum", @@ -735,7 +735,7 @@ 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", @@ -753,7 +753,7 @@ export const defaultBosses: Array = [ id: "the_unmaker", maxHp: 2e20, name: "The Unmaker", - prestigeRequirement: 20, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "void_sanctum", @@ -771,7 +771,7 @@ export const defaultBosses: Array = [ id: "void_progenitor", maxHp: 8e20, name: "The Void Progenitor", - prestigeRequirement: 21, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "void_sanctum", @@ -789,7 +789,7 @@ export const defaultBosses: Array = [ id: "void_emperor", maxHp: 3e21, name: "The Void Emperor", - prestigeRequirement: 22, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "void_sanctum", @@ -808,7 +808,7 @@ export const defaultBosses: Array = [ id: "throne_warden", maxHp: 1e22, name: "The Throne Warden", - prestigeRequirement: 21, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "eternal_throne", @@ -826,7 +826,7 @@ 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", @@ -844,7 +844,7 @@ export const defaultBosses: Array = [ id: "the_undying", maxHp: 2e23, name: "The Undying", - prestigeRequirement: 23, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "eternal_throne", @@ -862,7 +862,7 @@ export const defaultBosses: Array = [ id: "apex_sovereign", maxHp: 8e23, name: "The Apex Sovereign", - prestigeRequirement: 24, + prestigeRequirement: 5, status: "locked", upgradeRewards: [], zoneId: "eternal_throne", @@ -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", @@ -899,7 +899,7 @@ export const defaultBosses: Array = [ id: "chaos_wyrm", maxHp: 1e26, name: "The Chaos Wyrm", - prestigeRequirement: 26, + prestigeRequirement: 6, status: "locked", upgradeRewards: [], zoneId: "primordial_chaos", @@ -917,7 +917,7 @@ 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", @@ -935,7 +935,7 @@ export const defaultBosses: Array = [ id: "entropy_avatar", maxHp: 2e29, name: "The Entropy Avatar", - prestigeRequirement: 29, + prestigeRequirement: 7, status: "locked", upgradeRewards: [], zoneId: "primordial_chaos", @@ -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", @@ -972,7 +972,7 @@ 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", @@ -990,7 +990,7 @@ export const defaultBosses: Array = [ id: "horizon_beast", maxHp: 1e37, name: "The Horizon Beast", - prestigeRequirement: 35, + prestigeRequirement: 8, status: "locked", upgradeRewards: [], zoneId: "infinite_expanse", @@ -1008,7 +1008,7 @@ export const defaultBosses: Array = [ id: "infinity_construct", maxHp: 5e40, name: "The Infinity Construct", - prestigeRequirement: 37, + prestigeRequirement: 8, status: "locked", upgradeRewards: [], zoneId: "infinite_expanse", @@ -1026,7 +1026,7 @@ export const defaultBosses: Array = [ id: "expanse_sovereign", maxHp: 2e44, name: "The Expanse Sovereign", - prestigeRequirement: 39, + prestigeRequirement: 9, status: "locked", upgradeRewards: [], zoneId: "infinite_expanse", @@ -1045,7 +1045,7 @@ 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", @@ -1063,7 +1063,7 @@ export const defaultBosses: Array = [ id: "reality_shaper", maxHp: 4e52, name: "The Reality Shaper", - prestigeRequirement: 44, + prestigeRequirement: 10, status: "locked", upgradeRewards: [], zoneId: "reality_forge", @@ -1081,7 +1081,7 @@ export const defaultBosses: Array = [ id: "creation_prime", maxHp: 2e57, name: "The Creation Prime", - prestigeRequirement: 47, + prestigeRequirement: 11, status: "locked", upgradeRewards: [], zoneId: "reality_forge", @@ -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", @@ -1118,7 +1118,7 @@ export const defaultBosses: Array = [ id: "storm_colossus", maxHp: 4e65, name: "The Storm Colossus", - prestigeRequirement: 51, + prestigeRequirement: 12, status: "locked", upgradeRewards: [], zoneId: "cosmic_maelstrom", @@ -1136,7 +1136,7 @@ export const defaultBosses: Array = [ id: "force_prime", maxHp: 2e71, name: "The Force Prime", - prestigeRequirement: 54, + prestigeRequirement: 12, status: "locked", upgradeRewards: [], zoneId: "cosmic_maelstrom", @@ -1154,7 +1154,7 @@ export const defaultBosses: Array = [ id: "maelstrom_god", maxHp: 1e77, name: "The Maelstrom God", - prestigeRequirement: 57, + prestigeRequirement: 13, status: "locked", upgradeRewards: [], zoneId: "cosmic_maelstrom", @@ -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", @@ -1191,7 +1191,7 @@ 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", @@ -1209,7 +1209,7 @@ export const defaultBosses: Array = [ id: "time_elder", maxHp: 1e95, name: "The Time Elder", - prestigeRequirement: 65, + prestigeRequirement: 15, status: "locked", upgradeRewards: [], zoneId: "primeval_sanctum", @@ -1227,7 +1227,7 @@ export const defaultBosses: Array = [ id: "origin_beast", maxHp: 8e101, name: "The Origin Beast", - prestigeRequirement: 69, + prestigeRequirement: 16, status: "locked", upgradeRewards: [], zoneId: "primeval_sanctum", @@ -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", @@ -1264,7 +1264,7 @@ 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", @@ -1282,7 +1282,7 @@ export const defaultBosses: Array = [ id: "void_convergence", maxHp: 1e125, name: "The Void Convergence", - prestigeRequirement: 79, + prestigeRequirement: 18, status: "locked", upgradeRewards: [], zoneId: "the_absolute", @@ -1300,7 +1300,7 @@ export const defaultBosses: Array = [ id: "eternal_end", maxHp: 5e134, name: "The Eternal End", - prestigeRequirement: 83, + prestigeRequirement: 19, status: "locked", upgradeRewards: [], zoneId: "the_absolute", @@ -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/transcendenceUpgrades.ts b/apps/api/src/data/transcendenceUpgrades.ts index cd4f301..8cfac04 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: 25, 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: 75, 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: 200, description: "You have mastered the infinite spiral of transcendence, doubling all future echo yields.", id: "echo_meta_3", diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 41dbd43..dad8ce3 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -15,7 +15,6 @@ import type { } from "@elysium/types"; const basePrestigeGoldThreshold = 1_000_000; -const thresholdScaleFactor = 5; const runestonesPerPrestigeLevel = 10; const milestoneInterval = 5; const milestoneRunestonesPerInterval = 25; @@ -29,7 +28,8 @@ 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. @@ -40,7 +40,7 @@ const calculatePrestigeThreshold = ( ): number => { return ( basePrestigeGoldThreshold - * Math.pow(thresholdScaleFactor, prestigeCount) + * Math.pow(prestigeCount + 1, 2) * thresholdMultiplier ); }; @@ -146,14 +146,15 @@ const calculateRunestones = (parameters: RunestoneParameters): number => { /** * Calculates the new prestige production multiplier. - * Formula: 1.15^prestigeCount — exponential scaling per prestige. + * Formula: 1.25^prestigeCount — exponential scaling per prestige that eventually + * overtakes the polynomial threshold growth, making late prestiges progressively easier. * @param prestigeCount - The new prestige count. * @returns The production multiplier for the new prestige level. */ const calculateProductionMultiplier = ( prestigeCount: number, ): number => { - return Math.pow(1.15, prestigeCount); + return Math.pow(1.25, prestigeCount); }; /** 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/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/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index b3a9c39..1638781 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", () => { @@ -128,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.25 at count 1", () => { + expect(calculateProductionMultiplier(1)).toBeCloseTo(1.25); }); it("scales exponentially", () => { - expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10)); + expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.25, 10)); }); }); 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", () => { -- 2.52.0 From eed61db410198a8b291b2c783529a24c9908dbaf Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 13:56:44 -0700 Subject: [PATCH 05/53] fix: add dark_templar_1 upgrade reward to Void Titan boss Closes #138 --- apps/api/src/data/bosses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index b8323ee..351df71 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -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 ─────────────────────────────────────────────────────── -- 2.52.0 From 8a38d02e6990247bd392f0bb7f927a4819643d93 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 13:58:54 -0700 Subject: [PATCH 06/53] fix: buff Shadow Marshes quest rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shadow_mere: 150 essence → 5M gold + 5K essence - witch_coven: 500 essence → 20M gold + 20K essence - plague_ruins: 8M gold + 2K essence → 100M gold + 30K essence + 500 crystals Closes #136 --- apps/api/src/data/quests.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 2799402..e73060b 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -198,7 +198,8 @@ export const defaultQuests: Array = [ name: "The Shadow Mere", prerequisiteIds: [], rewards: [ - { amount: 150, type: "essence" }, + { amount: 5_000_000, type: "gold" }, + { amount: 5000, type: "essence" }, ], status: "locked", zoneId: "shadow_marshes", @@ -212,7 +213,8 @@ export const defaultQuests: Array = [ name: "The Witch Coven", prerequisiteIds: [ "shadow_mere" ], rewards: [ - { amount: 500, type: "essence" }, + { amount: 20_000_000, type: "gold" }, + { amount: 20_000, type: "essence" }, { targetId: "shadow_assassin", type: "adventurer" }, ], status: "locked", @@ -245,9 +247,9 @@ export const defaultQuests: Array = [ 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", -- 2.52.0 From f001acc3827f5ada9d317feda43780bef615374c Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:00:33 -0700 Subject: [PATCH 07/53] fix: buff Astral Void quest rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - void_rift: zero gold → 2B gold + 300K essence + 1K crystals - star_graveyard: 1B gold + 100K essence → 8B gold + 800K essence + 3K crystals - between_worlds: zero gold + 250K essence → 25B gold + 2M essence + 8K crystals - the_end: 10B gold + 1M essence → 80B gold + 5M essence + 20K crystals Closes #137 --- apps/api/src/data/quests.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index e73060b..1fcdd3f 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -331,8 +331,9 @@ export const defaultQuests: Array = [ 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", @@ -346,9 +347,9 @@ export const defaultQuests: Array = [ 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", @@ -362,8 +363,9 @@ export const defaultQuests: Array = [ 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", @@ -378,9 +380,9 @@ export const defaultQuests: Array = [ 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", -- 2.52.0 From e4808680edb641b89bf46ed52bc0da5325efda23 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:02:16 -0700 Subject: [PATCH 08/53] feat: add missing quests to Frozen Peaks zone - Added glacier_tomb (200K combat, 2.5hr) between frozen_wastes and ice_caves - Added frozen_throne (3M combat, 7hr) after storm_citadel - Updated ice_caves prerequisite to chain from glacier_tomb - Frozen Peaks now has 5 quests, in line with other zones Closes #139 --- apps/api/src/data/quests.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 1fcdd3f..50b47b5 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -157,6 +157,21 @@ export const defaultQuests: Array = [ status: "locked", zoneId: "frozen_peaks", }, + { + combatPowerRequired: 200_000, + description: + "A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.", + durationSeconds: 150 * 60, + id: "glacier_tomb", + name: "The Glacier Tomb", + prerequisiteIds: [ "frozen_wastes" ], + rewards: [ + { amount: 10_000_000, type: "gold" }, + { amount: 3000, type: "essence" }, + ], + status: "locked", + zoneId: "frozen_peaks", + }, { combatPowerRequired: 400_000, description: @@ -164,7 +179,7 @@ export const defaultQuests: Array = [ durationSeconds: 3 * 60 * 60, id: "ice_caves", name: "The Ice Caves", - prerequisiteIds: [ "frozen_wastes" ], + prerequisiteIds: [ "glacier_tomb" ], rewards: [ { amount: 5000, type: "essence" }, { amount: 200, type: "crystals" }, @@ -188,6 +203,22 @@ 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: 7 * 60 * 60, + id: "frozen_throne", + name: "The Frozen Throne", + prerequisiteIds: [ "storm_citadel" ], + rewards: [ + { amount: 60_000_000, type: "gold" }, + { amount: 25_000, type: "essence" }, + { amount: 400, type: "crystals" }, + ], + status: "locked", + zoneId: "frozen_peaks", + }, // ── Shadow Marshes ──────────────────────────────────────────────────────── { combatPowerRequired: 5_000_000, -- 2.52.0 From d84725921a419dc40b5a6c9d2e2ee317ff4bb6aa Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:06:01 -0700 Subject: [PATCH 09/53] fix: restore upgrade drops to late-game bosses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assigned 3 previously orphaned adventurer upgrades to appropriate bosses: - horizon_beast (Infinite Expanse) → oblivion_paladin_1 - maelstrom_god (Cosmic Maelstrom) → transcendent_rogue_1 - eternal_end (The Absolute) → omniversal_champion_1 Closes #140 --- apps/api/src/data/bosses.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 351df71..f0c3c21 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -992,7 +992,7 @@ export const defaultBosses: Array = [ name: "The Horizon Beast", prestigeRequirement: 8, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "oblivion_paladin_1" ], zoneId: "infinite_expanse", }, { @@ -1156,7 +1156,7 @@ export const defaultBosses: Array = [ name: "The Maelstrom God", prestigeRequirement: 13, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "transcendent_rogue_1" ], zoneId: "cosmic_maelstrom", }, { @@ -1302,7 +1302,7 @@ export const defaultBosses: Array = [ name: "The Eternal End", prestigeRequirement: 19, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "omniversal_champion_1" ], zoneId: "the_absolute", }, { -- 2.52.0 From 4b3a856ef98456d50ea15b0531c43beac151c571 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:25:34 -0700 Subject: [PATCH 10/53] fix: smooth adventurer cost curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - militia: GPS 0.5 → 0.7 to match 10x cost jump - Tiers 11-14: costs raised to even ~4.7x spread through tier 15 - Closes #145 --- apps/api/src/data/adventurers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 4079373..5435a53 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, -- 2.52.0 From 7ecc6554842d0f9a53b80308167024fa113a4cd5 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:35:51 -0700 Subject: [PATCH 11/53] fix: buff purchasable equipment dominated by boss drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - celestial_focus: click 3x → 4.25x (above free void_heart_gem) - void_conduit: combat 7x → 10.5x (above free throne_blade) - crystal_matrix: gold 4.75x → 7.5x (above free eternal_armour) - Closes #141 --- apps/api/src/data/equipment.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index d0a6d86..4fd3319 100644 --- a/apps/api/src/data/equipment.ts +++ b/apps/api/src/data/equipment.ts @@ -697,7 +697,7 @@ export const defaultEquipment: Array = [ }, // ── 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 +721,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 +745,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.", -- 2.52.0 From 7c390f45b556ad317191570df54fa69eb6b6115f Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:37:11 -0700 Subject: [PATCH 12/53] fix: add zone-18 click_power recipe, raising ceiling to 1.28x - Added absolute_focus (click 1.28x) to the_absolute zone - Matches zone-18 pattern, filling gap left by existing gold/combat recipes - Closes #142 --- apps/api/src/data/recipes.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 8564a2b..597d5e7 100644 --- a/apps/api/src/data/recipes.ts +++ b/apps/api/src/data/recipes.ts @@ -508,6 +508,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: -- 2.52.0 From 0609cc75840625e72b35222383a7caa929e64579 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 14:44:59 -0700 Subject: [PATCH 13/53] fix: buff rare-material crafting recipes to justify ingredient cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - elder_bark_shield: combat 1.08x → 1.12x - void_fragment_amulet: gold 1.10x → 1.15x - soul_bound_catalyst: essence 1.15x → 1.20x - Closes #143 --- apps/api/src/data/recipes.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 597d5e7..9ee4ded 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.12 }, 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", @@ -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", -- 2.52.0 From b6e218167ded2560eb5660b785d8661c06d6a5d3 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 15:29:42 -0700 Subject: [PATCH 14/53] fix: differentiate philosophers_stone and buff crystal_shard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - philosophers_stone: gold 1.25x → 1.4x (income specialist, distinct from eternal_flame which has combat 1.1x + gold 1.25x) - crystal_shard: gold 1.10x → 1.20x (zone-5 epic, better premium) - Closes #144 --- apps/api/src/data/equipment.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index 4fd3319..de6a5bf 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.55, 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.25, 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", -- 2.52.0 From 4c297f1ce174b7c3d083ec590e01e2758f113ffe Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 16:47:53 -0700 Subject: [PATCH 15/53] fix: resolve sync inflation, signature mismatch, CP accuracy, auto-buy cap, unlock hints - #147: Guard all patch functions with hasChanged before incrementing sync counter to prevent inflation on no-op patches - #148: Clear stale HMAC signature after each boss fight so subsequent auto-saves do not send a mismatched signature - #146: Auto-unlock adventurer-specific upgrades in applyTick when their adventurer count > 0; show recruit hint in upgrade panel - #149: Add Essence/s row to resource bar dropdown - #150: Fix broken auto-quest CP reduce formula; centralise via computePartyCombatPower which applies all multipliers correctly - #151: Cap auto-buy at 100 for non-max-tier adventurers; max tier (highest level unlocked) remains uncapped - #152: Export computePartyCombatPower from tick, applying global upgrades, prestige, equipment, set bonuses, echo, crafted, and companion multipliers; use it in resource bar and boss panel --- apps/api/src/routes/debug.ts | 99 +++++++++++- apps/web/src/components/game/bossPanel.tsx | 85 ++-------- apps/web/src/components/game/upgradePanel.tsx | 18 +++ apps/web/src/components/ui/resourceBar.tsx | 21 ++- apps/web/src/context/gameContext.tsx | 35 ++++- apps/web/src/engine/tick.ts | 145 ++++++++++++++++++ 6 files changed, 315 insertions(+), 88 deletions(-) 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/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/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..1743a6f 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -10,7 +10,12 @@ /* 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, +} from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; interface ResourceBarProperties { @@ -83,12 +88,11 @@ const ResourceBar = ({ const { gold, essence, crystals } = resources; let partyCombatPower = 0; let goldPerSecond = 0; + let essencePerSecond = 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); } let avatarUrl: string | null = null; @@ -182,6 +186,13 @@ const ResourceBar = ({ {"Gold/s"} +
+ {"⚡"} + + {formatNumber(essencePerSecond)} + + {"Essence/s"} +
diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index fd89b84..22b2ec3 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -58,6 +58,7 @@ import { RESOURCE_CAP, applyTick, calculateClickPower, + computePartyCombatPower, } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; @@ -1078,11 +1079,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 +1117,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 @@ -1346,6 +1360,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, diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index c80f8e5..c8b42a4 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -195,6 +195,138 @@ 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 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. + * @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; + } + } + + // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear + const prestigeMultiplier = 1 + state.prestige.count * 0.1; + + const equipmentCombatMultiplier = state.equipment. + 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; +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. @@ -469,6 +601,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, -- 2.52.0 From d1559c327f91c2f17d94f6de8ac79b4e468a1ca0 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 16:54:53 -0700 Subject: [PATCH 16/53] fix: balance equipment, click_power recipe ceiling, adventurer cost curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #141: Already resolved in prior commits (celestial_focus 4.25×, void_conduit 10.5×, crystal_matrix 7.5× all exceed free-drop tier) - #142: Add primal_omega_lens cross-zone click_power recipe at 1.38×, matching the eternal_omega combat ceiling and closing the gap above the zone-17 cap of 1.25× - #143: Already resolved in prior commits (elder_bark_shield 1.12×, void_fragment_amulet 1.15×, soul_bound_catalyst 1.20× all buffed) - #144: Raise philosophers_stone click 2.25×→2.5× to differentiate from eternal_flame; raise crystal_shard click 1.55×→1.65× so the volcanic_forger set trinket beats void_compass (1.6×) - #145: Militia goldPerSecond already fixed; raise celestial_guard baseCost 1.4T→1.8T, smoothing tier 14→15 from 4.67× to 6× and removing the jarring tier 15→16 wall (7.14×→5.56×) --- apps/api/src/data/adventurers.ts | 2 +- apps/api/src/data/equipment.ts | 4 ++-- apps/api/src/data/recipes.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 5435a53..7294344 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -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/equipment.ts b/apps/api/src/data/equipment.ts index de6a5bf..5e37bcf 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.2 }, + 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,7 +305,7 @@ export const defaultEquipment: Array = [ type: "trinket", }, { - bonus: { clickMultiplier: 2.25, goldMultiplier: 1.4 }, + bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 }, description: "The legendary stone that transmutes effort into wealth — every action fills the coffers.", equipped: false, diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 9ee4ded..5d03ff8 100644 --- a/apps/api/src/data/recipes.ts +++ b/apps/api/src/data/recipes.ts @@ -492,6 +492,19 @@ export const defaultRecipes: Array = [ ], zoneId: "abyssal_trench", }, + { + bonus: { type: "click_power", value: 1.38 }, + description: + "A primeval relic submerged at the absolute boundary of existence alongside omega crystals and boundary shards — the first and last thing, unified. Every action your guild takes through it is simultaneously the most ancient and most final thing that has ever happened. It does not miss.", + id: "primal_omega_lens", + name: "Primal Omega Lens", + requiredMaterials: [ + { materialId: "primeval_relic", quantity: 2 }, + { materialId: "boundary_shard", quantity: 4 }, + { materialId: "omega_crystal", quantity: 2 }, + ], + zoneId: "the_absolute", + }, { bonus: { type: "combat_power", value: 1.4 }, description: -- 2.52.0 From 77c7ee02a6242f5bdf81d9492d8ec01ee6f2e11a Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 17:05:56 -0700 Subject: [PATCH 17/53] fix: assign upgrade rewards to late-game bosses (#140) Distributes the nine unassigned adventurer-specific upgrade rewards across Crystalline Spire through Eternal Throne bosses that previously had empty upgradeRewards arrays, ensuring all adventurer upgrades are obtainable via boss drops. --- apps/api/src/data/bosses.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index f0c3c21..7a52460 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -628,7 +628,7 @@ export const defaultBosses: Array = [ name: "The Prism Golem", prestigeRequirement: 3, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "crystal_sage_1" ], zoneId: "crystalline_spire", }, { @@ -664,7 +664,7 @@ export const defaultBosses: Array = [ name: "The Faceted", prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "void_sentinel_1" ], zoneId: "crystalline_spire", }, { @@ -682,7 +682,7 @@ export const defaultBosses: Array = [ name: "The Diamond Colossus", prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "eternal_champion_1" ], zoneId: "crystalline_spire", }, { @@ -700,7 +700,7 @@ export const defaultBosses: Array = [ name: "The Crystal Sovereign", prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "cosmos_knight_1" ], zoneId: "crystalline_spire", }, // ── Void Sanctum ────────────────────────────────────────────────────────── @@ -719,7 +719,7 @@ export const defaultBosses: Array = [ name: "The Void Herald", prestigeRequirement: 4, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "seraph_knight_1" ], zoneId: "void_sanctum", }, { @@ -755,7 +755,7 @@ export const defaultBosses: Array = [ name: "The Unmaker", prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "abyss_diver_1" ], zoneId: "void_sanctum", }, { @@ -791,7 +791,7 @@ export const defaultBosses: Array = [ name: "The Void Emperor", prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "infernal_warden_1" ], zoneId: "void_sanctum", }, // ── Eternal Throne ──────────────────────────────────────────────────────── @@ -810,7 +810,7 @@ export const defaultBosses: Array = [ name: "The Throne Warden", prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "infinity_ranger_1" ], zoneId: "eternal_throne", }, { @@ -846,7 +846,7 @@ export const defaultBosses: Array = [ name: "The Undying", prestigeRequirement: 5, status: "locked", - upgradeRewards: [], + upgradeRewards: [ "reality_warden_1" ], zoneId: "eternal_throne", }, { -- 2.52.0 From 56d963dc906526f5c5aab115ac02b43884a177f1 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 17:07:13 -0700 Subject: [PATCH 18/53] fix: clarify combat power vs boss damage distinction (#153) Expands the JSDoc on computePartyCombatPower to explicitly document that the companion bossDamage multiplier is intentionally included in all combat-power calculations (boss panel, resource bar, quest gating), matching server-side behaviour and resolving labelling ambiguity. --- apps/web/src/engine/tick.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index c8b42a4..240edab 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -246,7 +246,14 @@ export const computeEssencePerSecond = (state: GameState): number => { /** * 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. + * 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. */ -- 2.52.0 From 8a332dc9cef0c734e5fdcb965af649c887c26689 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Mar 2026 17:13:00 -0700 Subject: [PATCH 19/53] fix: show effective post-multiplier stats on adventurer cards (#154) Adds computeEffectiveAdventurerStats to tick.ts to calculate per-unit gold/s, essence/s, and combat power with all active multipliers applied (upgrades, prestige, equipment, echo, crafted, companions). Updates AdventurerCard to display these effective values so players can see the true contribution of each adventurer rather than raw base stats. --- .../src/components/game/adventurerPanel.tsx | 30 +++-- apps/web/src/engine/tick.ts | 112 ++++++++++++++++++ 2 files changed, 134 insertions(+), 8 deletions(-) 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/engine/tick.ts b/apps/web/src/engine/tick.ts index 240edab..145e56b 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -243,6 +243,118 @@ export const computeEssencePerSecond = (state: GameState): number => { return essencePerSecond; }; +/** + * Computes the effective per-unit stats for a single adventurer type, + * applying all active multipliers (upgrades, prestige, equipment, echo, + * crafted, companion). The returned values represent what a single + * adventurer of this type currently contributes per second, matching the + * per-unit contribution used by computeGoldPerSecond and + * computeEssencePerSecond. + * @param state - The current game state. + * @param adventurerId - The ID of the adventurer to compute stats for. + * @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower. + */ +export const computeEffectiveAdventurerStats = ( + state: GameState, + adventurerId: string, +): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => { + const adventurer = state.adventurers.find((a) => { + return a.id === adventurerId; + }); + + /* V8 ignore next 3 -- @preserve */ + if (adventurer === undefined) { + return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 }; + } + + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurerId; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + + const equippedItems = state.equipment.filter((item) => { + return item.equipped; + }); + const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.goldMultiplier ?? 1); + }, 1); + const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.combatMultiplier ?? 1); + }, 1); + const equippedItemIds = equippedItems.map((item) => { + return item.id; + }); + const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS); + + const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; + const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; + // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear + const prestigeCombatMultiplier = 1 + state.prestige.count * 0.1; + const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; + const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1; + const craftedGoldMultiplier + = state.exploration?.craftedGoldMultiplier ?? 1; + const craftedEssenceMultiplier + = state.exploration?.craftedEssenceMultiplier ?? 1; + const craftedCombatMultiplier + = state.exploration?.craftedCombatMultiplier ?? 1; + + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionGoldMult + = companionBonus?.type === "passiveGold" + ? 1 + companionBonus.value + : 1; + const companionEssenceMult + = companionBonus?.type === "essenceIncome" + ? 1 + companionBonus.value + : 1; + const companionCombatMult + = companionBonus?.type === "bossDamage" + ? 1 + companionBonus.value + : 1; + + const goldPerSecond + = adventurer.goldPerSecond + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesIncome + * echoIncome + * equipmentGoldMultiplier + * setBonuses.goldMultiplier + * craftedGoldMultiplier + * companionGoldMult; + + const essencePerSecond + = adventurer.essencePerSecond + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesEssence + * craftedEssenceMultiplier + * companionEssenceMult; + + const combatPower + = adventurer.combatPower + * upgradeMultiplier + * prestigeCombatMultiplier + * equipmentCombatMultiplier + * setBonuses.combatMultiplier + * echoCombatMultiplier + * craftedCombatMultiplier + * companionCombatMult; + + return { combatPower, essencePerSecond, goldPerSecond }; +}; + /** * Computes the party's total combat power, applying all active multipliers * (upgrades, prestige, equipment, set bonuses, echo, crafted, companion). -- 2.52.0 From 689133d05d542b42ecb02b5710374092fb69c51b Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 26 Mar 2026 10:24:53 -0700 Subject: [PATCH 20/53] fix: preserve autoAdventurer setting across prestige The auto-buy adventurers toggle was silently reset to false on every prestige because it was not included in the list of automation preferences carried forward into the fresh state. This mirrors the existing handling for autoBoss and autoQuest. Closes #156 --- apps/api/src/services/prestige.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index dad8ce3..f20c8c1 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -263,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 -- 2.52.0 From 0542402b4da10b5b92e3a79c81d64389cd6bb716 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 26 Mar 2026 10:25:06 -0700 Subject: [PATCH 21/53] fix: use computePartyCombatPower in quest panel for consistent CP display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quest panel was computing party combat power with a simplified hand-rolled loop (base combatPower × count only) that did not account for upgrade multipliers, prestige bonus, equipment set bonuses, echo or crafted multipliers, or the active companion bonus. This meant the displayed "you have X combat power" value diverged from the value used by the auto-quest engine (computePartyCombatPower), which could show the player an incorrect picture of whether a quest was startable — particularly after upgrades or equipment began boosting combat power. Replacing the loop with computePartyCombatPower(state) makes the quest card display fully consistent with the auto-quest eligibility check. Closes #157 --- apps/web/src/components/game/questPanel.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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; }); -- 2.52.0 From 48120e078975c8dff0431e8be2abcae5e3ac83b3 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 26 Mar 2026 15:43:13 -0700 Subject: [PATCH 22/53] fix: pull adventurer upgrade rewards forward to their relevant progression window Ten upgrades were dropping 1-2 zones after the adventurer they buff was no longer meaningful. Moved apprentice_1 to goblin_camp, militia_1 to haunted_mine, knight_1 to frozen_wastes, peasant_2 to glacier_tomb, peasant_3 to shadow_mere, and pulled the T27-30 upgrades (astral_sovereign_1, primordial_mage_1, reality_warden_1, infinity_ranger_1) and cosmos_knight_1 into their adventurer's own zone. --- apps/api/src/data/quests.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 50b47b5..6090ffa 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", @@ -82,7 +84,6 @@ export const defaultQuests: Array = [ rewards: [ { amount: 15_000, type: "gold" }, { amount: 20, type: "essence" }, - { targetId: "militia_1", type: "upgrade" }, { targetId: "acolyte_1", type: "upgrade" }, { targetId: "ranger", type: "adventurer" }, ], @@ -117,7 +118,6 @@ export const defaultQuests: Array = [ rewards: [ { amount: 300, type: "essence" }, { amount: 30, type: "crystals" }, - { targetId: "apprentice_1", type: "upgrade" }, { targetId: "archmage", type: "adventurer" }, ], status: "locked", @@ -153,6 +153,7 @@ 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", @@ -168,6 +169,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 10_000_000, type: "gold" }, { amount: 3000, type: "essence" }, + { targetId: "peasant_2", type: "upgrade" }, ], status: "locked", zoneId: "frozen_peaks", @@ -231,6 +233,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5_000_000, type: "gold" }, { amount: 5000, type: "essence" }, + { targetId: "peasant_3", type: "upgrade" }, ], status: "locked", zoneId: "shadow_marshes", @@ -263,8 +266,6 @@ export const defaultQuests: Array = [ { amount: 2_000_000, type: "gold" }, { amount: 1500, type: "essence" }, { amount: 75, type: "crystals" }, - { targetId: "knight_1", type: "upgrade" }, - { targetId: "peasant_2", type: "upgrade" }, ], status: "locked", zoneId: "shadow_marshes", @@ -315,7 +316,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", @@ -1183,6 +1183,7 @@ export const defaultQuests: Array = [ { amount: 8e39, type: "gold" }, { amount: 2.5e36, type: "essence" }, { amount: 5e32, type: "crystals" }, + { targetId: "cosmos_knight_1", type: "upgrade" }, ], status: "locked", zoneId: "infinite_expanse", @@ -1265,7 +1266,7 @@ export const defaultQuests: Array = [ { amount: 2.5e49, type: "gold" }, { amount: 8e45, type: "essence" }, { amount: 5e41, type: "crystals" }, - { targetId: "cosmos_knight_1", type: "upgrade" }, + { targetId: "primordial_mage_1", type: "upgrade" }, ], status: "locked", zoneId: "reality_forge", @@ -1298,6 +1299,7 @@ export const defaultQuests: Array = [ { amount: 6e52, type: "gold" }, { amount: 2e49, type: "essence" }, { amount: 1.2e45, type: "crystals" }, + { targetId: "astral_sovereign_1", type: "upgrade" }, ], status: "locked", zoneId: "reality_forge", @@ -1364,7 +1366,6 @@ export const defaultQuests: Array = [ { amount: 4e63, type: "gold" }, { amount: 1.2e60, type: "essence" }, { amount: 7e55, type: "crystals" }, - { targetId: "astral_sovereign_1", type: "upgrade" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1381,6 +1382,7 @@ export const defaultQuests: Array = [ { amount: 2e66, type: "gold" }, { amount: 6e62, type: "essence" }, { amount: 3.5e58, type: "crystals" }, + { targetId: "reality_warden_1", type: "upgrade" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1397,6 +1399,7 @@ export const defaultQuests: Array = [ { amount: 1e69, type: "gold" }, { amount: 3e65, type: "essence" }, { amount: 1.8e61, type: "crystals" }, + { targetId: "infinity_ranger_1", type: "upgrade" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1463,7 +1466,6 @@ export const defaultQuests: Array = [ { amount: 6e83, type: "gold" }, { amount: 1.8e80, type: "essence" }, { amount: 1e76, type: "crystals" }, - { targetId: "primordial_mage_1", type: "upgrade" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1545,7 +1547,6 @@ export const defaultQuests: Array = [ { amount: 2e108, type: "gold" }, { amount: 6e104, type: "essence" }, { amount: 3e100, type: "crystals" }, - { targetId: "reality_warden_1", type: "upgrade" }, ], status: "locked", zoneId: "the_absolute", @@ -1578,7 +1579,6 @@ export const defaultQuests: Array = [ { amount: 5e121, type: "gold" }, { amount: 1.5e118, type: "essence" }, { amount: 7e113, type: "crystals" }, - { targetId: "infinity_ranger_1", type: "upgrade" }, ], status: "locked", zoneId: "the_absolute", -- 2.52.0 From a09280470efda1471479706c5419444df6f296a5 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 10:35:29 -0700 Subject: [PATCH 23/53] fix: prevent auto-save race from discarding collected exploration materials (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block the auto-save tick while the /explore/collect request is in-flight, clear the stale HMAC signature after the server-side DB write, and reset the save timer so the next auto-save fires after React has re-rendered with the new materials in stateReference — eliminating the window where a stale client snapshot could overwrite the server's freshly saved collect result. --- apps/web/src/context/gameContext.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 22b2ec3..206b84c 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1810,7 +1810,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; -- 2.52.0 From 3735cff23ff765c7eb9d122e0817e71fc0d023c8 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 10:35:36 -0700 Subject: [PATCH 24/53] fix: unlock exploration areas when their zone is unlocked by boss kill or quest (#161) applyBossResult and the tick engine both updated zone status to "unlocked" but never propagated that unlock to state.exploration.areas, leaving all areas in the new zone permanently locked until force-unlock was used. Both code paths now map over exploration areas and set any locked area whose zone just became unlocked to "available" in the same state update. --- apps/web/src/context/gameContext.tsx | 21 +++++++++++++++++++++ apps/web/src/engine/tick.ts | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 206b84c..04fa573 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -53,6 +53,7 @@ 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, @@ -116,6 +117,9 @@ const applyBossResult = ( }). filter(Boolean), ); + const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => { + return z.id; + })); const challengeUpdate = previous.dailyChallenges === undefined @@ -216,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; + }), + }, + }, }; } diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 145e56b..ada6501 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -21,6 +21,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"; /** @@ -753,6 +754,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, -- 2.52.0 From b3d257048f460b0a52650bdeaf2d7699ba6fafd4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:12:42 -0700 Subject: [PATCH 25/53] fix: prevent duplicate prestige bot announcements on concurrent requests Adds an optimistic lock on the prestige route so that a second concurrent request for the same state is rejected with 409 rather than firing the Discord announcement twice. Also adds missing branch-coverage tests for debug.ts to satisfy the 100% threshold. Closes #162 --- apps/api/src/routes/prestige.ts | 15 ++++- apps/api/test/routes/debug.spec.ts | 96 +++++++++++++++++++++++++++ apps/api/test/routes/prestige.spec.ts | 20 ++++-- 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index f482d5a..2c651a8 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, diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 2c8d051..a0fb33e 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,18 @@ 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("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 +908,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 +949,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 +989,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 +1041,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..a6dd7b4 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -8,7 +8,7 @@ import type { GameState } from "@elysium/types"; vi.mock("../../src/db/client.js", () => ({ prisma: { player: { update: vi.fn() }, - gameState: { findUnique: vi.fn(), update: vi.fn() }, + gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })); @@ -48,7 +48,7 @@ describe("prestige route", () => { let app: Hono; let prisma: { player: { update: ReturnType }; - gameState: { 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,8 +120,8 @@ 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); -- 2.52.0 From 48477ee286e55a33b4cb2d6d7696aafbfe249bd2 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:35:13 -0700 Subject: [PATCH 26/53] fix: eliminate loading screen flash after prestige (#163) Add reloadSilent which rehydrates state without toggling isLoading, preventing the game from unmounting and showing the loading screen after auto- or manual prestige. --- .../web/src/components/game/prestigePanel.tsx | 4 +- apps/web/src/context/gameContext.tsx | 39 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index 9addb63..e12d331 100644 --- a/apps/web/src/components/game/prestigePanel.tsx +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -84,7 +84,7 @@ const categoryOrder: Array = [ const PrestigePanel = (): JSX.Element => { const { state, - reload, + reloadSilent, formatNumber, buyPrestigeUpgrade, enableNotifications, @@ -141,7 +141,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/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 04fa573..a35d47c 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -310,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). */ @@ -718,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); @@ -805,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 ]); @@ -1315,7 +1351,7 @@ export const GameProvider = ({ if (enableNotificationsReference.current) { sendNotification("⭐ Prestige!", "You have ascended!"); } - await reloadReference.current(); + await reloadSilentReference.current(); }). catch(() => { @@ -2373,6 +2409,7 @@ export const GameProvider = ({ offlineEssence, offlineGold, reload, + reloadSilent, resetProgress, saveSchemaVersion, schemaOutdated, -- 2.52.0 From 96868c41433193e18565012ae726dfe3e7d0f80f Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:45:57 -0700 Subject: [PATCH 27/53] balance: reduce shadow_mere CP requirement 5M -> 2M (#164) The zone unlocks at 1.5M CP (storm_citadel), making the 5M CP entry quest unreachable for most players. 2M CP is achievable with Arcane Scholar and Dragon Rider adventurers without being trivial. --- apps/api/src/data/quests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 6090ffa..bf4c190 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -223,7 +223,7 @@ export const defaultQuests: Array = [ }, // ── 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, -- 2.52.0 From 4a9ecbf7064fa1d87a21ee564485c141207a4ae4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:46:58 -0700 Subject: [PATCH 28/53] balance: improve mid-game crystal income (#165) Add 150 crystals to shadow_mere and 500 to witch_coven quest rewards. Double shadow_marshes boss crystal drops (700->1500, 1500->3000, 3000->6000) to provide meaningful crystal flow for players reaching Shadow Marshes. --- apps/api/src/data/bosses.ts | 6 +++--- apps/api/src/data/quests.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 7a52460..11aeb93 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -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: diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index bf4c190..c3dc3f3 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -233,6 +233,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5_000_000, type: "gold" }, { amount: 5000, type: "essence" }, + { amount: 150, type: "crystals" }, { targetId: "peasant_3", type: "upgrade" }, ], status: "locked", @@ -249,6 +250,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 20_000_000, type: "gold" }, { amount: 20_000, type: "essence" }, + { amount: 500, type: "crystals" }, { targetId: "shadow_assassin", type: "adventurer" }, ], status: "locked", -- 2.52.0 From ec0763819eb457546702dcf1290442e9b8ec4507 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:47:48 -0700 Subject: [PATCH 29/53] balance: increase runestone yield 50% per prestige (#166) Raise runestonesPerPrestigeLevel from 10 to 15. Early-game players were earning only 10-20 runestones per prestige, making the upgrade shop feel out of reach. This boost helps mid-game without affecting the cap behaviour (cbrt formula still prevents AFK windfalls). --- apps/api/src/services/prestige.ts | 2 +- apps/api/test/services/prestige.spec.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index f20c8c1..4eb62ab 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -15,7 +15,7 @@ import type { } from "@elysium/types"; const basePrestigeGoldThreshold = 1_000_000; -const runestonesPerPrestigeLevel = 10; +const runestonesPerPrestigeLevel = 15; const milestoneInterval = 5; const milestoneRunestonesPerInterval = 25; diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 1638781..977cc65 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -102,21 +102,21 @@ describe("isEligibleForPrestige", () => { describe("calculateRunestones", () => { it("calculates basic runestones formula", () => { - // floor(cbrt(4_000_000 / 1_000_000)) × 10 = floor(cbrt(4)) × 10 = 1 × 10 = 10 + // 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(10); + expect(result).toBe(15); }); it("applies echo runestone multiplier", () => { - // floor(cbrt(4)) × 10 = 10; × 2 = 20 + // floor(cbrt(4)) × 15 = 15; × 2 = 30 const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 }); - expect(result).toBe(20); + expect(result).toBe(30); }); it("applies purchased runestone upgrade multiplier", () => { - // With "runestone_gain_1" purchased (multiplier 1.25): floor(10 × 1.25) = 12 + // 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).toBe(12); + expect(result).toBe(18); }); it("caps base runestones before multipliers", () => { -- 2.52.0 From 7d1126e8ad13cf0cf471f74c5b8de38272208b4a Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:48:34 -0700 Subject: [PATCH 30/53] fix: guarantee clicks challenge in daily set (#167) Players blocked on zone progression had days where all three daily challenges (bossesDefeated, questsCompleted, prestige) required progression they couldn't make. Always including a clicks challenge ensures at least one challenge is completable regardless of where the player is in the game. --- apps/api/src/services/dailyChallenges.ts | 12 +++++++----- apps/api/test/services/dailyChallenges.spec.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) 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/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); }); }); -- 2.52.0 From 19f5f5e54f253e175793da7b99d012bbe202cc3a Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 13:19:54 -0700 Subject: [PATCH 31/53] feat: show projected runestone gain persistently in resource bar Adds computeProjectedRunestones() to the shared tick engine using the correct server-side formula (cbrt, (count+1)^2 threshold). The resource bar now shows a persistent '+N On Prestige' row so players can always see what they would earn. The prestige panel's own preview was also fixed to use the shared helper, replacing a broken local calculation that used sqrt and the wrong threshold formula. Closes #168 --- .../web/src/components/game/prestigePanel.tsx | 42 ++++--------------- apps/web/src/components/ui/resourceBar.tsx | 10 +++++ apps/web/src/engine/tick.ts | 30 +++++++++++++ 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index e12d331..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", @@ -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 { diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index 1743a6f..18ffbf7 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -15,6 +15,7 @@ import { computeEssencePerSecond, computeGoldPerSecond, computePartyCombatPower, + computeProjectedRunestones, } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; @@ -89,10 +90,12 @@ const ResourceBar = ({ let partyCombatPower = 0; let goldPerSecond = 0; let essencePerSecond = 0; + let projectedRunestones = 0; if (state !== null) { partyCombatPower = computePartyCombatPower(state); goldPerSecond = computeGoldPerSecond(state); essencePerSecond = computeEssencePerSecond(state); + projectedRunestones = computeProjectedRunestones(state); } let avatarUrl: string | null = null; @@ -234,6 +237,13 @@ const ResourceBar = ({ {"Runestones"}
+
+ {"⭐"} + + {`+${formatNumber(projectedRunestones)}`} + + {"On Prestige"} +
{"⚔️"} diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index ada6501..1b17ec1 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -447,6 +447,36 @@ export const computePartyCombatPower = (state: GameState): number => { * companionCombatMult; }; +const basePrestigeThreshold = 1_000_000; +const runestonesPerPrestigeLevelClient = 15; +const maxBaseRunestones = 200; + +/** + * Computes the projected runestone reward if the player were to prestige right now. + * Mirrors the server-side calculateRunestones formula exactly. + * @param state - The current game state. + * @returns The number of runestones the player would earn from a prestige now. + */ +export const computeProjectedRunestones = (state: GameState): number => { + const { count, purchasedUpgradeIds } = state.prestige; + const threshold = basePrestigeThreshold * Math.pow(count + 1, 2); + const base = Math.min( + Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold)) + * runestonesPerPrestigeLevelClient, + maxBaseRunestones, + ); + const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1") + ? 1.25 + : 1; + const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2") + ? 1.5 + : 1; + const runestoneMult = gain1Mult * gain2Mult; + /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- optional chained game state field */ + const echoMult: number = state.transcendence?.echoRunestoneMultiplier ?? 1; + return Math.floor(base * runestoneMult * echoMult); +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. -- 2.52.0 From 87686a310fdd24e078d768860081493b910db03c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 13:20:01 -0700 Subject: [PATCH 32/53] feat: add opt-out toggle for prestige bot announcements Adds enablePrestigeAnnouncements to ProfileSettings (defaults to true). The prestige route now checks this setting before posting the Discord webhook, and the edit profile modal exposes a toggle in the Sounds & Notifications section so players can opt out. Closes #169 --- apps/api/src/routes/prestige.ts | 31 +++++++++++++------ apps/api/src/routes/profile.ts | 2 ++ apps/api/test/routes/prestige.spec.ts | 16 ++++++++-- .../src/components/game/editProfileModal.tsx | 21 +++++++++++++ .../types/src/interfaces/profileSettings.ts | 6 ++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index 2c651a8..f205411 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -147,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/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index a6dd7b4..5c6546a 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -7,7 +7,7 @@ import type { GameState } from "@elysium/types"; vi.mock("../../src/db/client.js", () => ({ prisma: { - player: { update: vi.fn() }, + player: { findUnique: vi.fn(), update: vi.fn() }, gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })); @@ -47,7 +47,7 @@ const makeState = (overrides: Partial = {}): GameState => ({ describe("prestige route", () => { let app: Hono; let prisma: { - player: { update: ReturnType }; + player: { findUnique: ReturnType; update: ReturnType }; gameState: { findUnique: ReturnType; update: ReturnType; updateMany: ReturnType }; }; @@ -128,6 +128,18 @@ describe("prestige route", () => { 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/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/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, -- 2.52.0 From 50b9883951e509b97018d154ea3778d7a52b34da Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 13:41:44 -0700 Subject: [PATCH 33/53] fix: show unlock hint on locked codex entries (#146) Locked codex entries previously showed only '???' with no indication of how to unlock them. Each entry now displays a hint generated from its sourceType and sourceId (e.g. 'Defeat Troll King', 'Complete: Shadow Mere'). Closes #146 --- apps/web/src/components/game/codexPanel.tsx | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) 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)} +

); } -- 2.52.0 From f83728df57f34dfd699f19f591113c0e9b4adbcf Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 14:41:09 -0700 Subject: [PATCH 34/53] balance: reduce quest and exploration durations (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quest max reduced from 168h to 24h (÷7 scaling). Exploration max reduced from 144h to 12h (÷12 scaling). Zone 1 quest durations restored to original short values (1–30min). All inline duration comments updated to reflect new values. --- apps/api/src/data/explorations.ts | 288 +++++++++++++++--------------- apps/api/src/data/quests.ts | 186 +++++++++---------- 2 files changed, 237 insertions(+), 237 deletions(-) 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/quests.ts b/apps/api/src/data/quests.ts index c3dc3f3..888861f 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -77,7 +77,7 @@ 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: [], @@ -94,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" ], @@ -111,7 +111,7 @@ 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" ], @@ -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: [], @@ -162,7 +162,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 200_000, description: "A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.", - durationSeconds: 150 * 60, + durationSeconds: 20 * 60, id: "glacier_tomb", name: "The Glacier Tomb", prerequisiteIds: [ "frozen_wastes" ], @@ -178,7 +178,7 @@ 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: [ "glacier_tomb" ], @@ -194,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" ], @@ -209,7 +209,7 @@ export const defaultQuests: Array = [ combatPowerRequired: 3_000_000, description: "Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.", - durationSeconds: 7 * 60 * 60, + durationSeconds: 1 * 60 * 60, id: "frozen_throne", name: "The Frozen Throne", prerequisiteIds: [ "storm_citadel" ], @@ -226,7 +226,7 @@ export const defaultQuests: Array = [ 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: [], @@ -243,7 +243,7 @@ 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" ], @@ -260,7 +260,7 @@ 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" ], @@ -276,7 +276,7 @@ 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" ], @@ -294,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: [], @@ -310,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" ], @@ -326,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" ], @@ -342,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" ], @@ -359,7 +359,7 @@ 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: [], @@ -375,7 +375,7 @@ 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" ], @@ -391,7 +391,7 @@ 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" ], @@ -408,7 +408,7 @@ 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" ], @@ -425,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: [], @@ -441,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" ], @@ -456,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" ], @@ -472,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" ], @@ -488,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" ], @@ -505,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" ], @@ -522,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: [], @@ -538,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" ], @@ -554,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" ], @@ -570,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" ], @@ -586,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" ], @@ -603,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" ], @@ -620,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: [], @@ -636,7 +636,7 @@ 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" ], @@ -652,7 +652,7 @@ 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" ], @@ -668,7 +668,7 @@ 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" ], @@ -684,7 +684,7 @@ 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" ], @@ -701,7 +701,7 @@ 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" ], @@ -718,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: [], @@ -734,7 +734,7 @@ 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" ], @@ -750,7 +750,7 @@ 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" ], @@ -766,7 +766,7 @@ 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" ], @@ -782,7 +782,7 @@ 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" ], @@ -799,7 +799,7 @@ 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" ], @@ -816,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: [], @@ -832,7 +832,7 @@ 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" ], @@ -848,7 +848,7 @@ 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" ], @@ -864,7 +864,7 @@ 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" ], @@ -880,7 +880,7 @@ 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" ], @@ -897,7 +897,7 @@ 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" ], @@ -914,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: [], @@ -930,7 +930,7 @@ 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" ], @@ -946,7 +946,7 @@ 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" ], @@ -962,7 +962,7 @@ 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" ], @@ -979,7 +979,7 @@ 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" ], @@ -995,7 +995,7 @@ 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" ], @@ -1012,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: [], @@ -1028,7 +1028,7 @@ 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" ], @@ -1044,7 +1044,7 @@ 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" ], @@ -1061,7 +1061,7 @@ 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" ], @@ -1077,7 +1077,7 @@ 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" ], @@ -1094,7 +1094,7 @@ 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" ], @@ -1111,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: [], @@ -1127,7 +1127,7 @@ 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" ], @@ -1143,7 +1143,7 @@ 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" ], @@ -1160,7 +1160,7 @@ 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" ], @@ -1177,7 +1177,7 @@ 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" ], @@ -1194,7 +1194,7 @@ 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" ], @@ -1211,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: [], @@ -1227,7 +1227,7 @@ 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" ], @@ -1243,7 +1243,7 @@ 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" ], @@ -1260,7 +1260,7 @@ 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" ], @@ -1277,7 +1277,7 @@ 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" ], @@ -1293,7 +1293,7 @@ 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" ], @@ -1311,7 +1311,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: [], @@ -1327,7 +1327,7 @@ 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" ], @@ -1343,7 +1343,7 @@ 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" ], @@ -1360,7 +1360,7 @@ 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" ], @@ -1376,7 +1376,7 @@ 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" ], @@ -1393,7 +1393,7 @@ 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" ], @@ -1411,7 +1411,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: [], @@ -1427,7 +1427,7 @@ 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" ], @@ -1443,7 +1443,7 @@ 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" ], @@ -1460,7 +1460,7 @@ 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" ], @@ -1476,7 +1476,7 @@ 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" ], @@ -1492,7 +1492,7 @@ 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" ], @@ -1509,7 +1509,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: [], @@ -1525,7 +1525,7 @@ 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" ], @@ -1541,7 +1541,7 @@ 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" ], @@ -1557,7 +1557,7 @@ 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" ], @@ -1573,7 +1573,7 @@ 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" ], @@ -1589,7 +1589,7 @@ 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" ], -- 2.52.0 From 9cff54cfcd3315dcb705349130a9e2409b5cf6ed Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 15:05:41 -0700 Subject: [PATCH 35/53] balance: smooth prestige income cliff, quadratic milestones, exponential combat scaling (#170, #171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce income_10 cost 30k→22.5k and income_11 80k→60k (25% cut each) to ease the late-prestige runestone cliff without collapsing the timeline. Change prestige milestone bonus from linear (n×25) to quadratic (n²×25) so high-prestige milestones feel meaningful (P100 = 10k stones). Replace linear prestige combat multiplier (1 + count×0.1) with exponential (4^count) in both the tick engine and server-side boss route. Without this the final boss (2×10^145 HP) was unreachable by ~112 orders of magnitude; base-4 makes it achievable around P190, consistent with the 6-month target. --- apps/api/src/data/prestigeUpgrades.ts | 4 ++-- apps/api/src/routes/boss.ts | 10 ++++++++-- apps/api/src/services/prestige.ts | 4 ++-- apps/api/test/services/prestige.spec.ts | 8 ++++---- apps/web/src/engine/tick.ts | 12 ++++++++---- 5 files changed, 24 insertions(+), 14 deletions(-) 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/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/services/prestige.ts b/apps/api/src/services/prestige.ts index 4eb62ab..28d82d9 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -159,7 +159,7 @@ const calculateProductionMultiplier = ( /** * 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. */ @@ -168,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => { return 0; } const milestoneNumber = prestigeCount / milestoneInterval; - return milestoneNumber * milestoneRunestonesPerInterval; + return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval; }; /** diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 977cc65..6b7e689 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -151,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/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 1b17ec1..5733027 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -84,6 +84,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. */ @@ -296,8 +302,7 @@ export const computeEffectiveAdventurerStats = ( const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; - // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear - const prestigeCombatMultiplier = 1 + state.prestige.count * 0.1; + const prestigeCombatMultiplier = Math.pow(PRESTIGE_COMBAT_BASE, state.prestige.count); const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1; const craftedGoldMultiplier @@ -378,8 +383,7 @@ export const computePartyCombatPower = (state: GameState): number => { } } - // 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(PRESTIGE_COMBAT_BASE, state.prestige.count); const equipmentCombatMultiplier = state.equipment. filter((item) => { -- 2.52.0 From 952e9d62990e09ae4b46f4e1ef799b737334b6a4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 15:10:33 -0700 Subject: [PATCH 36/53] balance: early crystal access and smoother production scaling (#173, #174) Give Troll King 5 crystals (was 0) to signal the crystal economy from the first boss kill, and halve crystal_focus cost from 100 to 50 so it is reachable within the first zone's boss chain (#173). Increase production multiplier base from 1.25 to 1.3 so each prestige provides more perceptible run-time reduction in the P1-P30 window where the treadmill effect was most pronounced (#174). --- apps/api/src/data/bosses.ts | 2 +- apps/api/src/data/upgrades.ts | 2 +- apps/api/src/services/prestige.ts | 4 ++-- apps/api/test/services/prestige.spec.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 11aeb93..aadeff0 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: diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 28e14eb..cf29305 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: diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 28d82d9..ac08e89 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -146,7 +146,7 @@ const calculateRunestones = (parameters: RunestoneParameters): number => { /** * Calculates the new prestige production multiplier. - * Formula: 1.25^prestigeCount — exponential scaling per prestige that eventually + * 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. @@ -154,7 +154,7 @@ const calculateRunestones = (parameters: RunestoneParameters): number => { const calculateProductionMultiplier = ( prestigeCount: number, ): number => { - return Math.pow(1.25, prestigeCount); + return Math.pow(1.3, prestigeCount); }; /** diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 6b7e689..2cedc49 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -131,12 +131,12 @@ describe("calculateProductionMultiplier", () => { expect(calculateProductionMultiplier(0)).toBe(1); }); - it("returns 1.25 at count 1", () => { - expect(calculateProductionMultiplier(1)).toBeCloseTo(1.25); + it("returns 1.3 at count 1", () => { + expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3); }); it("scales exponentially", () => { - expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.25, 10)); + expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10)); }); }); -- 2.52.0 From 4163137e648d928f4e1518655aacfd1e267d4e51 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 16:44:55 -0700 Subject: [PATCH 37/53] balance: compress Zone 14 boss HP to close unlock gap (#176) --- apps/api/src/data/bosses.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index aadeff0..0b4b89f 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -980,7 +980,7 @@ export const defaultBosses: Array = [ { bountyRunestones: 265, crystalReward: 3e31, - currentHp: 1e37, + 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,7 +988,7 @@ export const defaultBosses: Array = [ essenceReward: 1e34, goldReward: 1e38, id: "horizon_beast", - maxHp: 1e37, + maxHp: 2e35, name: "The Horizon Beast", prestigeRequirement: 8, status: "locked", @@ -998,7 +998,7 @@ export const defaultBosses: Array = [ { bountyRunestones: 350, crystalReward: 1e35, - currentHp: 5e40, + 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,7 +1006,7 @@ export const defaultBosses: Array = [ essenceReward: 5e37, goldReward: 5e41, id: "infinity_construct", - maxHp: 5e40, + maxHp: 5e37, name: "The Infinity Construct", prestigeRequirement: 8, status: "locked", @@ -1016,7 +1016,7 @@ export const defaultBosses: Array = [ { bountyRunestones: 465, crystalReward: 5e38, - currentHp: 2e44, + 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,7 +1024,7 @@ export const defaultBosses: Array = [ essenceReward: 2e41, goldReward: 2e45, id: "expanse_sovereign", - maxHp: 2e44, + maxHp: 3e39, name: "The Expanse Sovereign", prestigeRequirement: 9, status: "locked", -- 2.52.0 From 78b1c1ec1743e16ed1747b46188b6910ac640541 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 16:45:00 -0700 Subject: [PATCH 38/53] balance: boost crafted combat recipe multipliers (#177) --- apps/api/src/data/recipes.ts | 20 ++++++++++---------- apps/web/src/data/recipes.ts | 18 +++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/api/src/data/recipes.ts b/apps/api/src/data/recipes.ts index 5d03ff8..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.12 }, + 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", @@ -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", @@ -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", @@ -506,7 +506,7 @@ export const defaultRecipes: Array = [ zoneId: "the_absolute", }, { - bonus: { type: "combat_power", value: 1.4 }, + 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", @@ -546,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/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", -- 2.52.0 From 65c4a409ca89c256980e43330d172b0f65f2bc62 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 16:45:03 -0700 Subject: [PATCH 39/53] =?UTF-8?q?feat:=20extend=20quest=20content=20throug?= =?UTF-8?q?h=20endgame=20to=20cover=20P60=E2=80=93P160=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/data/quests.ts | 192 +++++++++++++++++++++++++++++ apps/api/test/routes/debug.spec.ts | 24 ++++ apps/web/src/engine/tick.ts | 6 +- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 888861f..57aa04f 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -1306,6 +1306,54 @@ export const defaultQuests: Array = [ 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: 8e54, 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: 3e59, 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: 1.5e64, type: "crystals" }, + ], + status: "locked", + zoneId: "reality_forge", + }, // ── Cosmic Maelstrom ────────────────────────────────────────────────────── { combatPowerRequired: 1.8e46, @@ -1406,6 +1454,54 @@ export const defaultQuests: Array = [ 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: 8e68, 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: 3e75, 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: 1.5e82, type: "crystals" }, + ], + status: "locked", + zoneId: "cosmic_maelstrom", + }, // ── Primeval Sanctum ────────────────────────────────────────────────────── { combatPowerRequired: 7.2e49, @@ -1504,6 +1600,54 @@ export const defaultQuests: Array = [ 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: 1e88, 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: 5e94, 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: 2.5e101, type: "crystals" }, + ], + status: "locked", + zoneId: "primeval_sanctum", + }, // ── The Absolute ────────────────────────────────────────────────────────── { combatPowerRequired: 3e53, @@ -1601,4 +1745,52 @@ export const defaultQuests: Array = [ 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: 1.5e110, 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: 7e116, 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: 3e123, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, ]; diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index a0fb33e..dcdcdfe 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -881,6 +881,30 @@ describe("debug route", () => { 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"], diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 5733027..06ceaa7 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -302,7 +302,7 @@ export const computeEffectiveAdventurerStats = ( const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1; - const prestigeCombatMultiplier = Math.pow(PRESTIGE_COMBAT_BASE, state.prestige.count); + const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1; const craftedGoldMultiplier @@ -383,7 +383,7 @@ export const computePartyCombatPower = (state: GameState): number => { } } - const prestigeMultiplier = Math.pow(PRESTIGE_COMBAT_BASE, state.prestige.count); + const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count; const equipmentCombatMultiplier = state.equipment. filter((item) => { @@ -477,7 +477,7 @@ export const computeProjectedRunestones = (state: GameState): number => { : 1; const runestoneMult = gain1Mult * gain2Mult; /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- optional chained game state field */ - const echoMult: number = state.transcendence?.echoRunestoneMultiplier ?? 1; + const echoMult: number = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; return Math.floor(base * runestoneMult * echoMult); }; -- 2.52.0 From 29e2766b31a0d23a2a14ca05b7d1cb2f1b131781 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 17:11:07 -0700 Subject: [PATCH 40/53] feat: add three final Zone 18 quests to close P183-P225 content drought (#178) --- apps/api/src/data/quests.ts | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 57aa04f..1a4ebc1 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -1793,4 +1793,52 @@ export const defaultQuests: Array = [ 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: 1.5e129, 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: 7e135, 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: 3e142, type: "crystals" }, + ], + status: "locked", + zoneId: "the_absolute", + }, ]; -- 2.52.0 From d20ae27fd23be8ff969b293e3b058ad70f1b1cad Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 17:19:29 -0700 Subject: [PATCH 41/53] balance: reduce echo meta upgrade costs for a more reasonable transcendence loop (#179) --- apps/api/src/data/transcendenceUpgrades.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/transcendenceUpgrades.ts b/apps/api/src/data/transcendenceUpgrades.ts index 8cfac04..dd06d45 100644 --- a/apps/api/src/data/transcendenceUpgrades.ts +++ b/apps/api/src/data/transcendenceUpgrades.ts @@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array = [ // ── Echo meta multipliers ─────────────────────────────────────────────────── { category: "echo_meta", - cost: 25, + 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: 75, + 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: 200, + cost: 100, description: "You have mastered the infinite spiral of transcendence, doubling all future echo yields.", id: "echo_meta_3", -- 2.52.0 From 34f2b250f3964d0b922740768cb4dab75f272344 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 17:42:37 -0700 Subject: [PATCH 42/53] fix: correct outdated achievement descriptions and quest count (#184, #186) - Update devourer_slayer description: no longer references "first six zones" - Update quest_eternal to require 122 quests (was 95; added 27 in #175 and #178) --- apps/api/src/data/achievements.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index 4122311..330a43c 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", @@ -289,8 +289,8 @@ export const defaultAchievements: Array = [ unlockedAt: null, }, { - condition: { amount: 95, type: "questsCompleted" }, - description: "Complete all 95 quests across the known multiverse.", + condition: { amount: 122, type: "questsCompleted" }, + description: "Complete all 122 quests across the known multiverse.", icon: "🌌", id: "quest_eternal", name: "Quest Eternal", -- 2.52.0 From 2827ddef7270a1013c4954082c04fe4a527f1366 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 17:42:54 -0700 Subject: [PATCH 43/53] feat: add endgame prestige and gold milestone achievements (#183, #185) - Add prestige milestones at P50/P100/P150/P200 (10k/25k/50k/100k crystals) - Add gold milestones at 1e30/1e60/1e90 (Cosmic Wealthy, Infinite Hoarder, Omniversal Tycoon) --- apps/api/src/data/achievements.ts | 63 +++++++++++++++++++++++++++++++ apps/web/src/engine/tick.ts | 4 +- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index 330a43c..1d70349 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -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" }, @@ -363,4 +390,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: { crystals: 10_000 }, + unlockedAt: null, + }, + { + condition: { amount: 100, type: "prestigeCount" }, + description: "Prestige 100 times.", + icon: "💎", + id: "prestige_eternal", + name: "Eternal Looper", + reward: { crystals: 25_000 }, + unlockedAt: null, + }, + { + condition: { amount: 150, type: "prestigeCount" }, + description: "Prestige 150 times.", + icon: "🌟", + id: "prestige_immortal", + name: "Immortal Cycler", + reward: { crystals: 50_000 }, + unlockedAt: null, + }, + { + condition: { amount: 200, type: "prestigeCount" }, + description: "Prestige 200 times.", + icon: "👑", + id: "prestige_absolute", + name: "Absolute Champion", + reward: { crystals: 100_000 }, + unlockedAt: null, + }, ]; diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 06ceaa7..db663ab 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -476,8 +476,8 @@ export const computeProjectedRunestones = (state: GameState): number => { ? 1.5 : 1; const runestoneMult = gain1Mult * gain2Mult; - /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- optional chained game state field */ - const echoMult: number = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; + const echoMult: number + = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; return Math.floor(base * runestoneMult * echoMult); }; -- 2.52.0 From ac42da4c3b58561e6f5335ad43130ea953fad747 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:00:06 -0700 Subject: [PATCH 44/53] balance: zero out crystal rewards for Zone 7+ bosses (#187) Crystals are an early-game currency. The total crystal sink is ~125M crystals (purchasable equipment), but Zone 7+ bosses were awarding up to 5e139 crystals with the crystal multipliers applied. Bosses in celestial_reaches and beyond now award 0 crystals, keeping the crystal economy meaningful in early-mid game only. --- apps/api/src/data/bosses.ts | 106 ++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 0b4b89f..fbc7011 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -360,7 +360,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 40, - crystalReward: 40_000, + crystalReward: 0, currentHp: 2_000_000_000, damagePerSecond: 120_000, description: @@ -378,7 +378,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 50, - crystalReward: 100_000, + crystalReward: 0, currentHp: 8_000_000_000, damagePerSecond: 350_000, description: @@ -396,7 +396,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 60, - crystalReward: 300_000, + crystalReward: 0, currentHp: 30_000_000_000, damagePerSecond: 1_000_000, description: @@ -414,7 +414,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 75, - crystalReward: 800_000, + crystalReward: 0, currentHp: 100_000_000_000, damagePerSecond: 3_000_000, description: @@ -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: @@ -451,7 +451,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 55, - crystalReward: 4_000_000, + crystalReward: 0, currentHp: 1_000_000_000_000, damagePerSecond: 15_000_000, description: @@ -469,7 +469,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 70, - crystalReward: 12_000_000, + crystalReward: 0, currentHp: 4_000_000_000_000, damagePerSecond: 50_000_000, description: @@ -487,7 +487,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 85, - crystalReward: 40_000_000, + crystalReward: 0, currentHp: 15_000_000_000_000, damagePerSecond: 150_000_000, description: @@ -505,7 +505,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 100, - crystalReward: 150_000_000, + crystalReward: 0, currentHp: 50_000_000_000_000, damagePerSecond: 500_000_000, description: @@ -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: @@ -542,7 +542,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 70, - crystalReward: 1_000_000_000, + crystalReward: 0, currentHp: 500_000_000_000_000, damagePerSecond: 2_500_000_000, description: @@ -560,7 +560,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 90, - crystalReward: 3_000_000_000, + crystalReward: 0, currentHp: 2_000_000_000_000_000, damagePerSecond: 8_000_000_000, description: @@ -578,7 +578,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 110, - crystalReward: 10_000_000_000, + crystalReward: 0, currentHp: 6_000_000_000_000_000, damagePerSecond: 25_000_000_000, description: @@ -596,7 +596,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 135, - crystalReward: 30_000_000_000, + crystalReward: 0, currentHp: 8_000_000_000_000_000, damagePerSecond: 80_000_000_000, description: @@ -615,7 +615,7 @@ export const defaultBosses: Array = [ // ── Crystalline Spire ───────────────────────────────────────────────────── { bountyRunestones: 70, - crystalReward: 8e10, + crystalReward: 0, currentHp: 2e16, damagePerSecond: 120_000_000_000, description: @@ -633,7 +633,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 90, - crystalReward: 3e11, + crystalReward: 0, currentHp: 8e16, damagePerSecond: 4e11, description: @@ -651,7 +651,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 115, - crystalReward: 1e12, + crystalReward: 0, currentHp: 3e17, damagePerSecond: 1.2e12, description: @@ -669,7 +669,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 140, - crystalReward: 4e12, + crystalReward: 0, currentHp: 1e18, damagePerSecond: 4e12, description: @@ -687,7 +687,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 175, - crystalReward: 1.5e13, + crystalReward: 0, currentHp: 4e18, damagePerSecond: 1.5e13, description: @@ -706,7 +706,7 @@ export const defaultBosses: Array = [ // ── Void Sanctum ────────────────────────────────────────────────────────── { bountyRunestones: 90, - crystalReward: 4e13, + crystalReward: 0, currentHp: 1e19, damagePerSecond: 4e13, description: @@ -724,7 +724,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 115, - crystalReward: 1.5e14, + crystalReward: 0, currentHp: 5e19, damagePerSecond: 1.5e14, description: @@ -742,7 +742,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 145, - crystalReward: 5e14, + crystalReward: 0, currentHp: 2e20, damagePerSecond: 5e14, description: @@ -760,7 +760,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 180, - crystalReward: 2e15, + crystalReward: 0, currentHp: 8e20, damagePerSecond: 2e15, description: @@ -778,7 +778,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 225, - crystalReward: 8e15, + crystalReward: 0, currentHp: 3e21, damagePerSecond: 8e15, description: @@ -797,7 +797,7 @@ export const defaultBosses: Array = [ // ── Eternal Throne ──────────────────────────────────────────────────────── { bountyRunestones: 115, - crystalReward: 2e16, + crystalReward: 0, currentHp: 1e22, damagePerSecond: 2e16, description: @@ -815,7 +815,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 150, - crystalReward: 8e16, + crystalReward: 0, currentHp: 5e22, damagePerSecond: 8e16, description: @@ -833,7 +833,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 190, - crystalReward: 3e17, + crystalReward: 0, currentHp: 2e23, damagePerSecond: 3e17, description: @@ -851,7 +851,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 235, - crystalReward: 1.2e18, + crystalReward: 0, currentHp: 8e23, damagePerSecond: 1.2e18, description: @@ -869,7 +869,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 295, - crystalReward: 5e18, + crystalReward: 0, currentHp: 3e24, damagePerSecond: 5e18, description: @@ -888,7 +888,7 @@ export const defaultBosses: Array = [ // ── Primordial Chaos ────────────────────────────────────────────────────── { bountyRunestones: 150, - crystalReward: 2e20, + crystalReward: 0, currentHp: 1e26, damagePerSecond: 2e20, description: @@ -906,7 +906,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 200, - crystalReward: 8e21, + crystalReward: 0, currentHp: 5e27, damagePerSecond: 8e21, description: @@ -924,7 +924,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 265, - crystalReward: 4e23, + crystalReward: 0, currentHp: 2e29, damagePerSecond: 4e23, description: @@ -942,7 +942,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 350, - crystalReward: 2e25, + crystalReward: 0, currentHp: 8e30, damagePerSecond: 2e25, description: @@ -961,7 +961,7 @@ export const defaultBosses: Array = [ // ── Infinite Expanse ────────────────────────────────────────────────────── { bountyRunestones: 200, - crystalReward: 8e27, + crystalReward: 0, currentHp: 3e33, damagePerSecond: 8e27, description: @@ -979,7 +979,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 265, - crystalReward: 3e31, + crystalReward: 0, currentHp: 2e35, damagePerSecond: 3e31, description: @@ -997,7 +997,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 350, - crystalReward: 1e35, + crystalReward: 0, currentHp: 5e37, damagePerSecond: 1e35, description: @@ -1015,7 +1015,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 465, - crystalReward: 5e38, + crystalReward: 0, currentHp: 3e39, damagePerSecond: 5e38, description: @@ -1034,7 +1034,7 @@ export const defaultBosses: Array = [ // ── Reality Forge ───────────────────────────────────────────────────────── { bountyRunestones: 265, - crystalReward: 2e42, + crystalReward: 0, currentHp: 8e47, damagePerSecond: 2e42, description: @@ -1052,7 +1052,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 350, - crystalReward: 1e47, + crystalReward: 0, currentHp: 4e52, damagePerSecond: 1e47, description: @@ -1070,7 +1070,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 465, - crystalReward: 6e51, + crystalReward: 0, currentHp: 2e57, damagePerSecond: 6e51, description: @@ -1088,7 +1088,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 615, - crystalReward: 2e56, + crystalReward: 0, currentHp: 8e61, damagePerSecond: 2e56, description: @@ -1107,7 +1107,7 @@ export const defaultBosses: Array = [ // ── Cosmic Maelstrom ────────────────────────────────────────────────────── { bountyRunestones: 350, - crystalReward: 1e60, + crystalReward: 0, currentHp: 4e65, damagePerSecond: 1e60, description: @@ -1125,7 +1125,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 465, - crystalReward: 6e65, + crystalReward: 0, currentHp: 2e71, damagePerSecond: 6e65, description: @@ -1143,7 +1143,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 615, - crystalReward: 3e71, + crystalReward: 0, currentHp: 1e77, damagePerSecond: 3e71, description: @@ -1161,7 +1161,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 815, - crystalReward: 1e77, + crystalReward: 0, currentHp: 5e82, damagePerSecond: 1e77, description: @@ -1180,7 +1180,7 @@ export const defaultBosses: Array = [ // ── Primeval Sanctum ────────────────────────────────────────────────────── { bountyRunestones: 465, - crystalReward: 5e82, + crystalReward: 0, currentHp: 2e88, damagePerSecond: 5e82, description: @@ -1198,7 +1198,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 615, - crystalReward: 3e89, + crystalReward: 0, currentHp: 1e95, damagePerSecond: 3e89, description: @@ -1216,7 +1216,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 815, - crystalReward: 2e96, + crystalReward: 0, currentHp: 8e101, damagePerSecond: 2e96, description: @@ -1234,7 +1234,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 1080, - crystalReward: 1e103, + crystalReward: 0, currentHp: 5e108, damagePerSecond: 1e103, description: @@ -1253,7 +1253,7 @@ export const defaultBosses: Array = [ // ── The Absolute ────────────────────────────────────────────────────────── { bountyRunestones: 615, - crystalReward: 5e110, + crystalReward: 0, currentHp: 2e116, damagePerSecond: 5e110, description: @@ -1271,7 +1271,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 815, - crystalReward: 3e119, + crystalReward: 0, currentHp: 1e125, damagePerSecond: 3e119, description: @@ -1289,7 +1289,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 1080, - crystalReward: 1e129, + crystalReward: 0, currentHp: 5e134, damagePerSecond: 1e129, description: @@ -1307,7 +1307,7 @@ export const defaultBosses: Array = [ }, { bountyRunestones: 1430, - crystalReward: 5e139, + crystalReward: 0, currentHp: 2e145, damagePerSecond: 5e139, description: -- 2.52.0 From 218a150540afed18ba70698857ea67dc78f885bb Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:00:16 -0700 Subject: [PATCH 45/53] feat: add missing achievement milestones and fix reward types (#188, #189, #190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add quest_hero milestone at 75 quests (closes gap between 50 and 122) - Add boss_legend milestone at 50 bosses (closes gap between 30 and 72) - Replace crystal rewards on P50/P100/P150/P200 prestige achievements with runestones (100/500/2000/10000) — crystals become worthless by the time these are earned, runestones remain meaningful throughout --- apps/api/src/data/achievements.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index 1d70349..bbc7693 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -315,6 +315,15 @@ export const defaultAchievements: Array = [ reward: { crystals: 5000 }, unlockedAt: null, }, + { + condition: { amount: 75, type: "questsCompleted" }, + description: "Complete 75 quests.", + icon: "🌠", + id: "quest_hero", + name: "Quest Hero", + reward: { crystals: 10_000 }, + unlockedAt: null, + }, { condition: { amount: 122, type: "questsCompleted" }, description: "Complete all 122 quests across the known multiverse.", @@ -343,6 +352,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.", @@ -396,7 +414,7 @@ export const defaultAchievements: Array = [ icon: "✨", id: "prestige_transcendent", name: "Transcendent", - reward: { crystals: 10_000 }, + reward: { runestones: 100 }, unlockedAt: null, }, { @@ -405,7 +423,7 @@ export const defaultAchievements: Array = [ icon: "💎", id: "prestige_eternal", name: "Eternal Looper", - reward: { crystals: 25_000 }, + reward: { runestones: 500 }, unlockedAt: null, }, { @@ -414,7 +432,7 @@ export const defaultAchievements: Array = [ icon: "🌟", id: "prestige_immortal", name: "Immortal Cycler", - reward: { crystals: 50_000 }, + reward: { runestones: 2000 }, unlockedAt: null, }, { @@ -423,7 +441,7 @@ export const defaultAchievements: Array = [ icon: "👑", id: "prestige_absolute", name: "Absolute Champion", - reward: { crystals: 100_000 }, + reward: { runestones: 10_000 }, unlockedAt: null, }, ]; -- 2.52.0 From 010b4ea1dad7db0c7eb9022a71c4b941a109f745 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:00:21 -0700 Subject: [PATCH 46/53] feat: support runestone rewards in achievement system (#190) - Add runestones field to AchievementReward type - Update tick engine to accumulate and apply runestone rewards when achievements unlock, alongside the existing crystal rewards --- apps/web/src/engine/tick.ts | 33 +++++++++++--------- packages/types/src/interfaces/achievement.ts | 3 +- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index db663ab..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, @@ -818,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 { -- 2.52.0 From 63210a1e556ba0709e1c1dba2039559e0bf65c59 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:20:44 -0700 Subject: [PATCH 47/53] fix: zero crystal rewards for zone 9+ quests Closes #191 --- apps/api/src/data/quests.ts | 130 ++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 1a4ebc1..dc526f7 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -643,7 +643,7 @@ export const defaultQuests: Array = [ 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", @@ -659,7 +659,7 @@ export const defaultQuests: Array = [ 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", @@ -675,7 +675,7 @@ export const defaultQuests: Array = [ 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", @@ -691,7 +691,7 @@ export const defaultQuests: Array = [ 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", @@ -708,7 +708,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e17, type: "gold" }, { amount: 6e13, type: "essence" }, - { amount: 8e10, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infernal_court", @@ -741,7 +741,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e18, type: "gold" }, { amount: 8e14, type: "essence" }, - { amount: 3e12, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -757,7 +757,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 8e18, type: "gold" }, { amount: 3e15, type: "essence" }, - { amount: 1e13, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -773,7 +773,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 3e19, type: "gold" }, { amount: 1e16, type: "essence" }, - { amount: 4e13, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -789,7 +789,7 @@ export const defaultQuests: Array = [ 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", @@ -806,7 +806,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e20, type: "gold" }, { amount: 1.5e17, type: "essence" }, - { amount: 5e14, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "crystalline_spire", @@ -839,7 +839,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5e21, type: "gold" }, { amount: 2e18, type: "essence" }, - { amount: 2e15, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -855,7 +855,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e22, type: "gold" }, { amount: 8e18, type: "essence" }, - { amount: 8e15, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -871,7 +871,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 8e22, type: "gold" }, { amount: 3e19, type: "essence" }, - { amount: 3e16, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -887,7 +887,7 @@ export const defaultQuests: Array = [ 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", @@ -904,7 +904,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e24, type: "gold" }, { amount: 4e20, type: "essence" }, - { amount: 4e17, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "void_sanctum", @@ -937,7 +937,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e25, type: "gold" }, { amount: 4e21, type: "essence" }, - { amount: 1.5e18, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -953,7 +953,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e25, type: "gold" }, { amount: 1.5e22, type: "essence" }, - { amount: 5e18, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -969,7 +969,7 @@ export const defaultQuests: Array = [ 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", @@ -986,7 +986,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 6e26, type: "gold" }, { amount: 2.5e23, type: "essence" }, - { amount: 8e19, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -1002,7 +1002,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 3e27, type: "gold" }, { amount: 1e24, type: "essence" }, - { amount: 4e20, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "eternal_throne", @@ -1035,7 +1035,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e28, type: "gold" }, { amount: 1.5e25, type: "essence" }, - { amount: 5e21, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primordial_chaos", @@ -1051,7 +1051,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e29, type: "gold" }, { amount: 8e25, type: "essence" }, - { amount: 2e22, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "titan_warrior", type: "adventurer" }, ], status: "locked", @@ -1068,7 +1068,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e30, type: "gold" }, { amount: 4e26, type: "essence" }, - { amount: 8e22, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primordial_chaos", @@ -1084,7 +1084,7 @@ export const defaultQuests: Array = [ 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", @@ -1101,7 +1101,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 3e31, type: "gold" }, { amount: 1e28, type: "essence" }, - { amount: 2e24, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primordial_chaos", @@ -1134,7 +1134,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 6e34, type: "gold" }, { amount: 2e31, type: "essence" }, - { amount: 5e27, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infinite_expanse", @@ -1150,7 +1150,7 @@ export const defaultQuests: Array = [ 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", @@ -1167,7 +1167,7 @@ export const defaultQuests: Array = [ 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", @@ -1184,7 +1184,7 @@ export const defaultQuests: Array = [ 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", @@ -1201,7 +1201,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e41, type: "gold" }, { amount: 1.2e38, type: "essence" }, - { amount: 2.5e34, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "infinite_expanse", @@ -1234,7 +1234,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e46, type: "gold" }, { amount: 3e42, type: "essence" }, - { amount: 2e38, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1250,7 +1250,7 @@ export const defaultQuests: Array = [ 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", @@ -1267,7 +1267,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2.5e49, type: "gold" }, { amount: 8e45, type: "essence" }, - { amount: 5e41, type: "crystals" }, + { amount: 0, type: "crystals" }, { targetId: "primordial_mage_1", type: "upgrade" }, ], status: "locked", @@ -1284,7 +1284,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1.2e51, type: "gold" }, { amount: 4e47, type: "essence" }, - { amount: 2.5e43, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1300,7 +1300,7 @@ export const defaultQuests: Array = [ 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", @@ -1317,7 +1317,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5e62, type: "gold" }, { amount: 1.5e59, type: "essence" }, - { amount: 8e54, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1333,7 +1333,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e67, type: "gold" }, { amount: 6e63, type: "essence" }, - { amount: 3e59, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1349,7 +1349,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e72, type: "gold" }, { amount: 3e68, type: "essence" }, - { amount: 1.5e64, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "reality_forge", @@ -1382,7 +1382,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1.5e58, type: "gold" }, { amount: 5e54, type: "essence" }, - { amount: 3e50, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1398,7 +1398,7 @@ export const defaultQuests: Array = [ 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", @@ -1415,7 +1415,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e63, type: "gold" }, { amount: 1.2e60, type: "essence" }, - { amount: 7e55, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1431,7 +1431,7 @@ export const defaultQuests: Array = [ 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", @@ -1448,7 +1448,7 @@ export const defaultQuests: Array = [ 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", @@ -1465,7 +1465,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5e76, type: "gold" }, { amount: 1.5e73, type: "essence" }, - { amount: 8e68, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1481,7 +1481,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e83, type: "gold" }, { amount: 6e79, type: "essence" }, - { amount: 3e75, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1497,7 +1497,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e90, type: "gold" }, { amount: 3e86, type: "essence" }, - { amount: 1.5e82, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "cosmic_maelstrom", @@ -1530,7 +1530,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2.5e76, type: "gold" }, { amount: 7e72, type: "essence" }, - { amount: 4e68, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1546,7 +1546,7 @@ export const defaultQuests: Array = [ 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", @@ -1563,7 +1563,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 6e83, type: "gold" }, { amount: 1.8e80, type: "essence" }, - { amount: 1e76, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1579,7 +1579,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 3e87, type: "gold" }, { amount: 9e83, type: "essence" }, - { amount: 5e79, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1595,7 +1595,7 @@ export const defaultQuests: Array = [ 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", @@ -1611,7 +1611,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 8e95, type: "gold" }, { amount: 2.5e92, type: "essence" }, - { amount: 1e88, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1627,7 +1627,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e102, type: "gold" }, { amount: 1.2e99, type: "essence" }, - { amount: 5e94, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1643,7 +1643,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e109, type: "gold" }, { amount: 6e105, type: "essence" }, - { amount: 2.5e101, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "primeval_sanctum", @@ -1676,7 +1676,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 4e101, type: "gold" }, { amount: 1.2e98, type: "essence" }, - { amount: 6e93, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1692,7 +1692,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e108, type: "gold" }, { amount: 6e104, type: "essence" }, - { amount: 3e100, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1708,7 +1708,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e115, type: "gold" }, { amount: 3e111, type: "essence" }, - { amount: 1.5e107, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1724,7 +1724,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5e121, type: "gold" }, { amount: 1.5e118, type: "essence" }, - { amount: 7e113, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1740,7 +1740,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 3e130, type: "gold" }, { amount: 9e126, type: "essence" }, - { amount: 4e122, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1756,7 +1756,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e118, type: "gold" }, { amount: 3e114, type: "essence" }, - { amount: 1.5e110, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1772,7 +1772,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5e124, type: "gold" }, { amount: 1.5e121, type: "essence" }, - { amount: 7e116, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1788,7 +1788,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e131, type: "gold" }, { amount: 6e127, type: "essence" }, - { amount: 3e123, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1804,7 +1804,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 1e137, type: "gold" }, { amount: 3e133, type: "essence" }, - { amount: 1.5e129, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1820,7 +1820,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 5e143, type: "gold" }, { amount: 1.5e140, type: "essence" }, - { amount: 7e135, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", @@ -1836,7 +1836,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2e150, type: "gold" }, { amount: 6e146, type: "essence" }, - { amount: 3e142, type: "crystals" }, + { amount: 0, type: "crystals" }, ], status: "locked", zoneId: "the_absolute", -- 2.52.0 From bdc5849c935968b18681d114e67a75c29a1f3e8a Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:20:46 -0700 Subject: [PATCH 48/53] feat: add quest legend achievement at 100 quests Closes #192 --- apps/api/src/data/achievements.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index bbc7693..95efa1b 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -324,6 +324,15 @@ export const defaultAchievements: Array = [ 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: 122, type: "questsCompleted" }, description: "Complete all 122 quests across the known multiverse.", -- 2.52.0 From 912a7414b4ad7e68d2c2001d6096e781f439e267 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:37:49 -0700 Subject: [PATCH 49/53] fix: correct sunken temple quest reward regression Closes #193 --- apps/api/src/data/quests.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index dc526f7..5058d60 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -265,9 +265,9 @@ export const defaultQuests: Array = [ name: "The Sunken Temple", prerequisiteIds: [ "witch_coven" ], rewards: [ - { amount: 2_000_000, type: "gold" }, - { amount: 1500, type: "essence" }, - { amount: 75, type: "crystals" }, + { amount: 60_000_000, type: "gold" }, + { amount: 25_000, type: "essence" }, + { amount: 400, type: "crystals" }, ], status: "locked", zoneId: "shadow_marshes", -- 2.52.0 From c7b88061d0c157b23598e1418d5709a55abb0bbf Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:37:52 -0700 Subject: [PATCH 50/53] fix: increase essence guild multiplier to 2x Closes #194 --- apps/api/src/data/upgrades.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index cf29305..afbd696 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -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.", -- 2.52.0 From c4717492f6da653c92989ebb8031e48693a34cc0 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:50:18 -0700 Subject: [PATCH 51/53] fix: buff eternal prism stats to justify 100M crystal cost Closes #196 --- apps/api/src/data/equipment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index 5e37bcf..8011dc3 100644 --- a/apps/api/src/data/equipment.ts +++ b/apps/api/src/data/equipment.ts @@ -757,7 +757,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.", -- 2.52.0 From c74cd6b898454294f57d0b2dca15920a30b02b86 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:56:18 -0700 Subject: [PATCH 52/53] fix: correct quest_eternal count to 112 and update fully_equipped to 78 Closes #197 --- apps/api/src/data/achievements.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index 95efa1b..b40976d 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -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", @@ -334,8 +334,8 @@ export const defaultAchievements: Array = [ unlockedAt: null, }, { - condition: { amount: 122, type: "questsCompleted" }, - description: "Complete all 122 quests across the known multiverse.", + condition: { amount: 112, type: "questsCompleted" }, + description: "Complete all 112 quests across the known multiverse.", icon: "🌌", id: "quest_eternal", name: "Quest Eternal", -- 2.52.0 From f3f4f70b7cbd57462f63b42096cdde2ab0823881 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 18:56:21 -0700 Subject: [PATCH 53/53] feat: add 13 missing late-game equipment items for zones 15-18 Closes #198 --- apps/api/src/data/equipment.ts | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts index 8011dc3..abbe4c0 100644 --- a/apps/api/src/data/equipment.ts +++ b/apps/api/src/data/equipment.ts @@ -695,6 +695,168 @@ 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: 4.25 }, -- 2.52.0