1 Commits

Author SHA1 Message Date
hikari eec93e442b chore: add vampire expansion implementation TODO 2026-03-24 19:51:59 -07:00
34 changed files with 707 additions and 1349 deletions
+135
View File
@@ -0,0 +1,135 @@
# Vampire Expansion — Implementation TODO
Branch: `feat/expansions`
Thematic currency names:
- Gold → **Blood**
- Essence → **Ichor**
- Crystals → **Soul Shards**
- Runestones → **Bloodstones**
- Echoes → **Whispers**
- Click action → **Hunt**
- Adventurers → **Thralls**
- Prestige → **Siring** (working name)
- Transcendence → **The Awakening** (working name)
- Apotheosis → **Eternal Sovereignty** (role ID: 1486144657023959180)
CDN prefix for all vampire art: `https://cdn.nhcarrigan.com/elysium/vampire/<folder>/<id>.jpg`
Local scratch dir (delete before committing): `img/vampire/`
---
## Phase 1 — Types
- [ ] Add `VampireExpansionState` interface to `packages/types/src/interfaces/` mirroring full `GameState` structure (zones, bosses, quests, adventurers, upgrades, equipment, achievements, prestige, transcendence, apotheosis, exploration, resources, baseClickPower, lastTickAt, dailyChallenges, codex, autoQuest, autoBoss, autoAdventurer, companions, story)
- [ ] Add `ExpansionsState` interface: `{ vampire?: VampireExpansionState }`
- [ ] Add `expansions?: ExpansionsState` field to `GameState`
- [ ] Export new types from `packages/types/src/index.ts`
---
## Phase 2 — Data files (vampire content)
All data files go in `apps/api/src/data/vampire/`.
Same content scale as base game; use vampire theming throughout.
- [ ] `zones.ts` — 18 vampire-themed zones (crypts, blood forests, cursed castles, etc.)
- [ ] `bosses.ts` — 72 vampire-themed bosses (4 per zone)
- [ ] `quests.ts` — match base game quest count (~95); vampire-themed names/descriptions
- [ ] `adventurers.ts` — 32 thrall tiers with progressive stats
- [ ] `upgrades.ts` — match base game upgrade count (~57); vampire-themed
- [ ] `equipment.ts` — match base game equipment count (~53); vampire-themed sets
- [ ] `equipmentSets.ts` — vampire equipment sets
- [ ] `achievements.ts` — match base game count (~40); vampire-themed conditions
- [ ] `explorations.ts` — 72 areas across 18 vampire lore zones
- [ ] `materials.ts` — match base game material count (~54); vampire-themed
- [ ] `recipes.ts` — match base game recipe count (~36); vampire-themed
- [ ] `prestigeUpgrades.ts` — 25 "Siring" upgrades
- [ ] `transcendenceUpgrades.ts` — 15 "Awakening" upgrades
- [ ] `dailyChallenges.ts` — 10 vampire daily challenges
- [ ] `initialState.ts``initialVampireState()` function mirroring `initialGameState` structure
---
## Phase 3 — Art generation & CDN upload
For each category below, generate images via Gemini API (`gemini-3-pro-image-preview`),
save locally to `img/vampire/<folder>/`, upload to R2, then delete local files.
Use soft-shaded anime style; vampire/gothic aesthetic; crimson/black/dark purple palette.
- [ ] Zone banners (18) → `img/vampire/zones/` → CDN `vampire/zones/`
- [ ] Boss portraits (72) → `img/vampire/bosses/` → CDN `vampire/bosses/`
- [ ] Quest banners (match count) → `img/vampire/quests/` → CDN `vampire/quests/`
- [ ] Adventurer/thrall portraits (32) → `img/vampire/adventurers/` → CDN `vampire/adventurers/`
- [ ] Equipment icons (match count) → `img/vampire/equipment/` → CDN `vampire/equipment/`
- [ ] Achievement icons (match count) → `img/vampire/achievements/` → CDN `vampire/achievements/`
- [ ] Exploration area art (72) → `img/vampire/explorations/` → CDN `vampire/explorations/`
- [ ] Material icons (match count) → `img/vampire/materials/` → CDN `vampire/materials/`
- [ ] Story chapter banners (match count) → `img/vampire/story-chapters/` → CDN `vampire/story-chapters/`
---
## Phase 4 — API changes
- [ ] Add `inGuild` to Prisma `Player` model → update `initialGameState` if needed (already done in #134 — verify migration)
- [ ] Update Prisma schema: no DB changes needed (expansion state is inside the `GameState` JSON blob)
- [ ] Update `initialState.ts` to include `expansions: {}` in `initialGameState`
- [ ] Update `sync-new-content` debug route to inject/patch vampire expansion content when expansion is unlocked
- [ ] Add vampire-specific unlock trigger: when base-game apotheosis count ≥ 1, set `expansions.vampire` to `initialVampireState()` and `unlocked: true`
- [ ] Update the load endpoint to pass expansion state through to the client
- [ ] Ensure prestige/transcendence/apotheosis routes only reset state for their own expansion (base game routes must NOT touch `expansions.*`)
- [ ] Add vampire prestige, transcendence, and apotheosis routes (mirrors of base game routes, scoped to `expansions.vampire`)
- [ ] Grant `Eternal Sovereignty` role (ID: `1486144657023959180`) on vampire apotheosis
---
## Phase 5 — Frontend changes
### Expansion switcher
- [ ] Add expansion toggle buttons below the Early Access warning in the sidebar
- [ ] Always render all expansion buttons; disable any where `unlocked !== true`
- [ ] Active expansion stored in React state (not game state); defaults to `"base"`
- [ ] Switching expansion updates which data the UI panels display
### Resource bar
- [ ] Show ALL currencies from ALL expansions as separate labelled lines
- [ ] Vampire currencies use distinct icons/colours (crimson tint for blood, etc.)
- [ ] The "expand" button label shows the gold-equivalent currency of the active expansion
### Thematic UI
- [ ] When vampire expansion is active, swap labels: gold → Blood, essence → Ichor, etc.
- [ ] Apply `.vampire-mode` CSS class to game container when vampire is active
- [ ] Vampire colour palette: deep crimsons (`#5C0A1A`), rich crimson (`#C41E3A`), blacks, desaturated purples
### Tick engine
- [ ] Update `apps/web/src/engine/tick.ts` to compute passive income for all unlocked expansions every tick (not just base game)
- [ ] Offline income calculation must also cover all expansions
### Profile
- [ ] Profile panel: tab stats by expansion (base game tab + one tab per unlocked expansion)
- [ ] Show correct thematic prestige/transcendence/apotheosis badge names per expansion
- [ ] Lifetime stats (gold earned, clicks, etc.) tracked separately per expansion
### About / How to Play
- [ ] Update `aboutPanel.tsx` `HOW_TO_PLAY` array to document the expansion system
---
## Phase 6 — Tests & CI
- [ ] Unit tests for all new data files (at minimum, validate structure/required fields)
- [ ] Unit tests for `initialVampireState()`
- [ ] Tests for vampire unlock trigger route
- [ ] Tests for vampire prestige/transcendence/apotheosis routes
- [ ] Tests for updated tick engine (expansion income)
- [ ] Maintain 100% coverage on `apps/api` and `packages/types`
- [ ] Full pipeline: lint → build → test passing before PR
---
## Phase 7 — Final
- [ ] Delete `img/vampire/` directory before committing
- [ ] Update `MEMORY.md` with new content counts
- [ ] Open PR → request review
+6 -6
View File
@@ -26,7 +26,7 @@ export const defaultAdventurers: Array<Adventurer> = [
combatPower: 3,
count: 0,
essencePerSecond: 0,
goldPerSecond: 0.7,
goldPerSecond: 0.5,
id: "militia",
level: 2,
name: "Militia",
@@ -129,7 +129,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 2_850_000_000,
baseCost: 2_600_000_000,
class: "mage",
combatPower: 13_000,
count: 0,
@@ -141,7 +141,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 13_500_000_000,
baseCost: 11_000_000_000,
class: "rogue",
combatPower: 28_000,
count: 0,
@@ -153,7 +153,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 64_000_000_000,
baseCost: 47_000_000_000,
class: "paladin",
combatPower: 60_000,
count: 0,
@@ -165,7 +165,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 300_000_000_000,
baseCost: 200_000_000_000,
class: "rogue",
combatPower: 130_000,
count: 0,
@@ -177,7 +177,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 1_800_000_000_000,
baseCost: 1_400_000_000_000,
class: "paladin",
combatPower: 400_000,
count: 0,
+71 -71
View File
@@ -12,7 +12,7 @@ export const defaultBosses: Array<Boss> = [
// ── Verdant Vale ──────────────────────────────────────────────────────────
{
bountyRunestones: 1,
crystalReward: 5,
crystalReward: 0,
currentHp: 1000,
damagePerSecond: 5,
description:
@@ -122,7 +122,7 @@ export const defaultBosses: Array<Boss> = [
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
bountyRunestones: 20,
crystalReward: 1500,
crystalReward: 700,
currentHp: 6_000_000,
damagePerSecond: 1200,
description:
@@ -140,7 +140,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 25,
crystalReward: 3000,
crystalReward: 1500,
currentHp: 12_000_000,
damagePerSecond: 2400,
description:
@@ -158,7 +158,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 30,
crystalReward: 6000,
crystalReward: 3000,
currentHp: 20_000_000,
damagePerSecond: 4000,
description:
@@ -226,7 +226,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Titan",
prestigeRequirement: 0,
status: "locked",
upgradeRewards: [ "dark_templar_1" ],
upgradeRewards: [],
zoneId: "frozen_peaks",
},
// ── Volcanic Depths ───────────────────────────────────────────────────────
@@ -353,7 +353,7 @@ export const defaultBosses: Array<Boss> = [
id: "seraph_guardian",
maxHp: 500_000_000,
name: "The Seraph Guardian",
prestigeRequirement: 1,
prestigeRequirement: 6,
status: "locked",
upgradeRewards: [ "click_4" ],
zoneId: "celestial_reaches",
@@ -371,7 +371,7 @@ export const defaultBosses: Array<Boss> = [
id: "fallen_archangel",
maxHp: 2_000_000_000,
name: "The Fallen Archangel",
prestigeRequirement: 2,
prestigeRequirement: 7,
status: "locked",
upgradeRewards: [],
zoneId: "celestial_reaches",
@@ -389,7 +389,7 @@ export const defaultBosses: Array<Boss> = [
id: "divine_judge",
maxHp: 8_000_000_000,
name: "The Divine Judge",
prestigeRequirement: 2,
prestigeRequirement: 8,
status: "locked",
upgradeRewards: [ "divine_covenant" ],
zoneId: "celestial_reaches",
@@ -407,7 +407,7 @@ export const defaultBosses: Array<Boss> = [
id: "celestial_titan",
maxHp: 30_000_000_000,
name: "The Celestial Titan",
prestigeRequirement: 2,
prestigeRequirement: 9,
status: "locked",
upgradeRewards: [],
zoneId: "celestial_reaches",
@@ -425,7 +425,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_first_light",
maxHp: 100_000_000_000,
name: "The First Light",
prestigeRequirement: 2,
prestigeRequirement: 10,
status: "locked",
upgradeRewards: [],
zoneId: "celestial_reaches",
@@ -444,7 +444,7 @@ export const defaultBosses: Array<Boss> = [
id: "depth_leviathan",
maxHp: 250_000_000_000,
name: "The Depth Leviathan",
prestigeRequirement: 2,
prestigeRequirement: 9,
status: "locked",
upgradeRewards: [],
zoneId: "abyssal_trench",
@@ -462,7 +462,7 @@ export const defaultBosses: Array<Boss> = [
id: "kraken_elder",
maxHp: 1_000_000_000_000,
name: "The Elder Kraken",
prestigeRequirement: 2,
prestigeRequirement: 10,
status: "locked",
upgradeRewards: [ "abyssal_pact" ],
zoneId: "abyssal_trench",
@@ -480,7 +480,7 @@ export const defaultBosses: Array<Boss> = [
id: "abyssal_colossus",
maxHp: 4_000_000_000_000,
name: "The Abyssal Colossus",
prestigeRequirement: 2,
prestigeRequirement: 11,
status: "locked",
upgradeRewards: [],
zoneId: "abyssal_trench",
@@ -498,7 +498,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_deep_one",
maxHp: 15_000_000_000_000,
name: "The Deep One",
prestigeRequirement: 3,
prestigeRequirement: 12,
status: "locked",
upgradeRewards: [ "global_4" ],
zoneId: "abyssal_trench",
@@ -516,7 +516,7 @@ export const defaultBosses: Array<Boss> = [
id: "elder_abomination",
maxHp: 50_000_000_000_000,
name: "The Elder Abomination",
prestigeRequirement: 3,
prestigeRequirement: 13,
status: "locked",
upgradeRewards: [],
zoneId: "abyssal_trench",
@@ -535,7 +535,7 @@ export const defaultBosses: Array<Boss> = [
id: "demon_prince",
maxHp: 120_000_000_000_000,
name: "The Demon Prince",
prestigeRequirement: 3,
prestigeRequirement: 12,
status: "locked",
upgradeRewards: [],
zoneId: "infernal_court",
@@ -553,7 +553,7 @@ export const defaultBosses: Array<Boss> = [
id: "hellfire_titan",
maxHp: 500_000_000_000_000,
name: "The Hellfire Titan",
prestigeRequirement: 3,
prestigeRequirement: 13,
status: "locked",
upgradeRewards: [ "celestial_mandate" ],
zoneId: "infernal_court",
@@ -571,7 +571,7 @@ export const defaultBosses: Array<Boss> = [
id: "lord_of_sin",
maxHp: 2_000_000_000_000_000,
name: "The Lord of Sin",
prestigeRequirement: 3,
prestigeRequirement: 14,
status: "locked",
upgradeRewards: [],
zoneId: "infernal_court",
@@ -589,7 +589,7 @@ export const defaultBosses: Array<Boss> = [
id: "infernal_sovereign",
maxHp: 6_000_000_000_000_000,
name: "The Infernal Sovereign",
prestigeRequirement: 3,
prestigeRequirement: 15,
status: "locked",
upgradeRewards: [ "click_5" ],
zoneId: "infernal_court",
@@ -607,7 +607,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_fallen",
maxHp: 8_000_000_000_000_000,
name: "The Fallen",
prestigeRequirement: 4,
prestigeRequirement: 16,
status: "locked",
upgradeRewards: [],
zoneId: "infernal_court",
@@ -626,9 +626,9 @@ export const defaultBosses: Array<Boss> = [
id: "prism_golem",
maxHp: 2e16,
name: "The Prism Golem",
prestigeRequirement: 3,
prestigeRequirement: 15,
status: "locked",
upgradeRewards: [ "crystal_sage_1" ],
upgradeRewards: [],
zoneId: "crystalline_spire",
},
{
@@ -644,7 +644,7 @@ export const defaultBosses: Array<Boss> = [
id: "crystal_drake",
maxHp: 8e16,
name: "The Crystal Drake",
prestigeRequirement: 4,
prestigeRequirement: 16,
status: "locked",
upgradeRewards: [ "void_ascendancy" ],
zoneId: "crystalline_spire",
@@ -662,9 +662,9 @@ export const defaultBosses: Array<Boss> = [
id: "the_faceted",
maxHp: 3e17,
name: "The Faceted",
prestigeRequirement: 4,
prestigeRequirement: 17,
status: "locked",
upgradeRewards: [ "void_sentinel_1" ],
upgradeRewards: [],
zoneId: "crystalline_spire",
},
{
@@ -680,9 +680,9 @@ export const defaultBosses: Array<Boss> = [
id: "diamond_colossus",
maxHp: 1e18,
name: "The Diamond Colossus",
prestigeRequirement: 4,
prestigeRequirement: 18,
status: "locked",
upgradeRewards: [ "eternal_champion_1" ],
upgradeRewards: [],
zoneId: "crystalline_spire",
},
{
@@ -698,9 +698,9 @@ export const defaultBosses: Array<Boss> = [
id: "crystal_sovereign",
maxHp: 4e18,
name: "The Crystal Sovereign",
prestigeRequirement: 4,
prestigeRequirement: 19,
status: "locked",
upgradeRewards: [ "cosmos_knight_1" ],
upgradeRewards: [],
zoneId: "crystalline_spire",
},
// ── Void Sanctum ──────────────────────────────────────────────────────────
@@ -717,9 +717,9 @@ export const defaultBosses: Array<Boss> = [
id: "void_herald",
maxHp: 1e19,
name: "The Void Herald",
prestigeRequirement: 4,
prestigeRequirement: 18,
status: "locked",
upgradeRewards: [ "seraph_knight_1" ],
upgradeRewards: [],
zoneId: "void_sanctum",
},
{
@@ -735,7 +735,7 @@ export const defaultBosses: Array<Boss> = [
id: "eternal_shade",
maxHp: 5e19,
name: "The Eternal Shade",
prestigeRequirement: 4,
prestigeRequirement: 19,
status: "locked",
upgradeRewards: [ "divine_harmony" ],
zoneId: "void_sanctum",
@@ -753,9 +753,9 @@ export const defaultBosses: Array<Boss> = [
id: "the_unmaker",
maxHp: 2e20,
name: "The Unmaker",
prestigeRequirement: 5,
prestigeRequirement: 20,
status: "locked",
upgradeRewards: [ "abyss_diver_1" ],
upgradeRewards: [],
zoneId: "void_sanctum",
},
{
@@ -771,7 +771,7 @@ export const defaultBosses: Array<Boss> = [
id: "void_progenitor",
maxHp: 8e20,
name: "The Void Progenitor",
prestigeRequirement: 5,
prestigeRequirement: 21,
status: "locked",
upgradeRewards: [],
zoneId: "void_sanctum",
@@ -789,9 +789,9 @@ export const defaultBosses: Array<Boss> = [
id: "void_emperor",
maxHp: 3e21,
name: "The Void Emperor",
prestigeRequirement: 5,
prestigeRequirement: 22,
status: "locked",
upgradeRewards: [ "infernal_warden_1" ],
upgradeRewards: [],
zoneId: "void_sanctum",
},
// ── Eternal Throne ────────────────────────────────────────────────────────
@@ -808,9 +808,9 @@ export const defaultBosses: Array<Boss> = [
id: "throne_warden",
maxHp: 1e22,
name: "The Throne Warden",
prestigeRequirement: 5,
prestigeRequirement: 21,
status: "locked",
upgradeRewards: [ "infinity_ranger_1" ],
upgradeRewards: [],
zoneId: "eternal_throne",
},
{
@@ -826,7 +826,7 @@ export const defaultBosses: Array<Boss> = [
id: "eternal_knight",
maxHp: 5e22,
name: "The Eternal Knight",
prestigeRequirement: 5,
prestigeRequirement: 22,
status: "locked",
upgradeRewards: [ "infernal_fury" ],
zoneId: "eternal_throne",
@@ -844,9 +844,9 @@ export const defaultBosses: Array<Boss> = [
id: "the_undying",
maxHp: 2e23,
name: "The Undying",
prestigeRequirement: 5,
prestigeRequirement: 23,
status: "locked",
upgradeRewards: [ "reality_warden_1" ],
upgradeRewards: [],
zoneId: "eternal_throne",
},
{
@@ -862,7 +862,7 @@ export const defaultBosses: Array<Boss> = [
id: "apex_sovereign",
maxHp: 8e23,
name: "The Apex Sovereign",
prestigeRequirement: 5,
prestigeRequirement: 24,
status: "locked",
upgradeRewards: [],
zoneId: "eternal_throne",
@@ -880,7 +880,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_apex",
maxHp: 3e24,
name: "The Apex",
prestigeRequirement: 6,
prestigeRequirement: 25,
status: "locked",
upgradeRewards: [],
zoneId: "eternal_throne",
@@ -899,7 +899,7 @@ export const defaultBosses: Array<Boss> = [
id: "chaos_wyrm",
maxHp: 1e26,
name: "The Chaos Wyrm",
prestigeRequirement: 6,
prestigeRequirement: 26,
status: "locked",
upgradeRewards: [],
zoneId: "primordial_chaos",
@@ -917,7 +917,7 @@ export const defaultBosses: Array<Boss> = [
id: "creation_engine",
maxHp: 5e27,
name: "The Creation Engine",
prestigeRequirement: 6,
prestigeRequirement: 27,
status: "locked",
upgradeRewards: [ "aether_weaver_1" ],
zoneId: "primordial_chaos",
@@ -935,7 +935,7 @@ export const defaultBosses: Array<Boss> = [
id: "entropy_avatar",
maxHp: 2e29,
name: "The Entropy Avatar",
prestigeRequirement: 7,
prestigeRequirement: 29,
status: "locked",
upgradeRewards: [],
zoneId: "primordial_chaos",
@@ -953,7 +953,7 @@ export const defaultBosses: Array<Boss> = [
id: "primordial_titan",
maxHp: 8e30,
name: "The Primordial Titan",
prestigeRequirement: 7,
prestigeRequirement: 31,
status: "locked",
upgradeRewards: [],
zoneId: "primordial_chaos",
@@ -972,7 +972,7 @@ export const defaultBosses: Array<Boss> = [
id: "expanse_drifter",
maxHp: 3e33,
name: "The Expanse Drifter",
prestigeRequirement: 8,
prestigeRequirement: 33,
status: "locked",
upgradeRewards: [ "titan_warrior_1" ],
zoneId: "infinite_expanse",
@@ -990,9 +990,9 @@ export const defaultBosses: Array<Boss> = [
id: "horizon_beast",
maxHp: 1e37,
name: "The Horizon Beast",
prestigeRequirement: 8,
prestigeRequirement: 35,
status: "locked",
upgradeRewards: [ "oblivion_paladin_1" ],
upgradeRewards: [],
zoneId: "infinite_expanse",
},
{
@@ -1008,7 +1008,7 @@ export const defaultBosses: Array<Boss> = [
id: "infinity_construct",
maxHp: 5e40,
name: "The Infinity Construct",
prestigeRequirement: 8,
prestigeRequirement: 37,
status: "locked",
upgradeRewards: [],
zoneId: "infinite_expanse",
@@ -1026,7 +1026,7 @@ export const defaultBosses: Array<Boss> = [
id: "expanse_sovereign",
maxHp: 2e44,
name: "The Expanse Sovereign",
prestigeRequirement: 9,
prestigeRequirement: 39,
status: "locked",
upgradeRewards: [],
zoneId: "infinite_expanse",
@@ -1045,7 +1045,7 @@ export const defaultBosses: Array<Boss> = [
id: "forge_guardian",
maxHp: 8e47,
name: "The Forge Guardian",
prestigeRequirement: 9,
prestigeRequirement: 41,
status: "locked",
upgradeRewards: [ "nexus_sage_1" ],
zoneId: "reality_forge",
@@ -1063,7 +1063,7 @@ export const defaultBosses: Array<Boss> = [
id: "reality_shaper",
maxHp: 4e52,
name: "The Reality Shaper",
prestigeRequirement: 10,
prestigeRequirement: 44,
status: "locked",
upgradeRewards: [],
zoneId: "reality_forge",
@@ -1081,7 +1081,7 @@ export const defaultBosses: Array<Boss> = [
id: "creation_prime",
maxHp: 2e57,
name: "The Creation Prime",
prestigeRequirement: 11,
prestigeRequirement: 47,
status: "locked",
upgradeRewards: [],
zoneId: "reality_forge",
@@ -1099,7 +1099,7 @@ export const defaultBosses: Array<Boss> = [
id: "reality_architect",
maxHp: 8e61,
name: "The Reality Architect",
prestigeRequirement: 11,
prestigeRequirement: 49,
status: "locked",
upgradeRewards: [],
zoneId: "reality_forge",
@@ -1118,7 +1118,7 @@ export const defaultBosses: Array<Boss> = [
id: "storm_colossus",
maxHp: 4e65,
name: "The Storm Colossus",
prestigeRequirement: 12,
prestigeRequirement: 51,
status: "locked",
upgradeRewards: [],
zoneId: "cosmic_maelstrom",
@@ -1136,7 +1136,7 @@ export const defaultBosses: Array<Boss> = [
id: "force_prime",
maxHp: 2e71,
name: "The Force Prime",
prestigeRequirement: 12,
prestigeRequirement: 54,
status: "locked",
upgradeRewards: [],
zoneId: "cosmic_maelstrom",
@@ -1154,9 +1154,9 @@ export const defaultBosses: Array<Boss> = [
id: "maelstrom_god",
maxHp: 1e77,
name: "The Maelstrom God",
prestigeRequirement: 13,
prestigeRequirement: 57,
status: "locked",
upgradeRewards: [ "transcendent_rogue_1" ],
upgradeRewards: [],
zoneId: "cosmic_maelstrom",
},
{
@@ -1172,7 +1172,7 @@ export const defaultBosses: Array<Boss> = [
id: "cosmic_annihilator",
maxHp: 5e82,
name: "The Cosmic Annihilator",
prestigeRequirement: 13,
prestigeRequirement: 59,
status: "locked",
upgradeRewards: [],
zoneId: "cosmic_maelstrom",
@@ -1191,7 +1191,7 @@ export const defaultBosses: Array<Boss> = [
id: "ancient_sentinel",
maxHp: 2e88,
name: "The Ancient Sentinel",
prestigeRequirement: 14,
prestigeRequirement: 61,
status: "locked",
upgradeRewards: [ "astral_sovereign_1" ],
zoneId: "primeval_sanctum",
@@ -1209,7 +1209,7 @@ export const defaultBosses: Array<Boss> = [
id: "time_elder",
maxHp: 1e95,
name: "The Time Elder",
prestigeRequirement: 15,
prestigeRequirement: 65,
status: "locked",
upgradeRewards: [],
zoneId: "primeval_sanctum",
@@ -1227,7 +1227,7 @@ export const defaultBosses: Array<Boss> = [
id: "origin_beast",
maxHp: 8e101,
name: "The Origin Beast",
prestigeRequirement: 16,
prestigeRequirement: 69,
status: "locked",
upgradeRewards: [],
zoneId: "primeval_sanctum",
@@ -1245,7 +1245,7 @@ export const defaultBosses: Array<Boss> = [
id: "primeval_god",
maxHp: 5e108,
name: "The Primeval God",
prestigeRequirement: 17,
prestigeRequirement: 74,
status: "locked",
upgradeRewards: [],
zoneId: "primeval_sanctum",
@@ -1264,7 +1264,7 @@ export const defaultBosses: Array<Boss> = [
id: "absolute_herald",
maxHp: 2e116,
name: "The Absolute Herald",
prestigeRequirement: 17,
prestigeRequirement: 76,
status: "locked",
upgradeRewards: [ "primordial_mage_1" ],
zoneId: "the_absolute",
@@ -1282,7 +1282,7 @@ export const defaultBosses: Array<Boss> = [
id: "void_convergence",
maxHp: 1e125,
name: "The Void Convergence",
prestigeRequirement: 18,
prestigeRequirement: 79,
status: "locked",
upgradeRewards: [],
zoneId: "the_absolute",
@@ -1300,9 +1300,9 @@ export const defaultBosses: Array<Boss> = [
id: "eternal_end",
maxHp: 5e134,
name: "The Eternal End",
prestigeRequirement: 19,
prestigeRequirement: 83,
status: "locked",
upgradeRewards: [ "omniversal_champion_1" ],
upgradeRewards: [],
zoneId: "the_absolute",
},
{
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_absolute_one",
maxHp: 2e145,
name: "The Absolute One",
prestigeRequirement: 20,
prestigeRequirement: 88,
status: "locked",
upgradeRewards: [],
zoneId: "the_absolute",
+6 -6
View File
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 },
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 },
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<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 },
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
description:
"The legendary stone that transmutes effort into wealth — every action fills the coffers.",
"The legendary stone that grants mastery over gold and combat alike.",
equipped: false,
id: "philosophers_stone",
name: "Philosopher's Stone",
@@ -697,7 +697,7 @@ export const defaultEquipment: Array<Equipment> = [
},
// ── Purchasable endgame sinks ─────────────────────────────────────────────
{
bonus: { clickMultiplier: 4.25 },
bonus: { clickMultiplier: 3 },
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<Equipment> = [
type: "armour",
},
{
bonus: { combatMultiplier: 10.5 },
bonus: { combatMultiplier: 7 },
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<Equipment> = [
type: "trinket",
},
{
bonus: { goldMultiplier: 7.5 },
bonus: { goldMultiplier: 4.75 },
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.",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10",
multiplier: 200,
name: "Eternal Rune I",
runestonesCost: 22_500,
runestonesCost: 30_000,
},
{
category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11",
multiplier: 500,
name: "Eternal Rune II",
runestonesCost: 60_000,
runestonesCost: 80_000,
},
// ── Click Power ───────────────────────────────────────────────────────────
{
+118 -155
View File
@@ -34,7 +34,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2000, type: "gold" },
{ amount: 5, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" },
],
status: "locked",
@@ -51,7 +50,6 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 10, type: "crystals" },
{ targetId: "global_1", type: "upgrade" },
{ targetId: "militia_1", type: "upgrade" },
{ targetId: "scout", type: "adventurer" },
],
status: "locked",
@@ -77,13 +75,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 500,
description:
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
durationSeconds: 5 * 60,
durationSeconds: 25 * 60,
id: "necromancer_tower",
name: "Necromancer's Tower",
prerequisiteIds: [],
rewards: [
{ amount: 15_000, type: "gold" },
{ amount: 20, type: "essence" },
{ targetId: "militia_1", type: "upgrade" },
{ targetId: "acolyte_1", type: "upgrade" },
{ targetId: "ranger", type: "adventurer" },
],
@@ -94,7 +93,7 @@ export const defaultQuests: Array<Quest> = [
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: 5 * 60,
durationSeconds: 45 * 60,
id: "crumbling_fortress",
name: "The Crumbling Fortress",
prerequisiteIds: [ "necromancer_tower" ],
@@ -111,13 +110,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8000,
description:
"A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.",
durationSeconds: 10 * 60,
durationSeconds: 60 * 60,
id: "cursed_library",
name: "The Cursed Library",
prerequisiteIds: [ "crumbling_fortress" ],
rewards: [
{ amount: 300, type: "essence" },
{ amount: 30, type: "crystals" },
{ targetId: "apprentice_1", type: "upgrade" },
{ targetId: "archmage", type: "adventurer" },
],
status: "locked",
@@ -127,7 +127,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 30_000,
description:
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.",
durationSeconds: 15 * 60,
durationSeconds: 90 * 60,
id: "dragon_lair",
name: "Dragon's Lair",
prerequisiteIds: [ "cursed_library" ],
@@ -145,7 +145,7 @@ export const defaultQuests: Array<Quest> = [
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: 15 * 60,
durationSeconds: 2 * 60 * 60,
id: "frozen_wastes",
name: "The Frozen Wastes",
prerequisiteIds: [],
@@ -153,23 +153,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 5_000_000, type: "gold" },
{ amount: 100, type: "crystals" },
{ targetId: "global_3", type: "upgrade" },
{ targetId: "knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "frozen_peaks",
},
{
combatPowerRequired: 200_000,
description:
"A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.",
durationSeconds: 20 * 60,
id: "glacier_tomb",
name: "The Glacier Tomb",
prerequisiteIds: [ "frozen_wastes" ],
rewards: [
{ amount: 10_000_000, type: "gold" },
{ amount: 3000, type: "essence" },
{ targetId: "peasant_2", type: "upgrade" },
],
status: "locked",
zoneId: "frozen_peaks",
@@ -178,10 +161,10 @@ export const defaultQuests: Array<Quest> = [
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: 25 * 60,
durationSeconds: 3 * 60 * 60,
id: "ice_caves",
name: "The Ice Caves",
prerequisiteIds: [ "glacier_tomb" ],
prerequisiteIds: [ "frozen_wastes" ],
rewards: [
{ amount: 5000, type: "essence" },
{ amount: 200, type: "crystals" },
@@ -194,7 +177,7 @@ export const defaultQuests: Array<Quest> = [
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: 45 * 60,
durationSeconds: 5 * 60 * 60,
id: "storm_citadel",
name: "The Storm Citadel",
prerequisiteIds: [ "ice_caves" ],
@@ -205,36 +188,17 @@ export const defaultQuests: Array<Quest> = [
status: "locked",
zoneId: "frozen_peaks",
},
{
combatPowerRequired: 3_000_000,
description:
"Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.",
durationSeconds: 1 * 60 * 60,
id: "frozen_throne",
name: "The Frozen Throne",
prerequisiteIds: [ "storm_citadel" ],
rewards: [
{ amount: 60_000_000, type: "gold" },
{ amount: 25_000, type: "essence" },
{ amount: 400, type: "crystals" },
],
status: "locked",
zoneId: "frozen_peaks",
},
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 2_000_000,
combatPowerRequired: 5_000_000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 5 * 60,
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 5_000_000, type: "gold" },
{ amount: 5000, type: "essence" },
{ amount: 150, type: "crystals" },
{ targetId: "peasant_3", type: "upgrade" },
{ amount: 150, type: "essence" },
],
status: "locked",
zoneId: "shadow_marshes",
@@ -243,14 +207,12 @@ export const defaultQuests: Array<Quest> = [
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: 15 * 60,
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 20_000_000, type: "gold" },
{ amount: 20_000, type: "essence" },
{ amount: 500, type: "crystals" },
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
@@ -260,7 +222,7 @@ export const defaultQuests: Array<Quest> = [
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: 15 * 60,
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
@@ -268,6 +230,8 @@ export const defaultQuests: Array<Quest> = [
{ 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",
@@ -276,14 +240,14 @@ export const defaultQuests: Array<Quest> = [
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: 25 * 60,
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 100_000_000, type: "gold" },
{ amount: 30_000, type: "essence" },
{ amount: 500, type: "crystals" },
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
{ targetId: "dark_templar", type: "adventurer" },
],
status: "locked",
@@ -294,7 +258,7 @@ export const defaultQuests: Array<Quest> = [
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: 25 * 60,
durationSeconds: 3 * 60 * 60,
id: "lava_flows",
name: "The Lava Flows",
prerequisiteIds: [],
@@ -310,7 +274,7 @@ export const defaultQuests: Array<Quest> = [
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: 45 * 60,
durationSeconds: 5 * 60 * 60,
id: "fire_temple",
name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ],
@@ -318,6 +282,7 @@ export const defaultQuests: Array<Quest> = [
{ 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",
@@ -326,7 +291,7 @@ export const defaultQuests: Array<Quest> = [
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: 1 * 60 * 60,
durationSeconds: 7 * 60 * 60,
id: "magma_caverns",
name: "The Magma Caverns",
prerequisiteIds: [ "fire_temple" ],
@@ -342,7 +307,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 10 * 60 * 60,
id: "the_forge",
name: "The Primordial Forge",
prerequisiteIds: [ "magma_caverns" ],
@@ -359,14 +324,13 @@ export const defaultQuests: Array<Quest> = [
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: 35 * 60,
durationSeconds: 4 * 60 * 60,
id: "void_rift",
name: "Void Rift",
prerequisiteIds: [],
rewards: [
{ amount: 2_000_000_000, type: "gold" },
{ amount: 300_000, type: "essence" },
{ amount: 1000, type: "crystals" },
{ amount: 500, type: "crystals" },
{ amount: 5000, type: "essence" },
],
status: "locked",
zoneId: "astral_void",
@@ -375,14 +339,14 @@ export const defaultQuests: Array<Quest> = [
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: 1 * 60 * 60,
durationSeconds: 8 * 60 * 60,
id: "star_graveyard",
name: "The Star Graveyard",
prerequisiteIds: [ "void_rift" ],
rewards: [
{ amount: 8_000_000_000, type: "gold" },
{ amount: 800_000, type: "essence" },
{ amount: 3000, type: "crystals" },
{ amount: 1_000_000_000, type: "gold" },
{ amount: 100_000, type: "essence" },
{ amount: 1000, type: "crystals" },
],
status: "locked",
zoneId: "astral_void",
@@ -391,14 +355,13 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 12 * 60 * 60,
id: "between_worlds",
name: "Between Worlds",
prerequisiteIds: [ "star_graveyard" ],
rewards: [
{ amount: 25_000_000_000, type: "gold" },
{ amount: 2_000_000, type: "essence" },
{ amount: 8000, type: "crystals" },
{ amount: 250_000, type: "essence" },
{ amount: 2000, type: "crystals" },
{ targetId: "divine_champion", type: "adventurer" },
],
status: "locked",
@@ -408,14 +371,14 @@ export const defaultQuests: Array<Quest> = [
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: 210 * 60,
durationSeconds: 24 * 60 * 60,
id: "the_end",
name: "The End of All Things",
prerequisiteIds: [ "between_worlds" ],
rewards: [
{ amount: 80_000_000_000, type: "gold" },
{ amount: 5_000_000, type: "essence" },
{ amount: 20_000, type: "crystals" },
{ amount: 10_000_000_000, type: "gold" },
{ amount: 1_000_000, type: "essence" },
{ amount: 10_000, type: "crystals" },
],
status: "locked",
zoneId: "astral_void",
@@ -425,7 +388,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: Math.round(1.5 * 60 * 60),
id: "heavens_gate",
name: "The Heaven's Gate",
prerequisiteIds: [],
@@ -441,7 +404,7 @@ export const defaultQuests: Array<Quest> = [
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: 25 * 60,
durationSeconds: 3 * 60 * 60,
id: "angelic_choir",
name: "The Angelic Choir",
prerequisiteIds: [ "heavens_gate" ],
@@ -456,7 +419,7 @@ export const defaultQuests: Array<Quest> = [
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: 45 * 60,
durationSeconds: 5 * 60 * 60,
id: "divine_library",
name: "The Divine Library",
prerequisiteIds: [ "angelic_choir" ],
@@ -472,7 +435,7 @@ export const defaultQuests: Array<Quest> = [
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: 1 * 60 * 60,
durationSeconds: 8 * 60 * 60,
id: "cloud_citadel",
name: "The Cloud Citadel",
prerequisiteIds: [ "divine_library" ],
@@ -488,7 +451,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 12 * 60 * 60,
id: "trial_of_virtue",
name: "The Trial of Virtue",
prerequisiteIds: [ "cloud_citadel" ],
@@ -505,7 +468,7 @@ export const defaultQuests: Array<Quest> = [
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: 3 * 60 * 60,
durationSeconds: 20 * 60 * 60,
id: "celestial_archive",
name: "The Celestial Archive",
prerequisiteIds: [ "trial_of_virtue" ],
@@ -522,7 +485,7 @@ export const defaultQuests: Array<Quest> = [
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: 15 * 60,
durationSeconds: 2 * 60 * 60,
id: "the_dark_waters",
name: "The Dark Waters",
prerequisiteIds: [],
@@ -538,7 +501,7 @@ export const defaultQuests: Array<Quest> = [
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: 35 * 60,
durationSeconds: 4 * 60 * 60,
id: "bioluminescent_ruins",
name: "The Bioluminescent Ruins",
prerequisiteIds: [ "the_dark_waters" ],
@@ -554,7 +517,7 @@ export const defaultQuests: Array<Quest> = [
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: 1 * 60 * 60,
durationSeconds: 7 * 60 * 60,
id: "pressure_caves",
name: "The Pressure Caves",
prerequisiteIds: [ "bioluminescent_ruins" ],
@@ -570,7 +533,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 12 * 60 * 60,
id: "leviathan_graveyard",
name: "The Leviathan Graveyard",
prerequisiteIds: [ "pressure_caves" ],
@@ -586,7 +549,7 @@ export const defaultQuests: Array<Quest> = [
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: 150 * 60,
durationSeconds: 18 * 60 * 60,
id: "black_throne",
name: "The Black Throne",
prerequisiteIds: [ "leviathan_graveyard" ],
@@ -603,7 +566,7 @@ export const defaultQuests: Array<Quest> = [
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: 270 * 60,
durationSeconds: 30 * 60 * 60,
id: "abyssal_chronicle",
name: "The Abyssal Chronicle",
prerequisiteIds: [ "black_throne" ],
@@ -620,7 +583,7 @@ export const defaultQuests: Array<Quest> = [
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: 25 * 60,
durationSeconds: 3 * 60 * 60,
id: "brimstone_wastes",
name: "The Brimstone Wastes",
prerequisiteIds: [],
@@ -636,7 +599,7 @@ export const defaultQuests: Array<Quest> = [
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: 50 * 60,
durationSeconds: 6 * 60 * 60,
id: "pit_of_souls",
name: "The Pit of Souls",
prerequisiteIds: [ "brimstone_wastes" ],
@@ -652,7 +615,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 10 * 60 * 60,
id: "court_of_blood",
name: "The Court of Blood",
prerequisiteIds: [ "pit_of_souls" ],
@@ -668,7 +631,7 @@ export const defaultQuests: Array<Quest> = [
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: 150 * 60,
durationSeconds: 16 * 60 * 60,
id: "nine_hells",
name: "The Nine Hells",
prerequisiteIds: [ "court_of_blood" ],
@@ -684,7 +647,7 @@ export const defaultQuests: Array<Quest> = [
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: 210 * 60,
durationSeconds: 24 * 60 * 60,
id: "demon_forge",
name: "The Demon Forge",
prerequisiteIds: [ "nine_hells" ],
@@ -701,7 +664,7 @@ export const defaultQuests: Array<Quest> = [
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: 330 * 60,
durationSeconds: 40 * 60 * 60,
id: "infernal_codex",
name: "The Infernal Codex",
prerequisiteIds: [ "demon_forge" ],
@@ -718,7 +681,7 @@ export const defaultQuests: Array<Quest> = [
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: 35 * 60,
durationSeconds: 4 * 60 * 60,
id: "prism_gate",
name: "The Prism Gate",
prerequisiteIds: [],
@@ -734,7 +697,7 @@ export const defaultQuests: Array<Quest> = [
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: 1 * 60 * 60,
durationSeconds: 8 * 60 * 60,
id: "crystal_labyrinth",
name: "The Crystal Labyrinth",
prerequisiteIds: [ "prism_gate" ],
@@ -750,7 +713,7 @@ export const defaultQuests: Array<Quest> = [
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: 2 * 60 * 60,
durationSeconds: 14 * 60 * 60,
id: "faceted_realm",
name: "The Faceted Realm",
prerequisiteIds: [ "crystal_labyrinth" ],
@@ -766,7 +729,7 @@ export const defaultQuests: Array<Quest> = [
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: 3 * 60 * 60,
durationSeconds: 20 * 60 * 60,
id: "diamond_vault",
name: "The Diamond Vault",
prerequisiteIds: [ "faceted_realm" ],
@@ -782,7 +745,7 @@ export const defaultQuests: Array<Quest> = [
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: 270 * 60,
durationSeconds: 32 * 60 * 60,
id: "sovereign_spire",
name: "The Sovereign's Spire",
prerequisiteIds: [ "diamond_vault" ],
@@ -799,7 +762,7 @@ export const defaultQuests: Array<Quest> = [
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: 7 * 60 * 60,
durationSeconds: 50 * 60 * 60,
id: "the_prism_vault",
name: "The Prism Vault",
prerequisiteIds: [ "sovereign_spire" ],
@@ -816,7 +779,7 @@ export const defaultQuests: Array<Quest> = [
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: 50 * 60,
durationSeconds: 6 * 60 * 60,
id: "void_threshold",
name: "The Void Threshold",
prerequisiteIds: [],
@@ -832,7 +795,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 12 * 60 * 60,
id: "eternal_dark",
name: "The Eternal Dark",
prerequisiteIds: [ "void_threshold" ],
@@ -848,7 +811,7 @@ export const defaultQuests: Array<Quest> = [
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: 3 * 60 * 60,
durationSeconds: 20 * 60 * 60,
id: "sanctum_depths",
name: "The Sanctum Depths",
prerequisiteIds: [ "eternal_dark" ],
@@ -864,7 +827,7 @@ export const defaultQuests: Array<Quest> = [
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: 270 * 60,
durationSeconds: 30 * 60 * 60,
id: "unmaking_grounds",
name: "The Unmaking Grounds",
prerequisiteIds: [ "sanctum_depths" ],
@@ -880,7 +843,7 @@ export const defaultQuests: Array<Quest> = [
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: 7 * 60 * 60,
durationSeconds: 48 * 60 * 60,
id: "emperor_approach",
name: "The Emperor's Approach",
prerequisiteIds: [ "unmaking_grounds" ],
@@ -897,7 +860,7 @@ export const defaultQuests: Array<Quest> = [
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: 10 * 60 * 60,
durationSeconds: 72 * 60 * 60,
id: "heart_of_void",
name: "The Heart of the Void",
prerequisiteIds: [ "emperor_approach" ],
@@ -914,7 +877,7 @@ export const defaultQuests: Array<Quest> = [
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: 1 * 60 * 60,
durationSeconds: 8 * 60 * 60,
id: "throne_antechamber",
name: "The Throne Antechamber",
prerequisiteIds: [],
@@ -930,7 +893,7 @@ export const defaultQuests: Array<Quest> = [
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: 150 * 60,
durationSeconds: 16 * 60 * 60,
id: "eternal_gauntlet",
name: "The Eternal Gauntlet",
prerequisiteIds: [ "throne_antechamber" ],
@@ -946,7 +909,7 @@ export const defaultQuests: Array<Quest> = [
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: 4 * 60 * 60,
durationSeconds: 28 * 60 * 60,
id: "apex_trials",
name: "The Apex Trials",
prerequisiteIds: [ "eternal_gauntlet" ],
@@ -962,7 +925,7 @@ export const defaultQuests: Array<Quest> = [
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: 330 * 60,
durationSeconds: 40 * 60 * 60,
id: "sovereign_hall",
name: "The Sovereign's Hall",
prerequisiteIds: [ "apex_trials" ],
@@ -979,7 +942,7 @@ export const defaultQuests: Array<Quest> = [
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: 9 * 60 * 60,
durationSeconds: 60 * 60 * 60,
id: "the_final_ascent",
name: "The Final Ascent",
prerequisiteIds: [ "sovereign_hall" ],
@@ -995,7 +958,7 @@ export const defaultQuests: Array<Quest> = [
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: 14 * 60 * 60,
durationSeconds: 96 * 60 * 60,
id: "eternal_dominion",
name: "Eternal Dominion",
prerequisiteIds: [ "the_final_ascent" ],
@@ -1012,7 +975,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 10 * 60 * 60,
id: "chaos_entry",
name: "Into the Chaos",
prerequisiteIds: [],
@@ -1028,7 +991,7 @@ export const defaultQuests: Array<Quest> = [
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: 150 * 60,
durationSeconds: 18 * 60 * 60,
id: "chaos_currents",
name: "The Chaos Currents",
prerequisiteIds: [ "chaos_entry" ],
@@ -1044,7 +1007,7 @@ export const defaultQuests: Array<Quest> = [
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: 270 * 60,
durationSeconds: 30 * 60 * 60,
id: "unformed_wastes",
name: "The Unformed Wastes",
prerequisiteIds: [ "chaos_currents" ],
@@ -1061,7 +1024,7 @@ export const defaultQuests: Array<Quest> = [
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: 6 * 60 * 60,
durationSeconds: 45 * 60 * 60,
id: "potential_vaults",
name: "The Vaults of Potential",
prerequisiteIds: [ "unformed_wastes" ],
@@ -1077,7 +1040,7 @@ export const defaultQuests: Array<Quest> = [
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: 9 * 60 * 60,
durationSeconds: 65 * 60 * 60,
id: "creation_cradle",
name: "The Creation Cradle",
prerequisiteIds: [ "potential_vaults" ],
@@ -1094,7 +1057,7 @@ export const defaultQuests: Array<Quest> = [
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: 13 * 60 * 60,
durationSeconds: 90 * 60 * 60,
id: "chaos_chronicle",
name: "The Chaos Chronicle",
prerequisiteIds: [ "creation_cradle" ],
@@ -1111,7 +1074,7 @@ export const defaultQuests: Array<Quest> = [
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: 90 * 60,
durationSeconds: 12 * 60 * 60,
id: "first_horizon",
name: "The First Horizon",
prerequisiteIds: [],
@@ -1127,7 +1090,7 @@ export const defaultQuests: Array<Quest> = [
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: 3 * 60 * 60,
durationSeconds: 22 * 60 * 60,
id: "endless_sea",
name: "The Endless Sea",
prerequisiteIds: [ "first_horizon" ],
@@ -1143,7 +1106,7 @@ export const defaultQuests: Array<Quest> = [
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: 5 * 60 * 60,
durationSeconds: 36 * 60 * 60,
id: "expanse_ruins",
name: "The Expanse Ruins",
prerequisiteIds: [ "endless_sea" ],
@@ -1160,7 +1123,7 @@ export const defaultQuests: Array<Quest> = [
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: 8 * 60 * 60,
durationSeconds: 55 * 60 * 60,
id: "infinite_archive",
name: "The Infinite Archive",
prerequisiteIds: [ "expanse_ruins" ],
@@ -1177,7 +1140,7 @@ export const defaultQuests: Array<Quest> = [
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: 11 * 60 * 60,
durationSeconds: 80 * 60 * 60,
id: "paradox_plains",
name: "The Paradox Plains",
prerequisiteIds: [ "infinite_archive" ],
@@ -1185,7 +1148,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 8e39, type: "gold" },
{ amount: 2.5e36, type: "essence" },
{ amount: 5e32, type: "crystals" },
{ targetId: "cosmos_knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "infinite_expanse",
@@ -1194,7 +1156,7 @@ export const defaultQuests: Array<Quest> = [
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: 16 * 60 * 60,
durationSeconds: 110 * 60 * 60,
id: "expanse_codex",
name: "The Expanse Codex",
prerequisiteIds: [ "paradox_plains" ],
@@ -1211,7 +1173,7 @@ export const defaultQuests: Array<Quest> = [
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: 2 * 60 * 60,
durationSeconds: 14 * 60 * 60,
id: "forge_entrance",
name: "The Forge Entrance",
prerequisiteIds: [],
@@ -1227,7 +1189,7 @@ export const defaultQuests: Array<Quest> = [
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: 210 * 60,
durationSeconds: 25 * 60 * 60,
id: "blueprint_vault",
name: "The Blueprint Vault",
prerequisiteIds: [ "forge_entrance" ],
@@ -1243,7 +1205,7 @@ export const defaultQuests: Array<Quest> = [
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: 330 * 60,
durationSeconds: 40 * 60 * 60,
id: "creation_workshop",
name: "The Creation Workshop",
prerequisiteIds: [ "blueprint_vault" ],
@@ -1260,7 +1222,7 @@ export const defaultQuests: Array<Quest> = [
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: 9 * 60 * 60,
durationSeconds: 60 * 60 * 60,
id: "laws_engine",
name: "The Laws Engine",
prerequisiteIds: [ "creation_workshop" ],
@@ -1268,7 +1230,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2.5e49, type: "gold" },
{ amount: 8e45, type: "essence" },
{ amount: 5e41, type: "crystals" },
{ targetId: "primordial_mage_1", type: "upgrade" },
{ targetId: "cosmos_knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "reality_forge",
@@ -1277,7 +1239,7 @@ export const defaultQuests: Array<Quest> = [
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: 12 * 60 * 60,
durationSeconds: 85 * 60 * 60,
id: "forge_heart",
name: "The Forge Heart",
prerequisiteIds: [ "laws_engine" ],
@@ -1293,7 +1255,7 @@ export const defaultQuests: Array<Quest> = [
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: 17 * 60 * 60,
durationSeconds: 120 * 60 * 60,
id: "forge_chronicle",
name: "The Forge Chronicle",
prerequisiteIds: [ "forge_heart" ],
@@ -1301,7 +1263,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 6e52, type: "gold" },
{ amount: 2e49, type: "essence" },
{ amount: 1.2e45, type: "crystals" },
{ targetId: "astral_sovereign_1", type: "upgrade" },
],
status: "locked",
zoneId: "reality_forge",
@@ -1311,7 +1272,7 @@ export const defaultQuests: Array<Quest> = [
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: 150 * 60,
durationSeconds: 16 * 60 * 60,
id: "maelstrom_entry",
name: "The Maelstrom's Edge",
prerequisiteIds: [],
@@ -1327,7 +1288,7 @@ export const defaultQuests: Array<Quest> = [
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: 4 * 60 * 60,
durationSeconds: 28 * 60 * 60,
id: "force_nexus",
name: "The Force Nexus",
prerequisiteIds: [ "maelstrom_entry" ],
@@ -1343,7 +1304,7 @@ export const defaultQuests: Array<Quest> = [
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: 6 * 60 * 60,
durationSeconds: 45 * 60 * 60,
id: "storm_cauldron",
name: "The Storm Cauldron",
prerequisiteIds: [ "force_nexus" ],
@@ -1360,7 +1321,7 @@ export const defaultQuests: Array<Quest> = [
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: 9 * 60 * 60,
durationSeconds: 65 * 60 * 60,
id: "annihilation_fields",
name: "The Annihilation Fields",
prerequisiteIds: [ "storm_cauldron" ],
@@ -1368,6 +1329,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 4e63, type: "gold" },
{ amount: 1.2e60, type: "essence" },
{ amount: 7e55, type: "crystals" },
{ targetId: "astral_sovereign_1", type: "upgrade" },
],
status: "locked",
zoneId: "cosmic_maelstrom",
@@ -1376,7 +1338,7 @@ export const defaultQuests: Array<Quest> = [
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: 13 * 60 * 60,
durationSeconds: 90 * 60 * 60,
id: "convergence_point",
name: "The Convergence Point",
prerequisiteIds: [ "annihilation_fields" ],
@@ -1384,7 +1346,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2e66, type: "gold" },
{ amount: 6e62, type: "essence" },
{ amount: 3.5e58, type: "crystals" },
{ targetId: "reality_warden_1", type: "upgrade" },
],
status: "locked",
zoneId: "cosmic_maelstrom",
@@ -1393,7 +1354,7 @@ export const defaultQuests: Array<Quest> = [
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: 19 * 60 * 60,
durationSeconds: 130 * 60 * 60,
id: "maelstrom_codex",
name: "The Maelstrom Codex",
prerequisiteIds: [ "convergence_point" ],
@@ -1401,7 +1362,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 1e69, type: "gold" },
{ amount: 3e65, type: "essence" },
{ amount: 1.8e61, type: "crystals" },
{ targetId: "infinity_ranger_1", type: "upgrade" },
],
status: "locked",
zoneId: "cosmic_maelstrom",
@@ -1411,7 +1371,7 @@ export const defaultQuests: Array<Quest> = [
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: 150 * 60,
durationSeconds: 18 * 60 * 60,
id: "sanctum_gate",
name: "The Sanctum Gate",
prerequisiteIds: [],
@@ -1427,7 +1387,7 @@ export const defaultQuests: Array<Quest> = [
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: 270 * 60,
durationSeconds: 32 * 60 * 60,
id: "memory_vaults",
name: "The Memory Vaults",
prerequisiteIds: [ "sanctum_gate" ],
@@ -1443,7 +1403,7 @@ export const defaultQuests: Array<Quest> = [
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: 7 * 60 * 60,
durationSeconds: 50 * 60 * 60,
id: "origin_halls",
name: "The Origin Halls",
prerequisiteIds: [ "memory_vaults" ],
@@ -1460,7 +1420,7 @@ export const defaultQuests: Array<Quest> = [
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: 10 * 60 * 60,
durationSeconds: 72 * 60 * 60,
id: "first_light_hall",
name: "The Hall of First Light",
prerequisiteIds: [ "origin_halls" ],
@@ -1468,6 +1428,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 6e83, type: "gold" },
{ amount: 1.8e80, type: "essence" },
{ amount: 1e76, type: "crystals" },
{ targetId: "primordial_mage_1", type: "upgrade" },
],
status: "locked",
zoneId: "primeval_sanctum",
@@ -1476,7 +1437,7 @@ export const defaultQuests: Array<Quest> = [
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: 14 * 60 * 60,
durationSeconds: 100 * 60 * 60,
id: "before_time",
name: "Before Time",
prerequisiteIds: [ "first_light_hall" ],
@@ -1492,7 +1453,7 @@ export const defaultQuests: Array<Quest> = [
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: 21 * 60 * 60,
durationSeconds: 144 * 60 * 60,
id: "sanctum_chronicle",
name: "The Sanctum Chronicle",
prerequisiteIds: [ "before_time" ],
@@ -1509,7 +1470,7 @@ export const defaultQuests: Array<Quest> = [
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: 3 * 60 * 60,
durationSeconds: 20 * 60 * 60,
id: "absolute_threshold",
name: "The Absolute Threshold",
prerequisiteIds: [],
@@ -1525,7 +1486,7 @@ export const defaultQuests: Array<Quest> = [
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: 5 * 60 * 60,
durationSeconds: 36 * 60 * 60,
id: "nothing_wastes",
name: "The Nothing Wastes",
prerequisiteIds: [ "absolute_threshold" ],
@@ -1541,7 +1502,7 @@ export const defaultQuests: Array<Quest> = [
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: 8 * 60 * 60,
durationSeconds: 56 * 60 * 60,
id: "final_paradox",
name: "The Final Paradox",
prerequisiteIds: [ "nothing_wastes" ],
@@ -1549,6 +1510,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 2e108, type: "gold" },
{ amount: 6e104, type: "essence" },
{ amount: 3e100, type: "crystals" },
{ targetId: "reality_warden_1", type: "upgrade" },
],
status: "locked",
zoneId: "the_absolute",
@@ -1557,7 +1519,7 @@ export const defaultQuests: Array<Quest> = [
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: 11 * 60 * 60,
durationSeconds: 80 * 60 * 60,
id: "end_vault",
name: "The Vault of Ends",
prerequisiteIds: [ "final_paradox" ],
@@ -1573,7 +1535,7 @@ export const defaultQuests: Array<Quest> = [
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: 17 * 60 * 60,
durationSeconds: 120 * 60 * 60,
id: "terminal_approach",
name: "The Terminal Approach",
prerequisiteIds: [ "end_vault" ],
@@ -1581,6 +1543,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 5e121, type: "gold" },
{ amount: 1.5e118, type: "essence" },
{ amount: 7e113, type: "crystals" },
{ targetId: "infinity_ranger_1", type: "upgrade" },
],
status: "locked",
zoneId: "the_absolute",
@@ -1589,7 +1552,7 @@ export const defaultQuests: Array<Quest> = [
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: 24 * 60 * 60,
durationSeconds: 168 * 60 * 60,
id: "absolute_dominion",
name: "Absolute Dominion",
prerequisiteIds: [ "terminal_approach" ],
+3 -28
View File
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "verdant_vale",
},
{
bonus: { type: "combat_power", value: 1.12 },
bonus: { type: "combat_power", value: 1.08 },
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<CraftingRecipe> = [
zoneId: "frozen_peaks",
},
{
bonus: { type: "gold_income", value: 1.15 },
bonus: { type: "gold_income", value: 1.1 },
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<CraftingRecipe> = [
zoneId: "infernal_court",
},
{
bonus: { type: "essence_income", value: 1.2 },
bonus: { type: "essence_income", value: 1.15 },
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",
@@ -492,19 +492,6 @@ export const defaultRecipes: Array<CraftingRecipe> = [
],
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:
@@ -521,18 +508,6 @@ export const defaultRecipes: Array<CraftingRecipe> = [
},
// 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:
+15 -15
View File
@@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Income multipliers ──────────────────────────────────────────────────────
{
category: "income",
cost: 2,
cost: 5,
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<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 4,
cost: 10,
description:
"Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2",
@@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 8,
cost: 20,
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<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 16,
cost: 40,
description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4",
@@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 32,
cost: 80,
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<TranscendenceUpgrade> = [
// ── Combat multipliers ──────────────────────────────────────────────────────
{
category: "combat",
cost: 2,
cost: 5,
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<TranscendenceUpgrade> = [
},
{
category: "combat",
cost: 6,
cost: 15,
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<TranscendenceUpgrade> = [
},
{
category: "combat",
cost: 12,
cost: 35,
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<TranscendenceUpgrade> = [
// ── Prestige threshold reductions ──────────────────────────────────────────
{
category: "prestige_threshold",
cost: 3,
cost: 8,
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<TranscendenceUpgrade> = [
},
{
category: "prestige_threshold",
cost: 6,
cost: 20,
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<TranscendenceUpgrade> = [
// ── Prestige runestone multipliers ─────────────────────────────────────────
{
category: "prestige_runestones",
cost: 3,
cost: 8,
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<TranscendenceUpgrade> = [
},
{
category: "prestige_runestones",
cost: 6,
cost: 20,
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<TranscendenceUpgrade> = [
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
category: "echo_meta",
cost: 25,
cost: 50,
description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 75,
cost: 150,
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<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 200,
cost: 400,
description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3",
+1 -1
View File
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
{
costCrystals: 50,
costCrystals: 100,
costEssence: 0,
costGold: 0,
description:
+2 -8
View File
@@ -24,13 +24,6 @@ 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<HonoEnvironment>();
bossRouter.use("*", authMiddleware);
@@ -45,7 +38,8 @@ const calculatePartyStats = (
}
}
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count);
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
// Apply equipped weapon's combat bonus
// eslint-disable-next-line capitalized-comments -- v8 ignore
+7 -92
View File
@@ -642,14 +642,6 @@ 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;
@@ -657,9 +649,7 @@ const patchAdventurerStats = (state: GameState): number => {
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
savedAdventurer.level = defaultAdventurer.level;
savedAdventurer.name = defaultAdventurer.name;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -680,15 +670,6 @@ 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;
@@ -697,9 +678,7 @@ const patchQuestStats = (state: GameState): number => {
if (defaultQuest.combatPowerRequired !== undefined) {
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
}
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -710,7 +689,6 @@ 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;
@@ -721,20 +699,6 @@ 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;
@@ -746,9 +710,7 @@ const patchBossStats = (state: GameState): number => {
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -769,20 +731,12 @@ 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;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -793,7 +747,6 @@ 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;
@@ -804,15 +757,6 @@ 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;
@@ -823,9 +767,7 @@ const patchUpgradeStats = (state: GameState): number => {
savedUpgrade.costGold = defaultUpgrade.costGold;
savedUpgrade.costEssence = defaultUpgrade.costEssence;
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -836,7 +778,6 @@ 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;
@@ -847,18 +788,6 @@ 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;
@@ -870,9 +799,7 @@ const patchEquipmentStats = (state: GameState): number => {
if (defaultItem.setId !== undefined) {
savedItem.setId = defaultItem.setId;
}
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -893,16 +820,6 @@ 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;
@@ -910,9 +827,7 @@ const patchAchievementStats = (state: GameState): number => {
if (defaultAchievement.reward !== undefined) {
savedAchievement.reward = { ...defaultAchievement.reward };
}
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
+11 -35
View File
@@ -102,23 +102,12 @@ prestigeRouter.post("/", async(context) => {
}).length;
const now = Date.now();
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({
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId, updatedAt },
where: { discordId },
});
if (updateResult.count === 0) {
return context.json({ error: "Prestige already in progress" }, 409);
}
await prisma.player.update({
data: {
characterName: state.player.characterName,
@@ -147,30 +136,17 @@ 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,
const playerRecord = await prisma.player.findUnique({
select: { profileSettings: true },
where: { discordId },
prestige: prestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0,
});
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
const playerSettings = playerRecord?.profileSettings as
Record<string, unknown> | null | undefined;
const announcementsEnabled
= playerSettings?.enablePrestigeAnnouncements !== false;
if (announcementsEnabled) {
void postMilestoneWebhook(discordId, "prestige", {
// 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,
-2
View File
@@ -47,7 +47,6 @@ 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,
@@ -223,7 +222,6 @@ 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,
+5 -7
View File
@@ -71,7 +71,8 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result;
};
const progressionChallengeTypes: Array<DailyChallengeType> = [
const challengeTypes: Array<DailyChallengeType> = [
"clicks",
"bossesDefeated",
"questsCompleted",
"prestige",
@@ -79,8 +80,7 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
/**
* Generates 3 daily challenges for the given date string, deterministically.
* Always includes a "clicks" challenge (always completable regardless of
* progression), then picks 2 more from the remaining types.
* Picks one challenge from 3 different randomly-selected types.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects.
*/
@@ -88,10 +88,8 @@ const generateDailyChallenges = (
dateString: string,
): Array<DailyChallenge> => {
const seed = dateSeed(dateString);
const selectedTypes: Array<DailyChallengeType> = [
"clicks",
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2),
];
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
slice(0, 3);
return selectedTypes.map((type, index) => {
const templates = dailyChallengeTemplates.filter((template) => {
+13 -26
View File
@@ -15,21 +15,14 @@ import type {
} from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000;
const runestonesPerPrestigeLevel = 15;
const thresholdScaleFactor = 5;
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 ~1,125 per prestige.
*/
const maxBaseRunestones = 200;
/**
* Calculates the gold threshold required for the next prestige.
* Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 810
* then gets easier as the production multiplier overtakes it.
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
* @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 +33,7 @@ const calculatePrestigeThreshold = (
): number => {
return (
basePrestigeGoldThreshold
* Math.pow(prestigeCount + 1, 2)
* Math.pow(thresholdScaleFactor, prestigeCount)
* thresholdMultiplier
);
};
@@ -114,9 +107,7 @@ interface RunestoneParameters {
/**
* Calculates how many runestones the player earns from a prestige.
* 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.
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
* @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.
@@ -132,11 +123,9 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
echoRunestoneMultiplier = 1,
} = parameters;
const threshold = calculatePrestigeThreshold(prestigeCount);
const base = Math.min(
Math.floor(Math.cbrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel,
maxBaseRunestones,
);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel;
const runestoneMult = getCategoryMultiplier(
purchasedUpgradeIds,
"runestones",
@@ -146,20 +135,19 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
/**
* Calculates the new prestige production multiplier.
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
* @param prestigeCount - The new prestige count.
* @returns The production multiplier for the new prestige level.
*/
const calculateProductionMultiplier = (
prestigeCount: number,
): number => {
return Math.pow(1.3, prestigeCount);
return Math.pow(1.15, prestigeCount);
};
/**
* Returns the milestone runestone bonus for the given prestige count.
* Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param prestigeCount - The prestige count after the current prestige.
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
*/
@@ -168,7 +156,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
return 0;
}
const milestoneNumber = prestigeCount / milestoneInterval;
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval;
return milestoneNumber * milestoneRunestonesPerInterval;
};
/**
@@ -263,8 +251,7 @@ const buildPostPrestigeState = (
* Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset.
*/
autoAdventurer: currentState.autoAdventurer ?? false,
autoBoss: currentState.autoBoss ?? false,
autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
+1 -1
View File
@@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one";
/**
* Base constant used in the echo yield formula.
*/
const echoFormulaConstant = 224;
const echoFormulaConstant = 853;
const getCategoryMultiplier = (
purchasedIds: Array<string>,
-96
View File
@@ -595,18 +595,6 @@ 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"],
@@ -828,18 +816,6 @@ 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"],
@@ -869,18 +845,6 @@ 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"],
@@ -908,18 +872,6 @@ 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"],
@@ -949,18 +901,6 @@ 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"],
@@ -989,30 +929,6 @@ 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"],
@@ -1041,18 +957,6 @@ 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"],
+8 -28
View File
@@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { findUnique: vi.fn(), update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
player: { update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
@@ -47,8 +47,8 @@ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
describe("prestige route", () => {
let app: Hono;
let prisma: {
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
player: { update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
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, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
@@ -93,14 +93,6 @@ 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("");
@@ -120,26 +112,14 @@ 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, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { runestones: number; newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
});
it("skips webhook when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
});
});
describe("POST /buy-upgrade", () => {
+1 -1
View File
@@ -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(98); // 100 - 2
expect(body.echoesRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
});
+2 -13
View File
@@ -46,24 +46,13 @@ 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");
// 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);
// 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));
});
});
+17 -26
View File
@@ -55,18 +55,15 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): 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 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 5× at count 1", () => {
expect(calculatePrestigeThreshold(1)).toBe(5_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("returns 25× at count 2", () => {
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
});
it("applies threshold multiplier correctly", () => {
@@ -102,27 +99,21 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => {
// floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(15);
expect(result).toBe(20);
});
it("applies echo runestone multiplier", () => {
// floor(cbrt(4)) × 15 = 15; × 2 = 30
// floor(sqrt(4) × 10) = 20; × 2 = 40
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(30);
expect(result).toBe(40);
});
it("applies purchased runestone upgrade multiplier", () => {
// With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBe(18);
});
it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200);
expect(result).toBeGreaterThan(20);
});
});
@@ -131,12 +122,12 @@ describe("calculateProductionMultiplier", () => {
expect(calculateProductionMultiplier(0)).toBe(1);
});
it("returns 1.3 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
it("returns 1.15 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
});
it("scales exponentially", () => {
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10));
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
});
});
@@ -151,12 +142,12 @@ describe("calculateMilestoneBonus", () => {
expect(calculateMilestoneBonus(5)).toBe(25);
});
it("returns 100 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(100);
it("returns 50 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(50);
});
it("returns 225 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(225);
it("returns 75 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(75);
});
});
+5 -11
View File
@@ -97,21 +97,20 @@ describe("isEligibleForTranscendence", () => {
describe("calculateEchoes", () => {
it("handles prestige count of 0 by treating it as 1", () => {
// safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224
expect(calculateEchoes(0, 1)).toBe(224);
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
expect(calculateEchoes(0, 1)).toBe(853);
});
it("calculates echoes at count 1", () => {
// floor(224 / sqrt(1)) = 224
expect(calculateEchoes(1, 1)).toBe(224);
expect(calculateEchoes(1, 1)).toBe(853);
});
it("decreases echoes with higher prestige count", () => {
const echoesAt1 = calculateEchoes(1, 1);
const echoesAt4 = calculateEchoes(4, 1);
expect(echoesAt4).toBeLessThan(echoesAt1);
// floor(224 / sqrt(4)) = floor(224 / 2) = 112
expect(echoesAt4).toBe(112);
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
expect(echoesAt4).toBe(426);
});
it("applies echoMetaMultiplier", () => {
@@ -119,11 +118,6 @@ 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", () => {
@@ -9,7 +9,6 @@
/* 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";
@@ -77,19 +76,12 @@ 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 effectiveStats: EffectiveAdventurerStats;
readonly adventurer: Adventurer;
readonly currentGold: number;
readonly batchSize: BatchSize;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
}
/**
@@ -100,7 +92,6 @@ 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 = ({
@@ -109,7 +100,6 @@ const AdventurerCard = ({
batchSize,
unlockHint,
formatNumber,
effectiveStats,
}: AdventurerCardProperties): JSX.Element => {
const { buyAdventurer } = useGame();
@@ -144,17 +134,17 @@ const AdventurerCard = ({
<div className="adventurer-info">
<h3>{adventurer.name}</h3>
<p>
{formatNumber(effectiveStats.goldPerSecond)}
{formatNumber(adventurer.goldPerSecond)}
{" gold/s each"}
</p>
{adventurer.essencePerSecond > 0
&& <p>
{formatNumber(effectiveStats.essencePerSecond)}
{formatNumber(adventurer.essencePerSecond)}
{" essence/s each"}
</p>
}
<p>
{formatNumber(effectiveStats.combatPower)}
{formatNumber(adventurer.combatPower)}
{" combat power each"}
</p>
</div>
@@ -290,10 +280,6 @@ 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)}
+69 -16
View File
@@ -11,11 +11,10 @@
/* 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 } from "@elysium/types";
import type { Boss, GameState } from "@elysium/types";
interface BossCardProperties {
readonly boss: Boss;
@@ -158,6 +157,72 @@ 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.
@@ -201,14 +266,7 @@ const BossPanel = (): JSX.Element => {
void handleChallenge(bossId);
}
const {
adventurers,
autoBoss,
bosses,
prestige: playerPrestige,
quests,
zones,
} = state;
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
@@ -291,12 +349,7 @@ const BossPanel = (): JSX.Element => {
}
const autoBossOn = autoBoss === true;
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 { partyDps, partyHp } = computePartyStats(state);
const { count: prestigeCount } = playerPrestige;
return (
@@ -49,40 +49,6 @@ const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
zone: "zones",
};
/**
* Converts a snake_case ID to a Title Case display name.
* @param id - The snake_case identifier to format.
* @returns The formatted display name.
*/
const formatId = (id: string): string => {
return id.split("_").
map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
}).
join(" ");
};
/**
* Generates a human-readable unlock hint for a locked codex entry.
* @param entry - The locked codex entry.
* @returns A string describing how to unlock the entry.
*/
const buildUnlockHint = (entry: CodexEntry): string => {
const name = formatId(entry.sourceId);
switch (entry.sourceType) {
case "boss": return `Defeat ${name}`;
case "quest": return `Complete: ${name}`;
case "equipment": return `Obtain: ${name}`;
case "adventurer": return `Recruit a ${name}`;
case "upgrade": return `Purchase: ${name}`;
case "prestige": return `Purchase runestone upgrade: ${name}`;
case "zone": return `Explore: ${name}`;
case "exploration": return `Discover: ${name}`;
case "recipe": return `Craft: ${name}`;
default: return "Keep playing to unlock";
}
};
/**
* Renders the codex panel with lore entries grouped by zone.
* @returns The JSX element.
@@ -170,9 +136,6 @@ const CodexPanel = (): JSX.Element => {
<span className="codex-lock">{"🔒"}</span>
<span className="codex-entry-title">{"???"}</span>
</div>
<p className="codex-unlock-hint">
{buildUnlockHint(entry)}
</p>
</div>
);
}
@@ -225,10 +225,6 @@ const EditProfileModal = ({
void handleNotificationsEnable();
}
function handlePrestigeAnnouncementsToggle(): void {
toggleSetting("enablePrestigeAnnouncements");
}
const isSaveDisabled = saving || characterName.trim() === "";
let saveLabel = "Save Profile";
@@ -421,23 +417,6 @@ const EditProfileModal = ({
}
</span>
</button>
<button
className={`stat-toggle-btn ${
profileSettings.enablePrestigeAnnouncements
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handlePrestigeAnnouncementsToggle}
type="button"
>
<span>{"⭐ Prestige Bot Announcements"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enablePrestigeAnnouncements
? "✓ On"
: "Off"
}
</span>
</button>
</div>
<div className="edit-profile-section">
+37 -9
View File
@@ -12,27 +12,25 @@ import { useState, type JSX } from "react";
import { prestige } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
import {
PRESTIGE_UPGRADE_CATEGORY_LABELS,
PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS,
} 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(prestigeCount + 1, 2);
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
};
/**
@@ -44,6 +42,32 @@ const calculateProductionMultiplier = (prestigeCount: number): number => {
return Math.pow(1.15, prestigeCount);
};
/**
* Calculates the runestone preview for a prestige.
* @param totalGoldEarned - Total gold earned this run.
* @param prestigeCount - The current prestige count.
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
* @returns The predicted runestone reward.
*/
const calculateRunestonePreview = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: Array<string>,
): number => {
const threshold = calculateThreshold(prestigeCount);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
return (
upgrade.category === "runestones"
&& purchasedUpgradeIds.includes(upgrade.id)
);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
return Math.floor(base * runestoneMult);
};
const categoryOrder: Array<PrestigeUpgradeCategory> = [
"income",
"click",
@@ -60,7 +84,7 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
const PrestigePanel = (): JSX.Element => {
const {
state,
reloadSilent,
reload,
formatNumber,
buyPrestigeUpgrade,
enableNotifications,
@@ -90,7 +114,11 @@ const PrestigePanel = (): JSX.Element => {
const { autoAdventurer, prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = computeProjectedRunestones(state);
const runestonePreview = calculateRunestonePreview(
player.totalGoldEarned,
prestigeData.count,
prestigeData.purchasedUpgradeIds,
);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
async function handlePrestige(): Promise<void> {
@@ -113,7 +141,7 @@ const PrestigePanel = (): JSX.Element => {
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
);
}
await reloadSilent();
await reload();
} catch (error_: unknown) {
setPrestigeError(
error_ instanceof Error
+7 -6
View File
@@ -11,10 +11,7 @@
/* eslint-disable max-statements -- Many local variables needed for quest state */
import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
import {
computePartyCombatPower,
zoneFailureChance,
} from "../../engine/tick.js";
import { zoneFailureChance } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
@@ -211,7 +208,7 @@ const QuestPanel = (): JSX.Element => {
);
}
const { autoQuest, bosses, quests, zones } = state;
const { adventurers, autoQuest, bosses, quests, zones } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
@@ -229,7 +226,11 @@ const QuestPanel = (): JSX.Element => {
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const partyCombatPower = computePartyCombatPower(state);
let partyCombatPower = 0;
for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
const zoneQuests = quests.filter(({ zoneId }) => {
return zoneId === activeZoneId;
});
@@ -7,8 +7,6 @@
/* 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";
@@ -240,22 +238,6 @@ 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) => {
+5 -26
View File
@@ -10,13 +10,7 @@
/* 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,
computeEssencePerSecond,
computeGoldPerSecond,
computePartyCombatPower,
computeProjectedRunestones,
} from "../../engine/tick.js";
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
import type { Resource } from "@elysium/types";
interface ResourceBarProperties {
@@ -89,13 +83,12 @@ const ResourceBar = ({
const { gold, essence, crystals } = resources;
let partyCombatPower = 0;
let goldPerSecond = 0;
let essencePerSecond = 0;
let projectedRunestones = 0;
if (state !== null) {
partyCombatPower = computePartyCombatPower(state);
for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state);
projectedRunestones = computeProjectedRunestones(state);
}
let avatarUrl: string | null = null;
@@ -189,13 +182,6 @@ const ResourceBar = ({
</span>
<span className="resource-label">{"Gold/s"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚡"}</span>
<span className="resource-value">
{formatNumber(essencePerSecond)}
</span>
<span className="resource-label">{"Essence/s"}</span>
</div>
<div className={`resource${essenceFull
? " resource-full"
: ""}`}>
@@ -237,13 +223,6 @@ const ResourceBar = ({
</span>
<span className="resource-label">{"Runestones"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⭐"}</span>
<span className="resource-value">
{`+${formatNumber(projectedRunestones)}`}
</span>
<span className="resource-label">{"On Prestige"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚔️"}</span>
<span className="resource-value">
+8 -98
View File
@@ -53,13 +53,11 @@ import {
transcend as transcendApi,
} from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { RECIPES } from "../data/recipes.js";
import {
RESOURCE_CAP,
applyTick,
calculateClickPower,
computePartyCombatPower,
} from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
@@ -117,9 +115,6 @@ const applyBossResult = (
}).
filter(Boolean),
);
const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => {
return z.id;
}));
const challengeUpdate
= previous.dailyChallenges === undefined
@@ -220,23 +215,6 @@ 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;
}),
},
},
};
}
@@ -310,12 +288,6 @@ interface GameContextValue {
*/
reload: ()=> Promise<void>;
/**
* Reload state from the server without showing the loading screen (used
* after prestige to avoid the visible flash/hang).
*/
reloadSilent: ()=> Promise<void>;
/**
* Unix timestamp of the last successful cloud save (null until first save response).
*/
@@ -724,10 +696,6 @@ export const GameProvider = ({
/* No-op placeholder */
});
const reloadSilentReference = useRef<()=> Promise<void>>(async() => {
/* No-op placeholder */
});
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
@@ -815,32 +783,6 @@ 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 ]);
@@ -1136,7 +1078,11 @@ export const GameProvider = ({
return q.status === "active";
});
if (!hasActiveQuest) {
const partyCombatPower = computePartyCombatPower(next);
// 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 zoneOrder = new Map(
next.zones.map((z, index) => {
return [ z.id, index ];
@@ -1174,31 +1120,14 @@ 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);
const isMaxTier = adventurer.level === maxAdventurerLevel;
const withinCap
= isMaxTier || adventurer.count < autoBuyCap;
return (
adventurer.unlocked
&& next.resources.gold >= cost
&& withinCap
);
return adventurer.unlocked && next.resources.gold >= cost;
}).
sort((adventurerA, adventurerB) => {
return adventurerB.level - adventurerA.level;
return adventurerB.combatPower - adventurerA.combatPower;
});
if (bestAdventurer !== undefined) {
const purchaseCost
@@ -1351,7 +1280,7 @@ export const GameProvider = ({
if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!");
}
await reloadSilentReference.current();
await reloadReference.current();
}).
catch(() => {
@@ -1417,13 +1346,6 @@ 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,
@@ -1867,18 +1789,7 @@ export const GameProvider = ({
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
isSyncingReference.current = true;
const result = await collectExplorationApi({ areaId });
/*
* Collect mutates server state outside the normal save flow — clear the
* stale HMAC signature and reset the timer so the next auto-save fires
* after React has re-rendered with the new materials in stateReference.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
lastSaveReference.current = Date.now();
isSyncingReference.current = false;
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
@@ -2409,7 +2320,6 @@ export const GameProvider = ({
offlineEssence,
offlineGold,
reload,
reloadSilent,
resetProgress,
saveSchemaVersion,
schemaOutdated,
-316
View File
@@ -21,7 +21,6 @@ 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";
/**
@@ -84,12 +83,6 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
});
};
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
*/
export const PRESTIGE_COMBAT_BASE = 4;
/**
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
*/
@@ -202,285 +195,6 @@ export const computeGoldPerSecond = (state: GameState): number => {
return goldPerSecond;
};
/**
* Computes the current essence per second for the given game state,
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
* @param state - The current game state.
* @returns The total essence per second.
*/
export const computeEssencePerSecond = (state: GameState): number => {
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
let essencePerSecond = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const contribution
= adventurer.essencePerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
essencePerSecond = essencePerSecond + contribution;
}
return essencePerSecond;
};
/**
* Computes the effective per-unit stats for a single adventurer type,
* applying all active multipliers (upgrades, prestige, equipment, echo,
* crafted, companion). The returned values represent what a single
* adventurer of this type currently contributes per second, matching the
* per-unit contribution used by computeGoldPerSecond and
* computeEssencePerSecond.
* @param state - The current game state.
* @param adventurerId - The ID of the adventurer to compute stats for.
* @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower.
*/
export const computeEffectiveAdventurerStats = (
state: GameState,
adventurerId: string,
): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => {
const adventurer = state.adventurers.find((a) => {
return a.id === adventurerId;
});
/* V8 ignore next 3 -- @preserve */
if (adventurer === undefined) {
return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 };
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurerId;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const equippedItems = state.equipment.filter((item) => {
return item.equipped;
});
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.goldMultiplier ?? 1);
}, 1);
const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
const equippedItemIds = equippedItems.map((item) => {
return item.id;
});
const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS);
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const prestigeCombatMultiplier = Math.pow(PRESTIGE_COMBAT_BASE, state.prestige.count);
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
const craftedGoldMultiplier
= state.exploration?.craftedGoldMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionGoldMult
= companionBonus?.type === "passiveGold"
? 1 + companionBonus.value
: 1;
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
const goldPerSecond
= adventurer.goldPerSecond
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesIncome
* echoIncome
* equipmentGoldMultiplier
* setBonuses.goldMultiplier
* craftedGoldMultiplier
* companionGoldMult;
const essencePerSecond
= adventurer.essencePerSecond
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
const combatPower
= adventurer.combatPower
* upgradeMultiplier
* prestigeCombatMultiplier
* equipmentCombatMultiplier
* setBonuses.combatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
return { combatPower, essencePerSecond, goldPerSecond };
};
/**
* Computes the party's total combat power, applying all active multipliers
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
* This mirrors the server-side calculatePartyStats in boss.ts and is the
* single source of truth for all combat-power checks in the client:
* - Displayed as "Combat Power" in the resource bar
* - Displayed as "Party DPS" in the boss panel
* - Used to gate quest availability
* Note: the active companion's bossDamage bonus is intentionally included
* here, as it applies to the full combat power calculation (boss fights and
* quest gating alike), matching the server-side behaviour.
* @param state - The current game state.
* @returns The total party combat power.
*/
export const computePartyCombatPower = (state: GameState): number => {
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier = globalMultiplier * upgrade.multiplier;
}
}
const prestigeMultiplier = Math.pow(PRESTIGE_COMBAT_BASE, state.prestige.count);
const equipmentCombatMultiplier = state.equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
const equippedItemIds = state.equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
});
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
equippedItemIds,
EQUIPMENT_SETS,
);
const echoCombatMultiplier
= state.transcendence?.echoCombatMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
let partyCombatPower = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased
&& upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
}
}
const contribution
= adventurer.combatPower
* adventurer.count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyCombatPower = partyCombatPower + contribution;
}
return partyCombatPower
* equipmentCombatMultiplier
* setCombatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
};
const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15;
const maxBaseRunestones = 200;
/**
* Computes the projected runestone reward if the player were to prestige right now.
* Mirrors the server-side calculateRunestones formula exactly.
* @param state - The current game state.
* @returns The number of runestones the player would earn from a prestige now.
*/
export const computeProjectedRunestones = (state: GameState): number => {
const { count, purchasedUpgradeIds } = state.prestige;
const threshold = basePrestigeThreshold * Math.pow(count + 1, 2);
const base = Math.min(
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
* runestonesPerPrestigeLevelClient,
maxBaseRunestones,
);
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
? 1.25
: 1;
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
? 1.5
: 1;
const runestoneMult = gain1Mult * gain2Mult;
/* 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.
@@ -755,19 +469,6 @@ 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,
@@ -788,23 +489,6 @@ 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,
@@ -48,17 +48,11 @@ 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,