3 Commits

Author SHA1 Message Date
naomi 26d30c271d release: v0.3.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint, Build & Test (push) Successful in 1m11s
2026-03-23 17:39:55 -07:00
hikari 34d07bec95 balance: comprehensive game balance pass (#103-#123) (#124)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

Comprehensive balance pass addressing 20 tickets (#103–#122) plus one audit-discovered fix (#123), ensuring no player soft-locks and aligning all content counts with achievements and progression milestones.

### Changes

- **Equipment** (#103–#111): Differentiated all stat pairs so every piece has a unique bonus combination; added missing stats to `eternal_flame` and increased `eternal_prism` multiplier to justify cost tier
- **Recipes** (#112–#115): Added 4 cross-zone crafting recipes requiring materials from multiple zones to incentivise exploration breadth
- **Achievements** (#116–#118): Aligned `fully_equipped` (40→65), `quest_eternal` (72→95), and `boss_eternal` (60→72) thresholds with actual content counts; updated `devourer_slayer` description
- **Quest CP scaling** (#120–#122): Verified and corrected combat power requirements across all zones to follow consistent 4×/4× progression pattern
- **Zone file ordering** (#123): Swapped Frozen Peaks and Shadow Marshes quest sections so file order matches the actual unlock chain (no gameplay change)

### Tickets Closed

Closes #103
Closes #104
Closes #105
Closes #106
Closes #107
Closes #108
Closes #109
Closes #110
Closes #111
Closes #112
Closes #113
Closes #114
Closes #115
Closes #116
Closes #117
Closes #118
Closes #120
Closes #121
Closes #122
Closes #123

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #124
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-23 17:28:29 -07:00
hikari 3ac1d566cb chore: community feedback fixes and UI improvements (#102)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

Addresses all community feedback tickets from the last deploy, plus several UI improvements made during the same session.

### Bug fixes & balance
- **#97** — Fix auto-adventurer tier priority: sort by combat power instead of current cost so the highest-tier affordable unit is always purchased
- **#98** — Add Dark Templar adventurer (80k CP) to bridge the Volcanic Depths progression wall; rewire upgrade and quest rewards accordingly
- **#99** — Reorder and buff Shadow Assassin (55k CP, level 12) so Witch Coven feels rewarding rather than a regression
- **#100** — Display effective Gold/s (all multipliers applied) in the resource bar
- **#101** — Add Peasant tier 2 (10x, essence) and tier 3 (50x, crystals) upgrades for meaningful late-game scaling

### Other fixes
- Sync game state to server before auto-boss challenges (matching manual challenge behaviour)
- Refresh Discord avatar hash on every game load via bot token so stale CDN URLs are corrected automatically

### UI improvements
- Replace Donate / Discord / Support / View Profile / Edit Profile buttons with a single avatar dropdown menu
- Collapse all resources except Gold into a click-to-toggle dropdown; orange alert dot appears when a hidden resource is capped

## Closes

Closes #97
Closes #98
Closes #99
Closes #100
Closes #101

Reviewed-on: #102
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-23 16:07:25 -07:00
23 changed files with 979 additions and 345 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.2.1", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+7 -7
View File
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
{ {
condition: { amount: 18, type: "bossesDefeated" }, condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat all 18 bosses, including the Devourer of Worlds.", description: "Defeat all 18 bosses across the first six zones.",
icon: "🌟", icon: "🌟",
id: "devourer_slayer", id: "devourer_slayer",
name: "World Saver", name: "World Saver",
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 40, type: "equipmentOwned" }, condition: { amount: 65, type: "equipmentOwned" },
description: "Own 40 pieces of equipment.", description: "Own all 65 pieces of equipment.",
icon: "🛡️", icon: "🛡️",
id: "fully_equipped", id: "fully_equipped",
name: "Fully Equipped", name: "Fully Equipped",
@@ -289,8 +289,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 72, type: "questsCompleted" }, condition: { amount: 95, type: "questsCompleted" },
description: "Complete all 72 quests across the known multiverse.", description: "Complete all 95 quests across the known multiverse.",
icon: "🌌", icon: "🌌",
id: "quest_eternal", id: "quest_eternal",
name: "Quest Eternal", name: "Quest Eternal",
@@ -317,8 +317,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 60, type: "bossesDefeated" }, condition: { amount: 72, type: "bossesDefeated" },
description: "Defeat all 60 bosses across every plane of existence.", description: "Defeat all 72 bosses across every plane of existence.",
icon: "💀", icon: "💀",
id: "boss_eternal", id: "boss_eternal",
name: "Eternal Vanquisher", name: "Eternal Vanquisher",
+45 -33
View File
@@ -129,27 +129,39 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 4_000_000_000, baseCost: 2_600_000_000,
class: "rogue", class: "mage",
combatPower: 18_000, combatPower: 13_000,
count: 0, count: 0,
essencePerSecond: 6, essencePerSecond: 6,
goldPerSecond: 5000, goldPerSecond: 4500,
id: "shadow_assassin", id: "arcane_scholar",
level: 11, level: 11,
name: "Arcane Scholar",
unlocked: false,
},
{
baseCost: 11_000_000_000,
class: "rogue",
combatPower: 28_000,
count: 0,
essencePerSecond: 11,
goldPerSecond: 9500,
id: "shadow_assassin",
level: 12,
name: "Shadow Assassin", name: "Shadow Assassin",
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 28_000_000_000, baseCost: 47_000_000_000,
class: "mage", class: "paladin",
combatPower: 45_000, combatPower: 60_000,
count: 0, count: 0,
essencePerSecond: 15, essencePerSecond: 20,
goldPerSecond: 14_000, goldPerSecond: 20_000,
id: "arcane_scholar", id: "dark_templar",
level: 12, level: 13,
name: "Arcane Scholar", name: "Dark Templar",
unlocked: false, unlocked: false,
}, },
{ {
@@ -160,7 +172,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35, essencePerSecond: 35,
goldPerSecond: 40_000, goldPerSecond: 40_000,
id: "void_walker", id: "void_walker",
level: 13, level: 14,
name: "Void Walker", name: "Void Walker",
unlocked: false, unlocked: false,
}, },
@@ -172,7 +184,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100, essencePerSecond: 100,
goldPerSecond: 120_000, goldPerSecond: 120_000,
id: "celestial_guard", id: "celestial_guard",
level: 14, level: 15,
name: "Celestial Guard", name: "Celestial Guard",
unlocked: false, unlocked: false,
}, },
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300, essencePerSecond: 300,
goldPerSecond: 400_000, goldPerSecond: 400_000,
id: "divine_champion", id: "divine_champion",
level: 15, level: 16,
name: "Divine Champion", name: "Divine Champion",
unlocked: false, unlocked: false,
}, },
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 800, essencePerSecond: 800,
goldPerSecond: 1_200_000, goldPerSecond: 1_200_000,
id: "seraph_knight", id: "seraph_knight",
level: 16, level: 17,
name: "Seraph Knight", name: "Seraph Knight",
unlocked: false, unlocked: false,
}, },
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2000, essencePerSecond: 2000,
goldPerSecond: 3_500_000, goldPerSecond: 3_500_000,
id: "abyss_diver", id: "abyss_diver",
level: 17, level: 18,
name: "Abyss Diver", name: "Abyss Diver",
unlocked: false, unlocked: false,
}, },
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 5000, essencePerSecond: 5000,
goldPerSecond: 10_000_000, goldPerSecond: 10_000_000,
id: "infernal_warden", id: "infernal_warden",
level: 18, level: 19,
name: "Infernal Warden", name: "Infernal Warden",
unlocked: false, unlocked: false,
}, },
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000, essencePerSecond: 12_000,
goldPerSecond: 30_000_000, goldPerSecond: 30_000_000,
id: "crystal_sage", id: "crystal_sage",
level: 19, level: 20,
name: "Crystal Sage", name: "Crystal Sage",
unlocked: false, unlocked: false,
}, },
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 30_000, essencePerSecond: 30_000,
goldPerSecond: 90_000_000, goldPerSecond: 90_000_000,
id: "void_sentinel", id: "void_sentinel",
level: 20, level: 21,
name: "Void Sentinel", name: "Void Sentinel",
unlocked: false, unlocked: false,
}, },
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 80_000, essencePerSecond: 80_000,
goldPerSecond: 270_000_000, goldPerSecond: 270_000_000,
id: "eternal_champion", id: "eternal_champion",
level: 21, level: 22,
name: "Eternal Champion", name: "Eternal Champion",
unlocked: false, unlocked: false,
}, },
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 220_000, essencePerSecond: 220_000,
goldPerSecond: 800_000_000, goldPerSecond: 800_000_000,
id: "aether_weaver", id: "aether_weaver",
level: 22, level: 23,
name: "Aether Weaver", name: "Aether Weaver",
unlocked: false, unlocked: false,
}, },
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 600_000, essencePerSecond: 600_000,
goldPerSecond: 2_500_000_000, goldPerSecond: 2_500_000_000,
id: "titan_warrior", id: "titan_warrior",
level: 23, level: 24,
name: "Titan Warrior", name: "Titan Warrior",
unlocked: false, unlocked: false,
}, },
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 1_600_000, essencePerSecond: 1_600_000,
goldPerSecond: 7_500_000_000, goldPerSecond: 7_500_000_000,
id: "nexus_sage", id: "nexus_sage",
level: 24, level: 25,
name: "Nexus Sage", name: "Nexus Sage",
unlocked: false, unlocked: false,
}, },
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 4_500_000, essencePerSecond: 4_500_000,
goldPerSecond: 22_000_000_000, goldPerSecond: 22_000_000_000,
id: "cosmos_knight", id: "cosmos_knight",
level: 25, level: 26,
name: "Cosmos Knight", name: "Cosmos Knight",
unlocked: false, unlocked: false,
}, },
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000_000, essencePerSecond: 12_000_000,
goldPerSecond: 65_000_000_000, goldPerSecond: 65_000_000_000,
id: "astral_sovereign", id: "astral_sovereign",
level: 26, level: 27,
name: "Astral Sovereign", name: "Astral Sovereign",
unlocked: false, unlocked: false,
}, },
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35_000_000, essencePerSecond: 35_000_000,
goldPerSecond: 200_000_000_000, goldPerSecond: 200_000_000_000,
id: "primordial_mage", id: "primordial_mage",
level: 27, level: 28,
name: "Primordial Mage", name: "Primordial Mage",
unlocked: false, unlocked: false,
}, },
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100_000_000, essencePerSecond: 100_000_000,
goldPerSecond: 600_000_000_000, goldPerSecond: 600_000_000_000,
id: "reality_warden", id: "reality_warden",
level: 28, level: 29,
name: "Reality Warden", name: "Reality Warden",
unlocked: false, unlocked: false,
}, },
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300_000_000, essencePerSecond: 300_000_000,
goldPerSecond: 1_800_000_000_000, goldPerSecond: 1_800_000_000_000,
id: "infinity_ranger", id: "infinity_ranger",
level: 29, level: 30,
name: "Infinity Ranger", name: "Infinity Ranger",
unlocked: false, unlocked: false,
}, },
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 850_000_000, essencePerSecond: 850_000_000,
goldPerSecond: 5_500_000_000_000, goldPerSecond: 5_500_000_000_000,
id: "oblivion_paladin", id: "oblivion_paladin",
level: 30, level: 31,
name: "Oblivion Paladin", name: "Oblivion Paladin",
unlocked: false, unlocked: false,
}, },
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2_500_000_000, essencePerSecond: 2_500_000_000,
goldPerSecond: 16_000_000_000_000, goldPerSecond: 16_000_000_000_000,
id: "transcendent_rogue", id: "transcendent_rogue",
level: 31, level: 32,
name: "Transcendent Rogue", name: "Transcendent Rogue",
unlocked: false, unlocked: false,
}, },
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 7_000_000_000, essencePerSecond: 7_000_000_000,
goldPerSecond: 50_000_000_000_000, goldPerSecond: 50_000_000_000_000,
id: "omniversal_champion", id: "omniversal_champion",
level: 32, level: 33,
name: "Omniversal Champion", name: "Omniversal Champion",
unlocked: false, unlocked: false,
}, },
+46 -46
View File
@@ -121,17 +121,17 @@ export const defaultBosses: Array<Boss> = [
}, },
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
bountyRunestones: 5, bountyRunestones: 20,
crystalReward: 30, crystalReward: 700,
currentHp: 80_000, currentHp: 6_000_000,
damagePerSecond: 80, damagePerSecond: 1200,
description: description:
"She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.", "She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.",
equipmentRewards: [], equipmentRewards: [],
essenceReward: 800, essenceReward: 30_000,
goldReward: 800_000, goldReward: 80_000_000,
id: "swamp_witch", id: "swamp_witch",
maxHp: 80_000, maxHp: 6_000_000,
name: "Morgantha the Swamp Witch", name: "Morgantha the Swamp Witch",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -139,17 +139,17 @@ export const defaultBosses: Array<Boss> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
bountyRunestones: 8, bountyRunestones: 25,
crystalReward: 80, crystalReward: 1500,
currentHp: 300_000, currentHp: 12_000_000,
damagePerSecond: 180, damagePerSecond: 2400,
description: description:
"A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.", "A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.",
equipmentRewards: [ "runestone_amulet" ], equipmentRewards: [ "runestone_amulet" ],
essenceReward: 2000, essenceReward: 60_000,
goldReward: 3_000_000, goldReward: 180_000_000,
id: "plague_lord", id: "plague_lord",
maxHp: 300_000, maxHp: 12_000_000,
name: "The Plague Lord", name: "The Plague Lord",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -157,17 +157,17 @@ export const defaultBosses: Array<Boss> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
bountyRunestones: 10, bountyRunestones: 30,
crystalReward: 150, crystalReward: 3000,
currentHp: 800_000, currentHp: 20_000_000,
damagePerSecond: 350, damagePerSecond: 4000,
description: description:
"An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.", "An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.",
equipmentRewards: [ "crystal_shard" ], equipmentRewards: [ "crystal_shard" ],
essenceReward: 4000, essenceReward: 100_000,
goldReward: 8_000_000, goldReward: 350_000_000,
id: "mud_kraken", id: "mud_kraken",
maxHp: 800_000, maxHp: 20_000_000,
name: "The Mud Kraken", name: "The Mud Kraken",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -231,53 +231,53 @@ export const defaultBosses: Array<Boss> = [
}, },
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
{ {
bountyRunestones: 12, bountyRunestones: 32,
crystalReward: 150, crystalReward: 4000,
currentHp: 1_000_000, currentHp: 25_000_000,
damagePerSecond: 400, damagePerSecond: 5000,
description: description:
"Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.", "Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.",
equipmentRewards: [ "flame_lance" ], equipmentRewards: [ "flame_lance" ],
essenceReward: 6000, essenceReward: 150_000,
goldReward: 10_000_000, goldReward: 500_000_000,
id: "fire_elemental", id: "fire_elemental",
maxHp: 1_000_000, maxHp: 25_000_000,
name: "The Ancient Fire Elemental", name: "The Ancient Fire Elemental",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [ "celestial_guard_1" ], upgradeRewards: [ "dark_templar_1" ],
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
bountyRunestones: 18, bountyRunestones: 40,
crystalReward: 400, crystalReward: 8000,
currentHp: 4_000_000, currentHp: 60_000_000,
damagePerSecond: 1000, damagePerSecond: 12_000,
description: description:
"Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.", "Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.",
equipmentRewards: [ "volcanic_plate" ], equipmentRewards: [ "volcanic_plate" ],
essenceReward: 15_000, essenceReward: 300_000,
goldReward: 40_000_000, goldReward: 1_000_000_000,
id: "magma_titan", id: "magma_titan",
maxHp: 4_000_000, maxHp: 60_000_000,
name: "The Magma Titan", name: "The Magma Titan",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [ "crystal_resonance" ], upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
bountyRunestones: 25, bountyRunestones: 50,
crystalReward: 800, crystalReward: 15_000,
currentHp: 12_000_000, currentHp: 150_000_000,
damagePerSecond: 2500, damagePerSecond: 30_000,
description: description:
"The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.", "The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.",
equipmentRewards: [ "eternal_flame" ], equipmentRewards: [ "eternal_flame" ],
essenceReward: 40_000, essenceReward: 600_000,
goldReward: 120_000_000, goldReward: 2_000_000_000,
id: "phoenix_lord", id: "phoenix_lord",
maxHp: 12_000_000, maxHp: 150_000_000,
name: "The Phoenix Lord", name: "The Phoenix Lord",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -1120,7 +1120,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Storm Colossus", name: "The Storm Colossus",
prestigeRequirement: 51, prestigeRequirement: 51,
status: "locked", status: "locked",
upgradeRewards: [ "cosmos_knight_1" ], upgradeRewards: [],
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_absolute_one", id: "the_absolute_one",
maxHp: 2e145, maxHp: 2e145,
name: "The Absolute One", name: "The Absolute One",
prestigeRequirement: 90, prestigeRequirement: 88,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
+2 -2
View File
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 }, bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
description: description:
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.", "A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
equipped: false, equipped: false,
@@ -757,7 +757,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour", type: "armour",
}, },
{ {
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 }, bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
cost: { crystals: 100_000_000, essence: 0, gold: 0 }, cost: { crystals: 100_000_000, essence: 0, gold: 0 },
description: description:
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.", "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
+4 -4
View File
@@ -92,18 +92,18 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
{ {
category: "income", category: "income",
description: description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.", "The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
id: "income_10", id: "income_10",
multiplier: 500, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 30_000, runestonesCost: 30_000,
}, },
{ {
category: "income", category: "income",
description: description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.", "Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
id: "income_11", id: "income_11",
multiplier: 1000, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 80_000, runestonesCost: 80_000,
}, },
+142 -68
View File
@@ -33,6 +33,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 2000, type: "gold" }, { amount: 2000, type: "gold" },
{ amount: 5, type: "essence" }, { amount: 5, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" }, { targetId: "apprentice", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -64,7 +65,6 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [ "haunted_mine" ], prerequisiteIds: [ "haunted_mine" ],
rewards: [ rewards: [
{ amount: 50, type: "essence" }, { amount: 50, type: "essence" },
{ targetId: "click_2", type: "upgrade" },
{ targetId: "acolyte", type: "adventurer" }, { targetId: "acolyte", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -82,7 +82,8 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 15_000, type: "gold" }, { amount: 15_000, type: "gold" },
{ amount: 20, type: "essence" }, { amount: 20, type: "essence" },
{ targetId: "cleric_1", type: "upgrade" }, { targetId: "militia_1", type: "upgrade" },
{ targetId: "acolyte_1", type: "upgrade" },
{ targetId: "ranger", type: "adventurer" }, { targetId: "ranger", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -116,7 +117,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 300, type: "essence" }, { amount: 300, type: "essence" },
{ amount: 30, type: "crystals" }, { amount: 30, type: "crystals" },
{ targetId: "mage_1", type: "upgrade" }, { targetId: "apprentice_1", type: "upgrade" },
{ targetId: "archmage", type: "adventurer" }, { targetId: "archmage", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -139,70 +140,6 @@ export const defaultQuests: Array<Quest> = [
status: "locked", status: "locked",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
}, },
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5_000_000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 150, type: "essence" },
{ targetId: "militia_1", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
],
status: "locked",
zoneId: "shadow_marshes",
},
// ── Frozen Peaks ────────────────────────────────────────────────────────── // ── Frozen Peaks ──────────────────────────────────────────────────────────
{ {
combatPowerRequired: 100_000, combatPowerRequired: 100_000,
@@ -247,11 +184,75 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 30_000_000, type: "gold" }, { amount: 30_000_000, type: "gold" },
{ amount: 10_000, type: "essence" }, { amount: 10_000, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
}, },
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5_000_000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 150, type: "essence" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
{ targetId: "peasant_2", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
{ targetId: "dark_templar", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
{ {
combatPowerRequired: 1_200_000_000, combatPowerRequired: 1_200_000_000,
@@ -281,6 +282,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 40_000_000, type: "gold" }, { amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" }, { amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" }, { amount: 300, type: "crystals" },
{ targetId: "peasant_3", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -383,6 +385,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Celestial Reaches ───────────────────────────────────────────────────── // ── Celestial Reaches ─────────────────────────────────────────────────────
{ {
combatPowerRequired: 7.2e13,
description: 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.", "The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
durationSeconds: Math.round(1.5 * 60 * 60), durationSeconds: Math.round(1.5 * 60 * 60),
@@ -398,6 +401,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 3e14,
description: description:
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.", "A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -412,6 +416,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 1.2e15,
description: description:
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.", "Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
durationSeconds: 5 * 60 * 60, durationSeconds: 5 * 60 * 60,
@@ -427,6 +432,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 4.8e15,
description: 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.", "A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -442,6 +448,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 1.8e16,
description: 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.", "The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -458,6 +465,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 7.2e16,
description: 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.", "The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -474,6 +482,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Abyssal Trench ──────────────────────────────────────────────────────── // ── Abyssal Trench ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 3e17,
description: 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.", "The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
durationSeconds: 2 * 60 * 60, durationSeconds: 2 * 60 * 60,
@@ -489,6 +498,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 1.2e18,
description: 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.", "The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -504,6 +514,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 4.8e18,
description: 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.", "Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
durationSeconds: 7 * 60 * 60, durationSeconds: 7 * 60 * 60,
@@ -519,6 +530,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 1.8e19,
description: 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.", "Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -534,6 +546,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 7.2e19,
description: 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.", "A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
durationSeconds: 18 * 60 * 60, durationSeconds: 18 * 60 * 60,
@@ -550,6 +563,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 3e20,
description: 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.", "The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
durationSeconds: 30 * 60 * 60, durationSeconds: 30 * 60 * 60,
@@ -566,6 +580,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Infernal Court ──────────────────────────────────────────────────────── // ── Infernal Court ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.2e21,
description: 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.", "The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -581,6 +596,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 4.8e21,
description: 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.", "The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
durationSeconds: 6 * 60 * 60, durationSeconds: 6 * 60 * 60,
@@ -596,6 +612,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 1.8e22,
description: 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.", "The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
durationSeconds: 10 * 60 * 60, durationSeconds: 10 * 60 * 60,
@@ -611,6 +628,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 7.2e22,
description: 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.", "Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
durationSeconds: 16 * 60 * 60, durationSeconds: 16 * 60 * 60,
@@ -626,6 +644,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 3e23,
description: 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.", "The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
durationSeconds: 24 * 60 * 60, durationSeconds: 24 * 60 * 60,
@@ -642,6 +661,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 1.2e24,
description: 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.", "The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
durationSeconds: 40 * 60 * 60, durationSeconds: 40 * 60 * 60,
@@ -658,6 +678,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Crystalline Spire ───────────────────────────────────────────────────── // ── Crystalline Spire ─────────────────────────────────────────────────────
{ {
combatPowerRequired: 4.8e24,
description: 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.", "The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -673,6 +694,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 1.8e25,
description: 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.", "A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -688,6 +710,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 7.2e25,
description: 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.", "A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
durationSeconds: 14 * 60 * 60, durationSeconds: 14 * 60 * 60,
@@ -703,6 +726,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 3e26,
description: 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.", "The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -718,6 +742,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 1.2e27,
description: 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.", "The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
durationSeconds: 32 * 60 * 60, durationSeconds: 32 * 60 * 60,
@@ -734,6 +759,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 4.8e27,
description: 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.", "The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
durationSeconds: 50 * 60 * 60, durationSeconds: 50 * 60 * 60,
@@ -750,6 +776,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Void Sanctum ────────────────────────────────────────────────────────── // ── Void Sanctum ──────────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.8e28,
description: 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.", "The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
durationSeconds: 6 * 60 * 60, durationSeconds: 6 * 60 * 60,
@@ -765,6 +792,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 7.2e28,
description: 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.", "Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -780,6 +808,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 3e29,
description: 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.", "The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -795,6 +824,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 1.2e30,
description: 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.", "Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
durationSeconds: 30 * 60 * 60, durationSeconds: 30 * 60 * 60,
@@ -810,6 +840,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 4.8e30,
description: 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.", "The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
durationSeconds: 48 * 60 * 60, durationSeconds: 48 * 60 * 60,
@@ -826,6 +857,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 1.8e31,
description: 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.", "The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
durationSeconds: 72 * 60 * 60, durationSeconds: 72 * 60 * 60,
@@ -842,6 +874,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Eternal Throne ──────────────────────────────────────────────────────── // ── Eternal Throne ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 7.2e31,
description: 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.", "The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -857,6 +890,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 3e32,
description: 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.", "A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
durationSeconds: 16 * 60 * 60, durationSeconds: 16 * 60 * 60,
@@ -872,6 +906,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 1.2e33,
description: 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.", "The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
durationSeconds: 28 * 60 * 60, durationSeconds: 28 * 60 * 60,
@@ -887,6 +922,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 4.8e33,
description: 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.", "The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
durationSeconds: 40 * 60 * 60, durationSeconds: 40 * 60 * 60,
@@ -903,6 +939,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 1.8e34,
description: 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.", "The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
durationSeconds: 60 * 60 * 60, durationSeconds: 60 * 60 * 60,
@@ -918,6 +955,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 7.2e34,
description: 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.", "The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
durationSeconds: 96 * 60 * 60, durationSeconds: 96 * 60 * 60,
@@ -934,6 +972,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Primordial Chaos ────────────────────────────────────────────────────── // ── Primordial Chaos ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 3e35,
description: 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.", "Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
durationSeconds: 10 * 60 * 60, durationSeconds: 10 * 60 * 60,
@@ -949,6 +988,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 1.2e36,
description: 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.", "Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
durationSeconds: 18 * 60 * 60, durationSeconds: 18 * 60 * 60,
@@ -964,6 +1004,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 4.8e36,
description: 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.", "A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
durationSeconds: 30 * 60 * 60, durationSeconds: 30 * 60 * 60,
@@ -980,6 +1021,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 1.8e37,
description: 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.", "Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
durationSeconds: 45 * 60 * 60, durationSeconds: 45 * 60 * 60,
@@ -995,6 +1037,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 7.2e37,
description: 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.", "The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
durationSeconds: 65 * 60 * 60, durationSeconds: 65 * 60 * 60,
@@ -1011,6 +1054,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 3e38,
description: 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.", "The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
durationSeconds: 90 * 60 * 60, durationSeconds: 90 * 60 * 60,
@@ -1027,6 +1071,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Infinite Expanse ────────────────────────────────────────────────────── // ── Infinite Expanse ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.2e39,
description: 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.", "The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -1042,6 +1087,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 4.8e39,
description: 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.", "An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
durationSeconds: 22 * 60 * 60, durationSeconds: 22 * 60 * 60,
@@ -1057,6 +1103,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 1.8e40,
description: 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.", "Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
durationSeconds: 36 * 60 * 60, durationSeconds: 36 * 60 * 60,
@@ -1073,6 +1120,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 7.2e40,
description: 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.", "A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
durationSeconds: 55 * 60 * 60, durationSeconds: 55 * 60 * 60,
@@ -1089,6 +1137,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 3e41,
description: 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.", "A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
durationSeconds: 80 * 60 * 60, durationSeconds: 80 * 60 * 60,
@@ -1104,6 +1153,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 1.2e42,
description: 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'.", "The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
durationSeconds: 110 * 60 * 60, durationSeconds: 110 * 60 * 60,
@@ -1120,6 +1170,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Reality Forge ───────────────────────────────────────────────────────── // ── Reality Forge ─────────────────────────────────────────────────────────
{ {
combatPowerRequired: 4.8e42,
description: 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.", "The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
durationSeconds: 14 * 60 * 60, durationSeconds: 14 * 60 * 60,
@@ -1135,6 +1186,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 1.8e43,
description: 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.", "The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
durationSeconds: 25 * 60 * 60, durationSeconds: 25 * 60 * 60,
@@ -1150,6 +1202,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 7.2e43,
description: 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.", "The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
durationSeconds: 40 * 60 * 60, durationSeconds: 40 * 60 * 60,
@@ -1166,6 +1219,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 3e44,
description: 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.", "The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
durationSeconds: 60 * 60 * 60, durationSeconds: 60 * 60 * 60,
@@ -1182,6 +1236,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 1.2e45,
description: 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.", "The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
durationSeconds: 85 * 60 * 60, durationSeconds: 85 * 60 * 60,
@@ -1197,6 +1252,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 4.8e45,
description: 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.", "The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
durationSeconds: 120 * 60 * 60, durationSeconds: 120 * 60 * 60,
@@ -1213,6 +1269,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Cosmic Maelstrom ────────────────────────────────────────────────────── // ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.8e46,
description: 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.", "The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
durationSeconds: 16 * 60 * 60, durationSeconds: 16 * 60 * 60,
@@ -1228,6 +1285,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 7.2e46,
description: 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.", "The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
durationSeconds: 28 * 60 * 60, durationSeconds: 28 * 60 * 60,
@@ -1243,6 +1301,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 3e47,
description: 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.", "A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
durationSeconds: 45 * 60 * 60, durationSeconds: 45 * 60 * 60,
@@ -1259,6 +1318,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 1.2e48,
description: 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.", "Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
durationSeconds: 65 * 60 * 60, durationSeconds: 65 * 60 * 60,
@@ -1275,6 +1335,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 4.8e48,
description: 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.", "The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
durationSeconds: 90 * 60 * 60, durationSeconds: 90 * 60 * 60,
@@ -1290,6 +1351,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 1.8e49,
description: 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.", "The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
durationSeconds: 130 * 60 * 60, durationSeconds: 130 * 60 * 60,
@@ -1306,6 +1368,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Primeval Sanctum ────────────────────────────────────────────────────── // ── Primeval Sanctum ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 7.2e49,
description: 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.", "The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
durationSeconds: 18 * 60 * 60, durationSeconds: 18 * 60 * 60,
@@ -1321,6 +1384,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 3e50,
description: 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.", "The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
durationSeconds: 32 * 60 * 60, durationSeconds: 32 * 60 * 60,
@@ -1336,6 +1400,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 1.2e51,
description: 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.", "The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
durationSeconds: 50 * 60 * 60, durationSeconds: 50 * 60 * 60,
@@ -1352,6 +1417,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 4.8e51,
description: 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.", "The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
durationSeconds: 72 * 60 * 60, durationSeconds: 72 * 60 * 60,
@@ -1368,6 +1434,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 1.8e52,
description: 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.", "A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
durationSeconds: 100 * 60 * 60, durationSeconds: 100 * 60 * 60,
@@ -1383,6 +1450,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 7.2e52,
description: 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.", "The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
durationSeconds: 144 * 60 * 60, durationSeconds: 144 * 60 * 60,
@@ -1399,6 +1467,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── The Absolute ────────────────────────────────────────────────────────── // ── The Absolute ──────────────────────────────────────────────────────────
{ {
combatPowerRequired: 3e53,
description: 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.", "The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -1414,6 +1483,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 1.2e54,
description: 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.", "Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
durationSeconds: 36 * 60 * 60, durationSeconds: 36 * 60 * 60,
@@ -1429,6 +1499,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 4.8e54,
description: 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.", "A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
durationSeconds: 56 * 60 * 60, durationSeconds: 56 * 60 * 60,
@@ -1445,6 +1516,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 1.8e55,
description: 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.", "Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
durationSeconds: 80 * 60 * 60, durationSeconds: 80 * 60 * 60,
@@ -1460,6 +1532,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 7.2e55,
description: 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.", "The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
durationSeconds: 120 * 60 * 60, durationSeconds: 120 * 60 * 60,
@@ -1476,6 +1549,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 3e56,
description: 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.", "This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
durationSeconds: 168 * 60 * 60, durationSeconds: 168 * 60 * 60,
+56
View File
@@ -451,6 +451,62 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
// ── Cross-zone recipes ─────────────────────────────────────────────────────
{
bonus: { type: "gold_income", value: 1.28 },
description:
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
id: "verdant_pyre_seal",
name: "Verdant Pyre Seal",
requiredMaterials: [
{ materialId: "verdant_sap", quantity: 8 },
{ materialId: "ember_crystal", quantity: 6 },
{ materialId: "legendary_ore", quantity: 2 },
],
zoneId: "volcanic_depths",
},
{
bonus: { type: "click_power", value: 1.22 },
description:
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
id: "voidfrost_weave",
name: "Voidfrost Weave",
requiredMaterials: [
{ materialId: "glacial_ice", quantity: 8 },
{ materialId: "void_shard", quantity: 3 },
{ materialId: "shadow_essence", quantity: 5 },
],
zoneId: "shadow_marshes",
},
{
bonus: { type: "essence_income", value: 1.28 },
description:
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
id: "choir_of_the_deep",
name: "Choir of the Deep",
requiredMaterials: [
{ materialId: "celestial_dust", quantity: 8 },
{ materialId: "choir_shard", quantity: 2 },
{ materialId: "ancient_tooth", quantity: 2 },
{ materialId: "pressure_gem", quantity: 5 },
],
zoneId: "abyssal_trench",
},
{
bonus: { type: "combat_power", value: 1.4 },
description:
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
id: "eternal_omega",
name: "Eternal Omega",
requiredMaterials: [
{ materialId: "crown_fragment", quantity: 6 },
{ materialId: "eternity_splinter", quantity: 2 },
{ materialId: "boundary_shard", quantity: 4 },
{ materialId: "omega_crystal", quantity: 2 },
],
zoneId: "the_absolute",
},
// Zone 18: the_absolute // Zone 18: the_absolute
{ {
bonus: { type: "gold_income", value: 1.3 }, bonus: { type: "gold_income", value: 1.3 },
+3 -3
View File
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ─────────────────────────────────────────────────── // ── Echo meta multipliers ───────────────────────────────────────────────────
{ {
category: "echo_meta", category: "echo_meta",
cost: 10, cost: 50,
description: description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.", "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1", id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 25, cost: 150,
description: description:
"Each loop of existence makes the next more powerful — future echo yields +50%.", "Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2", id: "echo_meta_2",
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 50, cost: 400,
description: description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.", "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3", id: "echo_meta_3",
+61 -19
View File
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
{
adventurerId: "peasant",
costCrystals: 0,
costEssence: 20,
costGold: 0,
description:
"Organised labour guilds and proper scheduling make peasants ten times more productive.",
id: "peasant_2",
multiplier: 10,
name: "Guild Organisation",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "peasant",
costCrystals: 50,
costEssence: 0,
costGold: 0,
description:
"Magical augmentation through crystalline resonance supercharges even the humblest worker.",
id: "peasant_3",
multiplier: 50,
name: "Crystal Augmentation",
purchased: false,
target: "adventurer",
unlocked: false,
},
{ {
adventurerId: "militia", adventurerId: "militia",
costCrystals: 0, costCrystals: 0,
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 2, costEssence: 2,
costGold: 5000, costGold: 5000,
description: "Ancient books of magic double mage output.", description: "Ancient books of magic double mage output.",
id: "mage_1", id: "apprentice_1",
multiplier: 2, multiplier: 2,
name: "Arcane Tomes", name: "Arcane Tomes",
purchased: false, purchased: false,
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 3, costEssence: 3,
costGold: 8000, costGold: 8000,
description: "Sacred ceremonies double the output of your clerics.", description: "Sacred ceremonies double the output of your clerics.",
id: "cleric_1", id: "acolyte_1",
multiplier: 2, multiplier: 2,
name: "Holy Rites", name: "Holy Rites",
purchased: false, purchased: false,
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
{
adventurerId: "shadow_assassin",
costCrystals: 0,
costEssence: 50,
costGold: 0,
description: "Mastery of the shadow arts doubles assassin effectiveness.",
id: "shadow_assassin_1",
multiplier: 2,
name: "Shadow Arts",
purchased: false,
target: "adventurer",
unlocked: false,
},
{ {
adventurerId: "arcane_scholar", adventurerId: "arcane_scholar",
costCrystals: 0, costCrystals: 0,
costEssence: 150, costEssence: 1000,
costGold: 0, costGold: 0,
description: "Access to forbidden libraries doubles scholar output.", description: "Access to forbidden libraries doubles scholar output.",
id: "arcane_scholar_1", id: "arcane_scholar_1",
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
{
adventurerId: "shadow_assassin",
costCrystals: 0,
costEssence: 5000,
costGold: 0,
description: "Mastery of the shadow arts doubles assassin effectiveness.",
id: "shadow_assassin_1",
multiplier: 2,
name: "Shadow Arts",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "dark_templar",
costCrystals: 0,
costEssence: 25_000,
costGold: 0,
description:
"A sworn oath to the darkness of the marshes doubles templar output.",
id: "dark_templar_1",
multiplier: 2,
name: "Templar's Oath",
purchased: false,
target: "adventurer",
unlocked: false,
},
{ {
adventurerId: "void_walker", adventurerId: "void_walker",
costCrystals: 0, costCrystals: 0,
costEssence: 300, costEssence: 100_000,
costGold: 0, costGold: 0,
description: description:
"Walking through the void itself doubles the output of your void walkers.", "Walking through the void itself doubles the output of your void walkers.",
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{ {
adventurerId: "celestial_guard", adventurerId: "celestial_guard",
costCrystals: 0, costCrystals: 0,
costEssence: 750, costEssence: 500_000,
costGold: 0, costGold: 0,
description: description:
"A blessing from the celestials themselves doubles guard output.", "A blessing from the celestials themselves doubles guard output.",
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{ {
adventurerId: "divine_champion", adventurerId: "divine_champion",
costCrystals: 0, costCrystals: 0,
costEssence: 2000, costEssence: 2_000_000,
costGold: 0, costGold: 0,
description: "An unbreakable oath to the divine doubles champion output.", description: "An unbreakable oath to the divine doubles champion output.",
id: "divine_champion_1", id: "divine_champion_1",
+26 -1
View File
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
import { fetchDiscordUserById } from "../services/discord.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js";
import { import {
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
try { try {
const discordId = context.get("discordId"); const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([ const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
Promise.all([
prisma.gameState.findUnique({ where: { discordId } }), prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }),
]),
fetchDiscordUserById(discordId),
]); ]);
// Refresh avatar in DB when Discord returns an updated hash
if (
freshDiscordUser !== null
&& playerRecord !== null
&& freshDiscordUser.avatar !== playerRecord.avatar
) {
playerRecord.avatar = freshDiscordUser.avatar;
void prisma.player.update({
data: { avatar: freshDiscordUser.avatar },
where: { discordId },
}).catch((error: unknown) => {
void logger.error(
"avatar_refresh",
error instanceof Error
? error
: new Error(String(error)),
);
});
}
if (!record) { if (!record) {
// No save found — create a fresh state (handles nuked DB or first-time load race) // No save found — create a fresh state (handles nuked DB or first-time load race)
if (!playerRecord) { if (!playerRecord) {
@@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => {
*/ */
if (playerRecord !== null) { if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName; state.player.characterName = playerRecord.characterName;
state.player.avatar = playerRecord.avatar;
} }
const now = Date.now(); const now = Date.now();
+35 -1
View File
@@ -106,6 +106,40 @@ const fetchDiscordUser = async(
} }
}; };
/**
* Fetches a Discord user's profile by their Discord ID using the bot token.
* Returns null on any failure so callers are never blocked by Discord API issues.
* @param discordId - The Discord user ID to look up.
* @returns The Discord user object, or null if the fetch fails.
*/
const fetchDiscordUserById = async(
discordId: string,
): Promise<DiscordUser | null> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return null;
}
try {
const response = await fetch(
`https://discord.com/api/v10/users/${discordId}`,
{ headers: { Authorization: `Bot ${botToken}` } },
);
if (!response.ok) {
return null;
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
} catch (error) {
void logger.error(
"discord_fetch_user_by_id",
error instanceof Error
? error
: new Error(String(error)),
);
return null;
}
};
/** /**
* Builds the Discord OAuth authorisation URL. * Builds the Discord OAuth authorisation URL.
* @returns The full OAuth URL to redirect the user to. * @returns The full OAuth URL to redirect the user to.
@@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => {
}; };
export type { DiscordTokenResponse, DiscordUser }; export type { DiscordTokenResponse, DiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser }; export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
+73
View File
@@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({
}), }),
})); }));
vi.mock("../../src/services/discord.js", () => ({
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
}));
const DISCORD_ID = "test_discord_id"; const DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1; const CURRENT_SCHEMA_VERSION = 1;
@@ -200,6 +204,75 @@ describe("game route", () => {
expect(body.offlineGold).toBeGreaterThan(0); expect(body.offlineGold).toBeGreaterThan(0);
expect(body.offlineEssence).toBeGreaterThan(0); expect(body.offlineEssence).toBeGreaterThan(0);
}); });
it("syncs updated avatar from Discord into the returned state", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("new_hash");
});
it("continues loading when the avatar DB update fails", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("keeps stored avatar when Discord returns null", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("stored_hash");
});
}); });
describe("POST /save", () => { describe("POST /save", () => {
+49
View File
@@ -104,4 +104,53 @@ describe("discord service", () => {
await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
}); });
}); });
describe("fetchDiscordUserById", () => {
it("returns null when DISCORD_BOT_TOKEN is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when DISCORD_BOT_TOKEN is empty", async () => {
process.env["DISCORD_BOT_TOKEN"] = "";
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when response is not ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" });
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when fetch throws", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockRejectedValueOnce(new Error("network error"));
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when fetch throws a non-Error value", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockRejectedValueOnce("raw string error");
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns the user on success", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toMatchObject({ id: "123456", avatar: "abc123" });
});
});
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.2.1", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -135,7 +135,6 @@ const GameLayout = (): JSX.Element => {
); );
} }
const profileUrl = `/profile/${state.player.discordId}`;
const codexBadgeCount = pendingCodexEntryIds.length; const codexBadgeCount = pendingCodexEntryIds.length;
const storyBadgeCount = pendingStoryChapterIds.length; const storyBadgeCount = pendingStoryChapterIds.length;
@@ -160,7 +159,6 @@ const GameLayout = (): JSX.Element => {
onEditProfile={handleOpenEditProfile} onEditProfile={handleOpenEditProfile}
onForceSync={forceSync} onForceSync={forceSync}
prestigeCount={state.prestige.count} prestigeCount={state.prestige.count}
profileUrl={profileUrl}
resources={state.resources} resources={state.resources}
runestones={state.prestige.runestones} runestones={state.prestige.runestones}
transcendenceCount={state.transcendence?.count ?? 0} transcendenceCount={state.transcendence?.count ?? 0}
+155 -52
View File
@@ -4,12 +4,14 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- Resource bar has many resource and action elements */
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
/* eslint-disable complexity -- Many conditional resource and badge render paths */ /* 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 { useGame } from "../../context/gameContext.js";
import { RESOURCE_CAP } from "../../engine/tick.js"; import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
import type { JSX } from "react";
interface ResourceBarProperties { interface ResourceBarProperties {
readonly resources: Resource; readonly resources: Resource;
@@ -17,7 +19,6 @@ interface ResourceBarProperties {
readonly prestigeCount: number; readonly prestigeCount: number;
readonly transcendenceCount: number; readonly transcendenceCount: number;
readonly apotheosisCount: number; readonly apotheosisCount: number;
readonly profileUrl: string;
readonly onEditProfile: ()=> void; readonly onEditProfile: ()=> void;
readonly lastSavedAt: number | null; readonly lastSavedAt: number | null;
readonly isSyncing: boolean; readonly isSyncing: boolean;
@@ -58,7 +59,6 @@ const resourceFullTooltip = [
* @param props.prestigeCount - The number of prestiges completed. * @param props.prestigeCount - The number of prestiges completed.
* @param props.transcendenceCount - The number of transcendences completed. * @param props.transcendenceCount - The number of transcendences completed.
* @param props.apotheosisCount - The number of apotheoses completed. * @param props.apotheosisCount - The number of apotheoses completed.
* @param props.profileUrl - The URL of the player's public profile.
* @param props.onEditProfile - Callback to open the edit profile modal. * @param props.onEditProfile - Callback to open the edit profile modal.
* @param props.lastSavedAt - Timestamp of the last cloud save. * @param props.lastSavedAt - Timestamp of the last cloud save.
* @param props.isSyncing - Whether a sync is currently in progress. * @param props.isSyncing - Whether a sync is currently in progress.
@@ -71,56 +71,130 @@ const ResourceBar = ({
prestigeCount, prestigeCount,
transcendenceCount, transcendenceCount,
apotheosisCount, apotheosisCount,
profileUrl,
onEditProfile, onEditProfile,
lastSavedAt, lastSavedAt,
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProperties): JSX.Element => { }: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError, state } = useGame(); const { formatNumber, syncError, state } = useGame();
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
const { gold, essence, crystals } = resources; const { gold, essence, crystals } = resources;
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0;
if (state !== null) { if (state !== null) {
for (const adventurer of state.adventurers) { for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count; const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution; partyCombatPower = partyCombatPower + contribution;
} }
goldPerSecond = computeGoldPerSecond(state);
} }
const resourceValues = [ gold, essence, crystals ];
const anyFull = resourceValues.some((v) => { let avatarUrl: string | null = null;
return v >= RESOURCE_CAP; if (state !== null) {
}); avatarUrl = state.player.avatar === null
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
}
const profileUrl = state === null
? "#"
: `/profile/${state.player.discordId}`;
const goldFull = gold >= RESOURCE_CAP; const goldFull = gold >= RESOURCE_CAP;
const essenceFull = essence >= RESOURCE_CAP; const essenceFull = essence >= RESOURCE_CAP;
const crystalsFull = crystals >= RESOURCE_CAP; const crystalsFull = crystals >= RESOURCE_CAP;
const anyFull = goldFull || essenceFull || crystalsFull;
const hiddenResourcesFull = essenceFull || crystalsFull;
function handleForceSync(): void { function handleForceSync(): void {
void onForceSync(); void onForceSync();
} }
function handleToggleResources(): void {
setIsResourcesOpen((previous) => {
return !previous;
});
}
function handleResourceBlur(event: FocusEvent<HTMLDivElement>): void {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsResourcesOpen(false);
}
}
function handleToggleProfile(): void {
setIsProfileOpen((previous) => {
return !previous;
});
}
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsProfileOpen(false);
}
}
function handleEditProfile(): void {
setIsProfileOpen(false);
onEditProfile();
}
return ( return (
<> <>
<header className="resource-bar"> <header className="resource-bar">
<div className={`resource${goldFull <div
className="resource-menu"
onBlur={handleResourceBlur}
>
<button
className={`resource resource-toggle${goldFull
? " resource-full" ? " resource-full"
: ""}`}> : ""}`}
onClick={handleToggleResources}
title="Click to see all resources"
type="button"
>
<span className="resource-icon">{"🪙"}</span> <span className="resource-icon">{"🪙"}</span>
<span className="resource-value">{formatNumber(gold)}</span> <span className="resource-value">{formatNumber(gold)}</span>
<span className="resource-label">{"Gold"}</span> <span className="resource-label">{"Gold"}</span>
{goldFull {goldFull
? <span className="resource-cap-badge" title={resourceFullTooltip}> ? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"} {"FULL"}
</span> </span>
: null} : null}
{hiddenResourcesFull
? <span
className="resource-alert-dot"
title={"One or more resources are full!"}
/>
: null}
</button>
{isResourcesOpen
? <div className="resources-dropdown">
<div className="resource">
<span className="resource-icon">{"📈"}</span>
<span className="resource-value">
{formatNumber(goldPerSecond)}
</span>
<span className="resource-label">{"Gold/s"}</span>
</div> </div>
<div className={`resource${essenceFull <div className={`resource${essenceFull
? " resource-full" ? " resource-full"
: ""}`}> : ""}`}>
<span className="resource-icon">{"✨"}</span> <span className="resource-icon">{"✨"}</span>
<span className="resource-value">{formatNumber(essence)}</span> <span className="resource-value">
{formatNumber(essence)}
</span>
<span className="resource-label">{"Essence"}</span> <span className="resource-label">{"Essence"}</span>
{essenceFull {essenceFull
? <span className="resource-cap-badge" title={resourceFullTooltip}> ? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"} {"FULL"}
</span> </span>
: null} : null}
@@ -129,17 +203,24 @@ const ResourceBar = ({
? " resource-full" ? " resource-full"
: ""}`}> : ""}`}>
<span className="resource-icon">{"💎"}</span> <span className="resource-icon">{"💎"}</span>
<span className="resource-value">{formatNumber(crystals)}</span> <span className="resource-value">
{formatNumber(crystals)}
</span>
<span className="resource-label">{"Crystals"}</span> <span className="resource-label">{"Crystals"}</span>
{crystalsFull {crystalsFull
? <span className="resource-cap-badge" title={resourceFullTooltip}> ? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"} {"FULL"}
</span> </span>
: null} : null}
</div> </div>
<div className="resource"> <div className="resource">
<span className="resource-icon">{"🔮"}</span> <span className="resource-icon">{"🔮"}</span>
<span className="resource-value">{formatNumber(runestones)}</span> <span className="resource-value">
{formatNumber(runestones)}
</span>
<span className="resource-label">{"Runestones"}</span> <span className="resource-label">{"Runestones"}</span>
</div> </div>
<div className="resource"> <div className="resource">
@@ -149,6 +230,9 @@ const ResourceBar = ({
</span> </span>
<span className="resource-label">{"Combat Power"}</span> <span className="resource-label">{"Combat Power"}</span>
</div> </div>
</div>
: null}
</div>
{apotheosisCount > 0 {apotheosisCount > 0
&& <div className="apotheosis-badge"> && <div className="apotheosis-badge">
{"✨ Apotheosis "} {"✨ Apotheosis "}
@@ -167,34 +251,7 @@ const ResourceBar = ({
{prestigeCount} {prestigeCount}
</div> </div>
} }
<div className="profile-buttons"> <div className="resource-bar-actions">
<a
className="profile-link-button"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Support the developer"
>
{"💜"} <span className="btn-label">{"Donate"}</span>
</a>
<a
className="profile-link-button"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Join our Discord"
>
{"💬"} <span className="btn-label">{"Discord"}</span>
</a>
<a
className="profile-link-button"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Get support on our forum"
>
{"🆘"} <span className="btn-label">{"Support"}</span>
</a>
{syncError === null {syncError === null
? null ? null
: <span className="save-status save-error" title={syncError}> : <span className="save-status save-error" title={syncError}>
@@ -221,23 +278,69 @@ const ResourceBar = ({
? "⏳" ? "⏳"
: "💾"} : "💾"}
</button> </button>
{avatarUrl === null
? null
: <div
className="profile-menu"
onBlur={handleProfileBlur}
>
<button
className="profile-avatar-button"
onClick={handleToggleProfile}
title="Account"
type="button"
>
<img
alt="Profile"
className="profile-avatar-img"
src={avatarUrl}
/>
</button>
{isProfileOpen
? <div className="profile-dropdown">
<a <a
className="profile-link-button" className="profile-dropdown-item"
href={profileUrl} href={profileUrl}
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
title="View your public profile"
> >
{"👤"} <span className="btn-label">{"Profile"}</span> {"👤 View Profile"}
</a> </a>
<button <button
className="profile-edit-button" className="profile-dropdown-item"
onClick={onEditProfile} onClick={handleEditProfile}
title="Edit your profile"
type="button" type="button"
> >
{"✏️"} {"✏️ Edit Profile"}
</button> </button>
<hr className="profile-dropdown-divider" />
<a
className="profile-dropdown-item"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💜 Donate"}
</a>
<a
className="profile-dropdown-item"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💬 Discord"}
</a>
<a
className="profile-dropdown-item"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"🆘 Support"}
</a>
</div>
: null}
</div>}
</div> </div>
</header> </header>
{anyFull {anyFull
+21 -6
View File
@@ -1094,11 +1094,7 @@ export const GameProvider = ({
return adventurer.unlocked && next.resources.gold >= cost; return adventurer.unlocked && next.resources.gold >= cost;
}). }).
sort((adventurerA, adventurerB) => { sort((adventurerA, adventurerB) => {
const costA return adventurerB.combatPower - adventurerA.combatPower;
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
const costB
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
return costB - costA;
}); });
if (bestAdventurer !== undefined) { if (bestAdventurer !== undefined) {
const purchaseCost const purchaseCost
@@ -1285,7 +1281,26 @@ export const GameProvider = ({
if (availableBoss !== undefined) { if (availableBoss !== undefined) {
const { id: bossId, name: bossName } = availableBoss; const { id: bossId, name: bossName } = availableBoss;
isAutoBossingReference.current = true; isAutoBossingReference.current = true;
void challengeBossApi({ bossId }). const syncBeforeBoss
= stateReference.current !== null && !isSyncingReference.current
? saveGame({
state: stateReference.current,
...signatureReference.current === null
? {}
: { signature: signatureReference.current },
}).then((response) => {
if (response.signature !== undefined) {
signatureReference.current = response.signature;
localStorage.setItem(
"elysium_save_signature",
response.signature,
);
}
})
: Promise.resolve();
void syncBeforeBoss.then(async() => {
return await challengeBossApi({ bossId });
}).
then((result) => { then((result) => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
+4 -4
View File
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
{ {
content: content:
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.", "The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
id: "upgrade_mage_1", id: "upgrade_apprentice_1",
sourceId: "mage_1", sourceId: "apprentice_1",
sourceType: "upgrade", sourceType: "upgrade",
title: "Arcane Tomes: The Written Knowledge", title: "Arcane Tomes: The Written Knowledge",
zoneId: "guild_library", zoneId: "guild_library",
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
{ {
content: content:
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.", "The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
id: "upgrade_cleric_1", id: "upgrade_acolyte_1",
sourceId: "cleric_1", sourceId: "acolyte_1",
sourceType: "upgrade", sourceType: "upgrade",
title: "Holy Rites: The Sacred Routine", title: "Holy Rites: The Sacred Routine",
zoneId: "guild_library", zoneId: "guild_library",
+72
View File
@@ -123,6 +123,78 @@ const capResource = (value: number): number => {
return Math.min(value, RESOURCE_CAP); return Math.min(value, RESOURCE_CAP);
}; };
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
* Returns a new GameState (does not mutate the original).
* @param state - The current game state.
* @param deltaSeconds - Time elapsed since last tick in seconds.
* @returns A new GameState with the tick applied.
*/
/**
* Computes the effective gold earned per second across all adventurers,
* including all active multipliers (upgrades, prestige, equipment, etc.).
* @param state - The current game state.
* @returns Gold per second as a number.
*/
export const computeGoldPerSecond = (state: GameState): number => {
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
return item.equipped;
});
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.goldMultiplier ?? 1);
}, 1);
const setGoldMultiplier = computeSetBonuses(
equippedItems.map((item) => {
return item.id;
}),
EQUIPMENT_SETS,
).goldMultiplier;
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionGoldMult
= companionBonus?.type === "passiveGold"
? 1 + companionBonus.value
: 1;
let goldPerSecond = 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.goldPerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesIncome
* echoIncome
* equipmentGoldMultiplier
* setGoldMultiplier
* craftedGoldMultiplier
* companionGoldMult;
goldPerSecond = goldPerSecond + contribution;
}
return goldPerSecond;
};
/** /**
* Pure function — applies one game tick to the state. * Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick. * DeltaSeconds: time elapsed since last tick.
+124 -43
View File
@@ -116,6 +116,66 @@ body::before {
text-align: center; text-align: center;
} }
/* ── Resource toggle + dropdown ─────────────────────────────────────────── */
.resource-menu {
position: relative;
}
.resource-toggle {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0.3rem 0.6rem;
position: relative;
transition: background 0.2s, border-color 0.2s;
}
.resource-toggle:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary);
}
.resource-alert-dot {
background: var(--colour-warning, #f59e0b);
border-radius: 50%;
height: 0.45rem;
position: absolute;
right: 0;
top: 0;
width: 0.45rem;
}
.resources-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 0.1rem;
left: 0;
padding: 0.4rem;
position: absolute;
top: calc(100% + 0.4rem);
z-index: 100;
}
.resources-dropdown .resource {
border-radius: 0.35rem;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
white-space: nowrap;
}
.resources-dropdown .resource:hover {
background: rgba(255, 255, 255, 0.04);
}
/* ===================== GAME LAYOUT ===================== */ /* ===================== GAME LAYOUT ===================== */
.game-layout { .game-layout {
display: flex; display: flex;
@@ -1492,57 +1552,87 @@ body::before {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */ /* ── Resource bar actions (save + profile menu) ─────────────────────────── */
.profile-buttons { .resource-bar-actions {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
margin-left: auto; margin-left: auto;
} }
.profile-link-button { .profile-menu {
align-items: center; position: relative;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 1rem;
color: var(--colour-text-muted);
display: flex;
font-size: 0.8rem;
gap: 0.3rem;
padding: 0.3rem 0.8rem;
text-decoration: none;
transition: all 0.2s;
white-space: nowrap;
} }
.profile-link-button:hover { .profile-avatar-button {
background: rgba(147, 51, 234, 0.2); background: none;
border-color: var(--colour-primary); border: 2px solid rgba(147, 51, 234, 0.4);
color: var(--colour-text);
}
.profile-edit-button {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 50%; border-radius: 50%;
color: var(--colour-text-muted);
cursor: pointer; cursor: pointer;
font-family: inherit; display: flex;
font-size: 0.85rem;
height: 2rem; height: 2rem;
line-height: 1; overflow: hidden;
padding: 0; padding: 0;
transition: all 0.2s; transition: border-color 0.2s;
width: 2rem; width: 2rem;
} }
.profile-edit-button:hover { .profile-avatar-button:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary); border-color: var(--colour-primary);
}
.profile-avatar-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.profile-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
min-width: 10rem;
padding: 0.25rem;
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 100;
}
.profile-dropdown-item {
align-items: center;
background: none;
border: none;
border-radius: 0.35rem;
color: var(--colour-text-muted);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: 0.85rem;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
text-align: left;
text-decoration: none;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
width: 100%;
}
.profile-dropdown-item:hover {
background: rgba(147, 51, 234, 0.15);
color: var(--colour-text); color: var(--colour-text);
} }
.profile-dropdown-divider {
border: none;
border-top: 1px solid rgba(147, 51, 234, 0.2);
margin: 0.25rem 0;
}
.save-status { .save-status {
color: var(--colour-text-muted); color: var(--colour-text-muted);
font-size: 0.75rem; font-size: 0.75rem;
@@ -3167,10 +3257,10 @@ body::before {
display: none; display: none;
} }
/* Profile buttons fill their own row, aligned right */ /* Resource bar actions fill their own row, aligned right */
.profile-buttons { .resource-bar-actions {
margin-left: 0;
justify-content: flex-end; justify-content: flex-end;
margin-left: 0;
width: 100%; width: 100%;
} }
@@ -3240,15 +3330,6 @@ body::before {
/* --- Small mobile (≤ 480px) --------------------------- */ /* --- Small mobile (≤ 480px) --------------------------- */
@media (max-width: 480px) { @media (max-width: 480px) {
/* Icon-only profile link buttons to save horizontal space */
.btn-label {
display: none;
}
.profile-link-button {
padding: 0.3rem 0.5rem;
}
/* Slightly smaller tab buttons */ /* Slightly smaller tab buttons */
.tab-button { .tab-button {
font-size: 0.8rem; font-size: 0.8rem;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.2.1", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.2.1", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",