generated from nhcarrigan/template
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9f9edae45e
|
|||
|
a7a255dab6
|
|||
|
e92cf3c9a1
|
|||
|
26d30c271d
|
|||
| 34d07bec95 | |||
| 3ac1d566cb | |||
|
7bd6b2d3e3
|
|||
| 354b7e372e | |||
| dc1782bec9 | |||
| 635c630e49 | |||
| bb60ae3390 | |||
| ee47c1e8c9 | |||
| 2236d1dc9f | |||
|
621f594018
|
|||
| 1e845b14ce |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/api",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
},
|
||||
{
|
||||
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: "🌟",
|
||||
id: "devourer_slayer",
|
||||
name: "World Saver",
|
||||
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 40, type: "equipmentOwned" },
|
||||
description: "Own 40 pieces of equipment.",
|
||||
condition: { amount: 65, type: "equipmentOwned" },
|
||||
description: "Own all 65 pieces of equipment.",
|
||||
icon: "🛡️",
|
||||
id: "fully_equipped",
|
||||
name: "Fully Equipped",
|
||||
@@ -289,8 +289,8 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 72, type: "questsCompleted" },
|
||||
description: "Complete all 72 quests across the known multiverse.",
|
||||
condition: { amount: 95, type: "questsCompleted" },
|
||||
description: "Complete all 95 quests across the known multiverse.",
|
||||
icon: "🌌",
|
||||
id: "quest_eternal",
|
||||
name: "Quest Eternal",
|
||||
@@ -317,8 +317,8 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 60, type: "bossesDefeated" },
|
||||
description: "Defeat all 60 bosses across every plane of existence.",
|
||||
condition: { amount: 72, type: "bossesDefeated" },
|
||||
description: "Defeat all 72 bosses across every plane of existence.",
|
||||
icon: "💀",
|
||||
id: "boss_eternal",
|
||||
name: "Eternal Vanquisher",
|
||||
|
||||
@@ -129,27 +129,39 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 4_000_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 18_000,
|
||||
baseCost: 2_600_000_000,
|
||||
class: "mage",
|
||||
combatPower: 13_000,
|
||||
count: 0,
|
||||
essencePerSecond: 6,
|
||||
goldPerSecond: 5000,
|
||||
id: "shadow_assassin",
|
||||
goldPerSecond: 4500,
|
||||
id: "arcane_scholar",
|
||||
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",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 28_000_000_000,
|
||||
class: "mage",
|
||||
combatPower: 45_000,
|
||||
baseCost: 47_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 60_000,
|
||||
count: 0,
|
||||
essencePerSecond: 15,
|
||||
goldPerSecond: 14_000,
|
||||
id: "arcane_scholar",
|
||||
level: 12,
|
||||
name: "Arcane Scholar",
|
||||
essencePerSecond: 20,
|
||||
goldPerSecond: 20_000,
|
||||
id: "dark_templar",
|
||||
level: 13,
|
||||
name: "Dark Templar",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
@@ -160,7 +172,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 35,
|
||||
goldPerSecond: 40_000,
|
||||
id: "void_walker",
|
||||
level: 13,
|
||||
level: 14,
|
||||
name: "Void Walker",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -172,7 +184,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 100,
|
||||
goldPerSecond: 120_000,
|
||||
id: "celestial_guard",
|
||||
level: 14,
|
||||
level: 15,
|
||||
name: "Celestial Guard",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 300,
|
||||
goldPerSecond: 400_000,
|
||||
id: "divine_champion",
|
||||
level: 15,
|
||||
level: 16,
|
||||
name: "Divine Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 800,
|
||||
goldPerSecond: 1_200_000,
|
||||
id: "seraph_knight",
|
||||
level: 16,
|
||||
level: 17,
|
||||
name: "Seraph Knight",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 2000,
|
||||
goldPerSecond: 3_500_000,
|
||||
id: "abyss_diver",
|
||||
level: 17,
|
||||
level: 18,
|
||||
name: "Abyss Diver",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 5000,
|
||||
goldPerSecond: 10_000_000,
|
||||
id: "infernal_warden",
|
||||
level: 18,
|
||||
level: 19,
|
||||
name: "Infernal Warden",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 12_000,
|
||||
goldPerSecond: 30_000_000,
|
||||
id: "crystal_sage",
|
||||
level: 19,
|
||||
level: 20,
|
||||
name: "Crystal Sage",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 30_000,
|
||||
goldPerSecond: 90_000_000,
|
||||
id: "void_sentinel",
|
||||
level: 20,
|
||||
level: 21,
|
||||
name: "Void Sentinel",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 80_000,
|
||||
goldPerSecond: 270_000_000,
|
||||
id: "eternal_champion",
|
||||
level: 21,
|
||||
level: 22,
|
||||
name: "Eternal Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 220_000,
|
||||
goldPerSecond: 800_000_000,
|
||||
id: "aether_weaver",
|
||||
level: 22,
|
||||
level: 23,
|
||||
name: "Aether Weaver",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 600_000,
|
||||
goldPerSecond: 2_500_000_000,
|
||||
id: "titan_warrior",
|
||||
level: 23,
|
||||
level: 24,
|
||||
name: "Titan Warrior",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 1_600_000,
|
||||
goldPerSecond: 7_500_000_000,
|
||||
id: "nexus_sage",
|
||||
level: 24,
|
||||
level: 25,
|
||||
name: "Nexus Sage",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 4_500_000,
|
||||
goldPerSecond: 22_000_000_000,
|
||||
id: "cosmos_knight",
|
||||
level: 25,
|
||||
level: 26,
|
||||
name: "Cosmos Knight",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 12_000_000,
|
||||
goldPerSecond: 65_000_000_000,
|
||||
id: "astral_sovereign",
|
||||
level: 26,
|
||||
level: 27,
|
||||
name: "Astral Sovereign",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 35_000_000,
|
||||
goldPerSecond: 200_000_000_000,
|
||||
id: "primordial_mage",
|
||||
level: 27,
|
||||
level: 28,
|
||||
name: "Primordial Mage",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 100_000_000,
|
||||
goldPerSecond: 600_000_000_000,
|
||||
id: "reality_warden",
|
||||
level: 28,
|
||||
level: 29,
|
||||
name: "Reality Warden",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 300_000_000,
|
||||
goldPerSecond: 1_800_000_000_000,
|
||||
id: "infinity_ranger",
|
||||
level: 29,
|
||||
level: 30,
|
||||
name: "Infinity Ranger",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 850_000_000,
|
||||
goldPerSecond: 5_500_000_000_000,
|
||||
id: "oblivion_paladin",
|
||||
level: 30,
|
||||
level: 31,
|
||||
name: "Oblivion Paladin",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 2_500_000_000,
|
||||
goldPerSecond: 16_000_000_000_000,
|
||||
id: "transcendent_rogue",
|
||||
level: 31,
|
||||
level: 32,
|
||||
name: "Transcendent Rogue",
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
essencePerSecond: 7_000_000_000,
|
||||
goldPerSecond: 50_000_000_000_000,
|
||||
id: "omniversal_champion",
|
||||
level: 32,
|
||||
level: 33,
|
||||
name: "Omniversal Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
+46
-46
@@ -121,17 +121,17 @@ export const defaultBosses: Array<Boss> = [
|
||||
},
|
||||
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 5,
|
||||
crystalReward: 30,
|
||||
currentHp: 80_000,
|
||||
damagePerSecond: 80,
|
||||
bountyRunestones: 20,
|
||||
crystalReward: 700,
|
||||
currentHp: 6_000_000,
|
||||
damagePerSecond: 1200,
|
||||
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.",
|
||||
equipmentRewards: [],
|
||||
essenceReward: 800,
|
||||
goldReward: 800_000,
|
||||
essenceReward: 30_000,
|
||||
goldReward: 80_000_000,
|
||||
id: "swamp_witch",
|
||||
maxHp: 80_000,
|
||||
maxHp: 6_000_000,
|
||||
name: "Morgantha the Swamp Witch",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
@@ -139,17 +139,17 @@ export const defaultBosses: Array<Boss> = [
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 8,
|
||||
crystalReward: 80,
|
||||
currentHp: 300_000,
|
||||
damagePerSecond: 180,
|
||||
bountyRunestones: 25,
|
||||
crystalReward: 1500,
|
||||
currentHp: 12_000_000,
|
||||
damagePerSecond: 2400,
|
||||
description:
|
||||
"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" ],
|
||||
essenceReward: 2000,
|
||||
goldReward: 3_000_000,
|
||||
essenceReward: 60_000,
|
||||
goldReward: 180_000_000,
|
||||
id: "plague_lord",
|
||||
maxHp: 300_000,
|
||||
maxHp: 12_000_000,
|
||||
name: "The Plague Lord",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
@@ -157,17 +157,17 @@ export const defaultBosses: Array<Boss> = [
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 10,
|
||||
crystalReward: 150,
|
||||
currentHp: 800_000,
|
||||
damagePerSecond: 350,
|
||||
bountyRunestones: 30,
|
||||
crystalReward: 3000,
|
||||
currentHp: 20_000_000,
|
||||
damagePerSecond: 4000,
|
||||
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.",
|
||||
equipmentRewards: [ "crystal_shard" ],
|
||||
essenceReward: 4000,
|
||||
goldReward: 8_000_000,
|
||||
essenceReward: 100_000,
|
||||
goldReward: 350_000_000,
|
||||
id: "mud_kraken",
|
||||
maxHp: 800_000,
|
||||
maxHp: 20_000_000,
|
||||
name: "The Mud Kraken",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
@@ -231,53 +231,53 @@ export const defaultBosses: Array<Boss> = [
|
||||
},
|
||||
// ── Volcanic Depths ───────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 12,
|
||||
crystalReward: 150,
|
||||
currentHp: 1_000_000,
|
||||
damagePerSecond: 400,
|
||||
bountyRunestones: 32,
|
||||
crystalReward: 4000,
|
||||
currentHp: 25_000_000,
|
||||
damagePerSecond: 5000,
|
||||
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.",
|
||||
equipmentRewards: [ "flame_lance" ],
|
||||
essenceReward: 6000,
|
||||
goldReward: 10_000_000,
|
||||
essenceReward: 150_000,
|
||||
goldReward: 500_000_000,
|
||||
id: "fire_elemental",
|
||||
maxHp: 1_000_000,
|
||||
maxHp: 25_000_000,
|
||||
name: "The Ancient Fire Elemental",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "celestial_guard_1" ],
|
||||
upgradeRewards: [ "dark_templar_1" ],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 18,
|
||||
crystalReward: 400,
|
||||
currentHp: 4_000_000,
|
||||
damagePerSecond: 1000,
|
||||
bountyRunestones: 40,
|
||||
crystalReward: 8000,
|
||||
currentHp: 60_000_000,
|
||||
damagePerSecond: 12_000,
|
||||
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.",
|
||||
equipmentRewards: [ "volcanic_plate" ],
|
||||
essenceReward: 15_000,
|
||||
goldReward: 40_000_000,
|
||||
essenceReward: 300_000,
|
||||
goldReward: 1_000_000_000,
|
||||
id: "magma_titan",
|
||||
maxHp: 4_000_000,
|
||||
maxHp: 60_000_000,
|
||||
name: "The Magma Titan",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "crystal_resonance" ],
|
||||
upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 25,
|
||||
crystalReward: 800,
|
||||
currentHp: 12_000_000,
|
||||
damagePerSecond: 2500,
|
||||
bountyRunestones: 50,
|
||||
crystalReward: 15_000,
|
||||
currentHp: 150_000_000,
|
||||
damagePerSecond: 30_000,
|
||||
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.",
|
||||
equipmentRewards: [ "eternal_flame" ],
|
||||
essenceReward: 40_000,
|
||||
goldReward: 120_000_000,
|
||||
essenceReward: 600_000,
|
||||
goldReward: 2_000_000_000,
|
||||
id: "phoenix_lord",
|
||||
maxHp: 12_000_000,
|
||||
maxHp: 150_000_000,
|
||||
name: "The Phoenix Lord",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
@@ -1120,7 +1120,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
name: "The Storm Colossus",
|
||||
prestigeRequirement: 51,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "cosmos_knight_1" ],
|
||||
upgradeRewards: [],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_absolute_one",
|
||||
maxHp: 2e145,
|
||||
name: "The Absolute One",
|
||||
prestigeRequirement: 90,
|
||||
prestigeRequirement: 88,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "the_absolute",
|
||||
|
||||
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
type: "trinket",
|
||||
},
|
||||
{
|
||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
||||
bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
|
||||
description:
|
||||
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
||||
equipped: false,
|
||||
@@ -757,7 +757,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
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 },
|
||||
description:
|
||||
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
||||
|
||||
@@ -92,18 +92,18 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
||||
{
|
||||
category: "income",
|
||||
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",
|
||||
multiplier: 500,
|
||||
multiplier: 200,
|
||||
name: "Eternal Rune I",
|
||||
runestonesCost: 30_000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
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",
|
||||
multiplier: 1000,
|
||||
multiplier: 500,
|
||||
name: "Eternal Rune II",
|
||||
runestonesCost: 80_000,
|
||||
},
|
||||
|
||||
+152
-76
@@ -33,6 +33,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 2000, type: "gold" },
|
||||
{ amount: 5, type: "essence" },
|
||||
{ targetId: "peasant_1", type: "upgrade" },
|
||||
{ targetId: "apprentice", type: "adventurer" },
|
||||
],
|
||||
status: "locked",
|
||||
@@ -64,7 +65,6 @@ export const defaultQuests: Array<Quest> = [
|
||||
prerequisiteIds: [ "haunted_mine" ],
|
||||
rewards: [
|
||||
{ amount: 50, type: "essence" },
|
||||
{ targetId: "click_2", type: "upgrade" },
|
||||
{ targetId: "acolyte", type: "adventurer" },
|
||||
],
|
||||
status: "locked",
|
||||
@@ -82,7 +82,8 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 15_000, type: "gold" },
|
||||
{ amount: 20, type: "essence" },
|
||||
{ targetId: "cleric_1", type: "upgrade" },
|
||||
{ targetId: "militia_1", type: "upgrade" },
|
||||
{ targetId: "acolyte_1", type: "upgrade" },
|
||||
{ targetId: "ranger", type: "adventurer" },
|
||||
],
|
||||
status: "locked",
|
||||
@@ -116,7 +117,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 300, type: "essence" },
|
||||
{ amount: 30, type: "crystals" },
|
||||
{ targetId: "mage_1", type: "upgrade" },
|
||||
{ targetId: "apprentice_1", type: "upgrade" },
|
||||
{ targetId: "archmage", type: "adventurer" },
|
||||
],
|
||||
status: "locked",
|
||||
@@ -139,69 +140,6 @@ export const defaultQuests: Array<Quest> = [
|
||||
status: "locked",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 5000,
|
||||
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,
|
||||
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,
|
||||
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: 75, type: "crystals" },
|
||||
{ targetId: "knight_1", type: "upgrade" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 300_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 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 100_000,
|
||||
@@ -246,14 +184,78 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 30_000_000, type: "gold" },
|
||||
{ amount: 10_000, type: "essence" },
|
||||
{ targetId: "peasant_1", type: "upgrade" },
|
||||
],
|
||||
status: "locked",
|
||||
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 ───────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 2_000_000,
|
||||
combatPowerRequired: 1_200_000_000,
|
||||
description:
|
||||
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
|
||||
durationSeconds: 3 * 60 * 60,
|
||||
@@ -263,12 +265,13 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 15_000_000, type: "gold" },
|
||||
{ amount: 4000, type: "essence" },
|
||||
{ targetId: "void_walker", type: "adventurer" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 8_000_000,
|
||||
combatPowerRequired: 4_800_000_000,
|
||||
description:
|
||||
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
|
||||
durationSeconds: 5 * 60 * 60,
|
||||
@@ -276,15 +279,16 @@ export const defaultQuests: Array<Quest> = [
|
||||
name: "The Temple of the Flame",
|
||||
prerequisiteIds: [ "lava_flows" ],
|
||||
rewards: [
|
||||
{ amount: 40_000_000, type: "gold" },
|
||||
{ amount: 12_000, type: "essence" },
|
||||
{ amount: 300, type: "crystals" },
|
||||
{ targetId: "void_walker", type: "adventurer" },
|
||||
{ targetId: "peasant_3", type: "upgrade" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 30_000_000,
|
||||
combatPowerRequired: 18_000_000_000,
|
||||
description:
|
||||
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
|
||||
durationSeconds: 7 * 60 * 60,
|
||||
@@ -300,7 +304,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 120_000_000,
|
||||
combatPowerRequired: 72_000_000_000,
|
||||
description:
|
||||
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
|
||||
durationSeconds: 10 * 60 * 60,
|
||||
@@ -317,7 +321,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Astral Void ───────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 50_000_000,
|
||||
combatPowerRequired: 300_000_000_000,
|
||||
description:
|
||||
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
|
||||
durationSeconds: 4 * 60 * 60,
|
||||
@@ -332,7 +336,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 200_000_000,
|
||||
combatPowerRequired: 1_200_000_000_000,
|
||||
description:
|
||||
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
|
||||
durationSeconds: 8 * 60 * 60,
|
||||
@@ -348,7 +352,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 800_000_000,
|
||||
combatPowerRequired: 4_800_000_000_000,
|
||||
description:
|
||||
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
|
||||
durationSeconds: 12 * 60 * 60,
|
||||
@@ -364,7 +368,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3_000_000_000,
|
||||
combatPowerRequired: 18_000_000_000_000,
|
||||
description:
|
||||
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
|
||||
durationSeconds: 24 * 60 * 60,
|
||||
@@ -381,6 +385,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Celestial Reaches ─────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 7.2e13,
|
||||
description:
|
||||
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
|
||||
durationSeconds: Math.round(1.5 * 60 * 60),
|
||||
@@ -396,6 +401,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e14,
|
||||
description:
|
||||
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
|
||||
durationSeconds: 3 * 60 * 60,
|
||||
@@ -410,6 +416,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e15,
|
||||
description:
|
||||
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
|
||||
durationSeconds: 5 * 60 * 60,
|
||||
@@ -425,6 +432,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e15,
|
||||
description:
|
||||
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
|
||||
durationSeconds: 8 * 60 * 60,
|
||||
@@ -440,6 +448,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e16,
|
||||
description:
|
||||
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
|
||||
durationSeconds: 12 * 60 * 60,
|
||||
@@ -456,6 +465,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e16,
|
||||
description:
|
||||
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
|
||||
durationSeconds: 20 * 60 * 60,
|
||||
@@ -472,6 +482,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Abyssal Trench ────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 3e17,
|
||||
description:
|
||||
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
|
||||
durationSeconds: 2 * 60 * 60,
|
||||
@@ -487,6 +498,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e18,
|
||||
description:
|
||||
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
|
||||
durationSeconds: 4 * 60 * 60,
|
||||
@@ -502,6 +514,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e18,
|
||||
description:
|
||||
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
|
||||
durationSeconds: 7 * 60 * 60,
|
||||
@@ -517,6 +530,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e19,
|
||||
description:
|
||||
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
|
||||
durationSeconds: 12 * 60 * 60,
|
||||
@@ -532,6 +546,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e19,
|
||||
description:
|
||||
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
|
||||
durationSeconds: 18 * 60 * 60,
|
||||
@@ -548,6 +563,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e20,
|
||||
description:
|
||||
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
|
||||
durationSeconds: 30 * 60 * 60,
|
||||
@@ -564,6 +580,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Infernal Court ────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 1.2e21,
|
||||
description:
|
||||
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
|
||||
durationSeconds: 3 * 60 * 60,
|
||||
@@ -579,6 +596,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e21,
|
||||
description:
|
||||
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
|
||||
durationSeconds: 6 * 60 * 60,
|
||||
@@ -594,6 +612,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e22,
|
||||
description:
|
||||
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
|
||||
durationSeconds: 10 * 60 * 60,
|
||||
@@ -609,6 +628,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e22,
|
||||
description:
|
||||
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
|
||||
durationSeconds: 16 * 60 * 60,
|
||||
@@ -624,6 +644,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e23,
|
||||
description:
|
||||
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
|
||||
durationSeconds: 24 * 60 * 60,
|
||||
@@ -640,6 +661,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e24,
|
||||
description:
|
||||
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
|
||||
durationSeconds: 40 * 60 * 60,
|
||||
@@ -656,6 +678,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Crystalline Spire ─────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 4.8e24,
|
||||
description:
|
||||
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
|
||||
durationSeconds: 4 * 60 * 60,
|
||||
@@ -671,6 +694,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e25,
|
||||
description:
|
||||
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
|
||||
durationSeconds: 8 * 60 * 60,
|
||||
@@ -686,6 +710,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e25,
|
||||
description:
|
||||
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
|
||||
durationSeconds: 14 * 60 * 60,
|
||||
@@ -701,6 +726,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e26,
|
||||
description:
|
||||
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
|
||||
durationSeconds: 20 * 60 * 60,
|
||||
@@ -716,6 +742,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e27,
|
||||
description:
|
||||
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
|
||||
durationSeconds: 32 * 60 * 60,
|
||||
@@ -732,6 +759,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e27,
|
||||
description:
|
||||
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
|
||||
durationSeconds: 50 * 60 * 60,
|
||||
@@ -748,6 +776,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Void Sanctum ──────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 1.8e28,
|
||||
description:
|
||||
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
|
||||
durationSeconds: 6 * 60 * 60,
|
||||
@@ -763,6 +792,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e28,
|
||||
description:
|
||||
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
|
||||
durationSeconds: 12 * 60 * 60,
|
||||
@@ -778,6 +808,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e29,
|
||||
description:
|
||||
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
|
||||
durationSeconds: 20 * 60 * 60,
|
||||
@@ -793,6 +824,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e30,
|
||||
description:
|
||||
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
|
||||
durationSeconds: 30 * 60 * 60,
|
||||
@@ -808,6 +840,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e30,
|
||||
description:
|
||||
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
|
||||
durationSeconds: 48 * 60 * 60,
|
||||
@@ -824,6 +857,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e31,
|
||||
description:
|
||||
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
|
||||
durationSeconds: 72 * 60 * 60,
|
||||
@@ -840,6 +874,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Eternal Throne ────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 7.2e31,
|
||||
description:
|
||||
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
|
||||
durationSeconds: 8 * 60 * 60,
|
||||
@@ -855,6 +890,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e32,
|
||||
description:
|
||||
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
|
||||
durationSeconds: 16 * 60 * 60,
|
||||
@@ -870,6 +906,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e33,
|
||||
description:
|
||||
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
|
||||
durationSeconds: 28 * 60 * 60,
|
||||
@@ -885,6 +922,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e33,
|
||||
description:
|
||||
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
|
||||
durationSeconds: 40 * 60 * 60,
|
||||
@@ -901,6 +939,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e34,
|
||||
description:
|
||||
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
|
||||
durationSeconds: 60 * 60 * 60,
|
||||
@@ -916,6 +955,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e34,
|
||||
description:
|
||||
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
|
||||
durationSeconds: 96 * 60 * 60,
|
||||
@@ -932,6 +972,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Primordial Chaos ──────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 3e35,
|
||||
description:
|
||||
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
|
||||
durationSeconds: 10 * 60 * 60,
|
||||
@@ -947,6 +988,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e36,
|
||||
description:
|
||||
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
|
||||
durationSeconds: 18 * 60 * 60,
|
||||
@@ -962,6 +1004,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e36,
|
||||
description:
|
||||
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
|
||||
durationSeconds: 30 * 60 * 60,
|
||||
@@ -978,6 +1021,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e37,
|
||||
description:
|
||||
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
|
||||
durationSeconds: 45 * 60 * 60,
|
||||
@@ -993,6 +1037,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e37,
|
||||
description:
|
||||
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
|
||||
durationSeconds: 65 * 60 * 60,
|
||||
@@ -1009,6 +1054,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e38,
|
||||
description:
|
||||
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
|
||||
durationSeconds: 90 * 60 * 60,
|
||||
@@ -1025,6 +1071,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Infinite Expanse ──────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 1.2e39,
|
||||
description:
|
||||
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
|
||||
durationSeconds: 12 * 60 * 60,
|
||||
@@ -1040,6 +1087,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e39,
|
||||
description:
|
||||
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
|
||||
durationSeconds: 22 * 60 * 60,
|
||||
@@ -1055,6 +1103,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e40,
|
||||
description:
|
||||
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
|
||||
durationSeconds: 36 * 60 * 60,
|
||||
@@ -1071,6 +1120,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e40,
|
||||
description:
|
||||
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
|
||||
durationSeconds: 55 * 60 * 60,
|
||||
@@ -1087,6 +1137,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e41,
|
||||
description:
|
||||
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
|
||||
durationSeconds: 80 * 60 * 60,
|
||||
@@ -1102,6 +1153,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e42,
|
||||
description:
|
||||
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
|
||||
durationSeconds: 110 * 60 * 60,
|
||||
@@ -1118,6 +1170,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Reality Forge ─────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 4.8e42,
|
||||
description:
|
||||
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
|
||||
durationSeconds: 14 * 60 * 60,
|
||||
@@ -1133,6 +1186,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e43,
|
||||
description:
|
||||
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
|
||||
durationSeconds: 25 * 60 * 60,
|
||||
@@ -1148,6 +1202,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e43,
|
||||
description:
|
||||
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
|
||||
durationSeconds: 40 * 60 * 60,
|
||||
@@ -1164,6 +1219,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e44,
|
||||
description:
|
||||
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
|
||||
durationSeconds: 60 * 60 * 60,
|
||||
@@ -1180,6 +1236,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e45,
|
||||
description:
|
||||
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
|
||||
durationSeconds: 85 * 60 * 60,
|
||||
@@ -1195,6 +1252,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e45,
|
||||
description:
|
||||
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
|
||||
durationSeconds: 120 * 60 * 60,
|
||||
@@ -1211,6 +1269,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 1.8e46,
|
||||
description:
|
||||
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
|
||||
durationSeconds: 16 * 60 * 60,
|
||||
@@ -1226,6 +1285,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e46,
|
||||
description:
|
||||
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
|
||||
durationSeconds: 28 * 60 * 60,
|
||||
@@ -1241,6 +1301,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e47,
|
||||
description:
|
||||
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
|
||||
durationSeconds: 45 * 60 * 60,
|
||||
@@ -1257,6 +1318,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e48,
|
||||
description:
|
||||
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
|
||||
durationSeconds: 65 * 60 * 60,
|
||||
@@ -1273,6 +1335,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e48,
|
||||
description:
|
||||
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
|
||||
durationSeconds: 90 * 60 * 60,
|
||||
@@ -1288,6 +1351,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e49,
|
||||
description:
|
||||
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
|
||||
durationSeconds: 130 * 60 * 60,
|
||||
@@ -1304,6 +1368,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── Primeval Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 7.2e49,
|
||||
description:
|
||||
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
|
||||
durationSeconds: 18 * 60 * 60,
|
||||
@@ -1319,6 +1384,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e50,
|
||||
description:
|
||||
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
|
||||
durationSeconds: 32 * 60 * 60,
|
||||
@@ -1334,6 +1400,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e51,
|
||||
description:
|
||||
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
|
||||
durationSeconds: 50 * 60 * 60,
|
||||
@@ -1350,6 +1417,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e51,
|
||||
description:
|
||||
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
|
||||
durationSeconds: 72 * 60 * 60,
|
||||
@@ -1366,6 +1434,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e52,
|
||||
description:
|
||||
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
|
||||
durationSeconds: 100 * 60 * 60,
|
||||
@@ -1381,6 +1450,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e52,
|
||||
description:
|
||||
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
|
||||
durationSeconds: 144 * 60 * 60,
|
||||
@@ -1397,6 +1467,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
},
|
||||
// ── The Absolute ──────────────────────────────────────────────────────────
|
||||
{
|
||||
combatPowerRequired: 3e53,
|
||||
description:
|
||||
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
|
||||
durationSeconds: 20 * 60 * 60,
|
||||
@@ -1412,6 +1483,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.2e54,
|
||||
description:
|
||||
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
|
||||
durationSeconds: 36 * 60 * 60,
|
||||
@@ -1427,6 +1499,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 4.8e54,
|
||||
description:
|
||||
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
|
||||
durationSeconds: 56 * 60 * 60,
|
||||
@@ -1443,6 +1516,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1.8e55,
|
||||
description:
|
||||
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
|
||||
durationSeconds: 80 * 60 * 60,
|
||||
@@ -1458,6 +1532,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 7.2e55,
|
||||
description:
|
||||
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
|
||||
durationSeconds: 120 * 60 * 60,
|
||||
@@ -1474,6 +1549,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3e56,
|
||||
description:
|
||||
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
|
||||
durationSeconds: 168 * 60 * 60,
|
||||
|
||||
@@ -451,6 +451,62 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
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
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
|
||||
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 10,
|
||||
cost: 50,
|
||||
description:
|
||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||
id: "echo_meta_1",
|
||||
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 25,
|
||||
cost: 150,
|
||||
description:
|
||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||
id: "echo_meta_2",
|
||||
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 50,
|
||||
cost: 400,
|
||||
description:
|
||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||
id: "echo_meta_3",
|
||||
|
||||
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
target: "adventurer",
|
||||
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",
|
||||
costCrystals: 0,
|
||||
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
costEssence: 2,
|
||||
costGold: 5000,
|
||||
description: "Ancient books of magic double mage output.",
|
||||
id: "mage_1",
|
||||
id: "apprentice_1",
|
||||
multiplier: 2,
|
||||
name: "Arcane Tomes",
|
||||
purchased: false,
|
||||
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
costEssence: 3,
|
||||
costGold: 8000,
|
||||
description: "Sacred ceremonies double the output of your clerics.",
|
||||
id: "cleric_1",
|
||||
id: "acolyte_1",
|
||||
multiplier: 2,
|
||||
name: "Holy Rites",
|
||||
purchased: false,
|
||||
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
target: "adventurer",
|
||||
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",
|
||||
costCrystals: 0,
|
||||
costEssence: 150,
|
||||
costEssence: 1000,
|
||||
costGold: 0,
|
||||
description: "Access to forbidden libraries doubles scholar output.",
|
||||
id: "arcane_scholar_1",
|
||||
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
target: "adventurer",
|
||||
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",
|
||||
costCrystals: 0,
|
||||
costEssence: 300,
|
||||
costEssence: 100_000,
|
||||
costGold: 0,
|
||||
description:
|
||||
"Walking through the void itself doubles the output of your void walkers.",
|
||||
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
{
|
||||
adventurerId: "celestial_guard",
|
||||
costCrystals: 0,
|
||||
costEssence: 750,
|
||||
costEssence: 500_000,
|
||||
costGold: 0,
|
||||
description:
|
||||
"A blessing from the celestials themselves doubles guard output.",
|
||||
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
{
|
||||
adventurerId: "divine_champion",
|
||||
costCrystals: 0,
|
||||
costEssence: 2000,
|
||||
costEssence: 2_000_000,
|
||||
costGold: 0,
|
||||
description: "An unbreakable oath to the divine doubles champion output.",
|
||||
id: "divine_champion_1",
|
||||
|
||||
@@ -7,18 +7,26 @@
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
STORY_CHAPTERS,
|
||||
isStoryChapterUnlocked,
|
||||
type GameState,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { defaultAchievements } from "../data/achievements.js";
|
||||
import { defaultAdventurers } from "../data/adventurers.js";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipment } from "../data/equipment.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultQuests } from "../data/quests.js";
|
||||
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||
import { defaultUpgrades } from "../data/upgrades.js";
|
||||
import { defaultZones } from "../data/zones.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Computes the HMAC-SHA256 of data using the given secret.
|
||||
@@ -257,6 +265,180 @@ const applyBossUnlocks = (state: GameState): number => {
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unlocks any adventurer tiers that were granted as rewards for completed quests
|
||||
* but are still locked in the player's state.
|
||||
* @param state - The player's current game state (mutated directly).
|
||||
* @returns The number of adventurer tiers that were unlocked.
|
||||
*/
|
||||
const applyAdventurerUnlocks = (state: GameState): number => {
|
||||
let count = 0;
|
||||
const completedQuestIds = new Set(
|
||||
state.quests.
|
||||
filter((q) => {
|
||||
return q.status === "completed";
|
||||
}).
|
||||
map((q) => {
|
||||
return q.id;
|
||||
}),
|
||||
);
|
||||
const earnedAdventurerIds = new Set<string>();
|
||||
|
||||
for (const questDefinition of defaultQuests) {
|
||||
if (!completedQuestIds.has(questDefinition.id)) {
|
||||
continue;
|
||||
}
|
||||
for (const reward of questDefinition.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
||||
earnedAdventurerIds.add(reward.targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
|
||||
adventurer.unlocked = true;
|
||||
count = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects all upgrade IDs the player has legitimately earned via boss defeats
|
||||
* and completed quest rewards, sourcing reward data from game definitions.
|
||||
* @param state - The player's current game state.
|
||||
* @returns A set of earned upgrade IDs.
|
||||
*/
|
||||
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
|
||||
const earnedIds = new Set<string>();
|
||||
const defeatedBossIds = new Set(
|
||||
state.bosses.
|
||||
filter((b) => {
|
||||
return b.status === "defeated";
|
||||
}).
|
||||
map((b) => {
|
||||
return b.id;
|
||||
}),
|
||||
);
|
||||
const completedQuestIds = new Set(
|
||||
state.quests.
|
||||
filter((q) => {
|
||||
return q.status === "completed";
|
||||
}).
|
||||
map((q) => {
|
||||
return q.id;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const bossDefinition of defaultBosses) {
|
||||
if (!defeatedBossIds.has(bossDefinition.id)) {
|
||||
continue;
|
||||
}
|
||||
for (const upgradeId of bossDefinition.upgradeRewards) {
|
||||
earnedIds.add(upgradeId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const questDefinition of defaultQuests) {
|
||||
if (!completedQuestIds.has(questDefinition.id)) {
|
||||
continue;
|
||||
}
|
||||
for (const reward of questDefinition.rewards) {
|
||||
if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
||||
earnedIds.add(reward.targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return earnedIds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unlocks any upgrades that were granted as rewards for defeated bosses or
|
||||
* completed quests but are still locked in the player's state.
|
||||
* @param state - The player's current game state (mutated directly).
|
||||
* @returns The number of upgrades that were unlocked.
|
||||
*/
|
||||
const applyUpgradeUnlocks = (state: GameState): number => {
|
||||
let count = 0;
|
||||
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
|
||||
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
|
||||
upgrade.unlocked = true;
|
||||
count = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks as owned any equipment that was granted as a reward for defeated bosses
|
||||
* but is still unowned in the player's state.
|
||||
* @param state - The player's current game state (mutated directly).
|
||||
* @returns The number of equipment items that were marked as owned.
|
||||
*/
|
||||
const applyEquipmentUnlocks = (state: GameState): number => {
|
||||
let count = 0;
|
||||
const defeatedBossIds = new Set(
|
||||
state.bosses.
|
||||
filter((b) => {
|
||||
return b.status === "defeated";
|
||||
}).
|
||||
map((b) => {
|
||||
return b.id;
|
||||
}),
|
||||
);
|
||||
const earnedEquipmentIds = new Set<string>();
|
||||
|
||||
for (const bossDefinition of defaultBosses) {
|
||||
if (!defeatedBossIds.has(bossDefinition.id)) {
|
||||
continue;
|
||||
}
|
||||
for (const equipmentId of bossDefinition.equipmentRewards) {
|
||||
earnedEquipmentIds.add(equipmentId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of state.equipment) {
|
||||
if (!item.owned && earnedEquipmentIds.has(item.id)) {
|
||||
item.owned = true;
|
||||
count = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unlocks any story chapters whose conditions are met by the current game state
|
||||
* but are still absent from the player's unlockedChapterIds list.
|
||||
* @param state - The player's current game state (mutated directly).
|
||||
* @returns The number of story chapters that were unlocked.
|
||||
*/
|
||||
const applyStoryUnlocks = (state: GameState): number => {
|
||||
if (state.story === undefined) {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
|
||||
|
||||
for (const chapter of STORY_CHAPTERS) {
|
||||
if (alreadyUnlocked.has(chapter.id)) {
|
||||
continue;
|
||||
}
|
||||
if (isStoryChapterUnlocked(chapter, state)) {
|
||||
state.story.unlockedChapterIds.push(chapter.id);
|
||||
count = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes available any exploration areas whose parent zone is now unlocked.
|
||||
* @param state - The player's current game state (mutated directly).
|
||||
@@ -301,18 +483,121 @@ const applyExplorationUnlocks = (state: GameState): number => {
|
||||
const applyForceUnlocks = (
|
||||
state: GameState,
|
||||
): {
|
||||
adventurersUnlocked: number;
|
||||
bossesUnlocked: number;
|
||||
equipmentUnlocked: number;
|
||||
explorationUnlocked: number;
|
||||
questsUnlocked: number;
|
||||
storyUnlocked: number;
|
||||
upgradesUnlocked: number;
|
||||
zonesUnlocked: number;
|
||||
} => {
|
||||
const zonesUnlocked = applyZoneUnlocks(state);
|
||||
const questsUnlocked = applyQuestUnlocks(state);
|
||||
const bossesUnlocked = applyBossUnlocks(state);
|
||||
const explorationUnlocked = applyExplorationUnlocks(state);
|
||||
return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked };
|
||||
const adventurersUnlocked = applyAdventurerUnlocks(state);
|
||||
const upgradesUnlocked = applyUpgradeUnlocks(state);
|
||||
const equipmentUnlocked = applyEquipmentUnlocks(state);
|
||||
const storyUnlocked = applyStoryUnlocks(state);
|
||||
return {
|
||||
adventurersUnlocked,
|
||||
bossesUnlocked,
|
||||
equipmentUnlocked,
|
||||
explorationUnlocked,
|
||||
questsUnlocked,
|
||||
storyUnlocked,
|
||||
upgradesUnlocked,
|
||||
zonesUnlocked,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any entries from a defaults array that are missing from an existing
|
||||
* saved array (matched by `id`), cloning each new entry before pushing.
|
||||
* @param existing - The player's saved array (mutated in place).
|
||||
* @param defaults - The current default data array to compare against.
|
||||
* @returns The number of entries that were added.
|
||||
*/
|
||||
const injectMissingEntries = <T extends { id: string }>(
|
||||
existing: Array<T>,
|
||||
defaults: Array<T>,
|
||||
): number => {
|
||||
const existingIds = new Set(existing.map((item) => {
|
||||
return item.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const item of defaults) {
|
||||
if (!existingIds.has(item.id)) {
|
||||
existing.push(structuredClone(item));
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
const defaultOrder = new Map(defaults.map((item, index) => {
|
||||
return [ item.id, index ] as const;
|
||||
}));
|
||||
existing.sort((itemA, itemB) => {
|
||||
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any exploration areas from the defaults that are missing from the
|
||||
* player's exploration state, seeding each new area as locked.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of exploration areas that were added.
|
||||
*/
|
||||
const injectMissingExplorationAreas = (state: GameState): number => {
|
||||
if (state.exploration === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const existingIds = new Set(state.exploration.areas.map((area) => {
|
||||
return area.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const area of defaultExplorations) {
|
||||
if (!existingIds.has(area.id)) {
|
||||
state.exploration.areas.push({ id: area.id, status: "locked" });
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
|
||||
/**
|
||||
* Syncs a player's save with the current game data, injecting any content
|
||||
* entries that are missing because they were added after the save was created.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns Counts of how many entries were added per content type.
|
||||
*/
|
||||
const syncNewContent = (
|
||||
state: GameState,
|
||||
): {
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
} => {
|
||||
return {
|
||||
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
|
||||
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
|
||||
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
|
||||
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
|
||||
explorationAreasAdded: injectMissingExplorationAreas(state),
|
||||
questsAdded: injectMissingEntries(state.quests, defaultQuests),
|
||||
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
|
||||
zonesAdded: injectMissingEntries(state.zones, defaultZones),
|
||||
};
|
||||
};
|
||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||
|
||||
const debugRouter = new Hono<HonoEnvironment>();
|
||||
debugRouter.use(authMiddleware);
|
||||
|
||||
@@ -330,8 +615,16 @@ debugRouter.post("/force-unlocks", async(context) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||
const state = gameStateRecord.state as unknown as GameState;
|
||||
|
||||
const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }
|
||||
= applyForceUnlocks(state);
|
||||
const {
|
||||
adventurersUnlocked,
|
||||
bossesUnlocked,
|
||||
equipmentUnlocked,
|
||||
explorationUnlocked,
|
||||
questsUnlocked,
|
||||
storyUnlocked,
|
||||
upgradesUnlocked,
|
||||
zonesUnlocked,
|
||||
} = applyForceUnlocks(state);
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await prisma.gameState.update({
|
||||
@@ -347,11 +640,15 @@ debugRouter.post("/force-unlocks", async(context) => {
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
|
||||
return context.json({
|
||||
adventurersUnlocked,
|
||||
bossesUnlocked,
|
||||
equipmentUnlocked,
|
||||
explorationUnlocked,
|
||||
questsUnlocked,
|
||||
signature,
|
||||
state,
|
||||
storyUnlocked,
|
||||
upgradesUnlocked,
|
||||
zonesUnlocked,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -365,6 +662,67 @@ debugRouter.post("/force-unlocks", async(context) => {
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/sync-new-content", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const gameStateRecord = await prisma.gameState.findUnique({
|
||||
where: { discordId },
|
||||
});
|
||||
if (!gameStateRecord) {
|
||||
return context.json({ error: "No game state found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||
const state = gameStateRecord.state as unknown as GameState;
|
||||
|
||||
const {
|
||||
achievementsAdded,
|
||||
adventurersAdded,
|
||||
bossesAdded,
|
||||
equipmentAdded,
|
||||
explorationAreasAdded,
|
||||
questsAdded,
|
||||
upgradesAdded,
|
||||
zonesAdded,
|
||||
} = syncNewContent(state);
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: updatedAt },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature
|
||||
= secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
|
||||
return context.json({
|
||||
achievementsAdded,
|
||||
adventurersAdded,
|
||||
bossesAdded,
|
||||
equipmentAdded,
|
||||
explorationAreasAdded,
|
||||
questsAdded,
|
||||
signature,
|
||||
state,
|
||||
upgradesAdded,
|
||||
zonesAdded,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"debug_sync_new_content",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/hard-reset", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||
import { fetchDiscordUserById } from "../services/discord.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||
import {
|
||||
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const [ record, playerRecord ] = await Promise.all([
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
|
||||
Promise.all([
|
||||
prisma.gameState.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) {
|
||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||
if (!playerRecord) {
|
||||
@@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => {
|
||||
*/
|
||||
if (playerRecord !== null) {
|
||||
state.player.characterName = playerRecord.characterName;
|
||||
state.player.avatar = playerRecord.avatar;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -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.
|
||||
* @returns The full OAuth URL to redirect the user to.
|
||||
@@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => {
|
||||
};
|
||||
|
||||
export type { DiscordTokenResponse, DiscordUser };
|
||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
|
||||
|
||||
@@ -366,6 +366,161 @@ describe("debug route", () => {
|
||||
expect(body.explorationUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("unlocks adventurer tier when its quest has been completed", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"],
|
||||
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { adventurersUnlocked: number };
|
||||
expect(body.adventurersUnlocked).toBe(1);
|
||||
});
|
||||
|
||||
it("does not unlock adventurer tier when it is already unlocked", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"],
|
||||
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { adventurersUnlocked: number };
|
||||
expect(body.adventurersUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("unlocks upgrade when its boss has been defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { upgradesUnlocked: number };
|
||||
expect(body.upgradesUnlocked).toBe(1);
|
||||
});
|
||||
|
||||
it("does not unlock upgrade when boss is not defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
|
||||
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { upgradesUnlocked: number };
|
||||
expect(body.upgradesUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("does not unlock upgrade when it is already unlocked", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||
upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { upgradesUnlocked: number };
|
||||
expect(body.upgradesUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("unlocks upgrade granted as a quest reward", async () => {
|
||||
const state = makeState({
|
||||
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
||||
upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { upgradesUnlocked: number };
|
||||
expect(body.upgradesUnlocked).toBe(1);
|
||||
});
|
||||
|
||||
it("marks equipment as owned when its boss has been defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { equipmentUnlocked: number };
|
||||
expect(body.equipmentUnlocked).toBe(1);
|
||||
});
|
||||
|
||||
it("does not mark equipment as owned when boss is not defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
|
||||
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { equipmentUnlocked: number };
|
||||
expect(body.equipmentUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("does not mark equipment as owned when it is already owned", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||
equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { equipmentUnlocked: number };
|
||||
expect(body.equipmentUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("returns storyUnlocked=0 when story is undefined", async () => {
|
||||
const state = makeState({
|
||||
story: undefined as unknown as GameState["story"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { storyUnlocked: number };
|
||||
expect(body.storyUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("unlocks story chapter when its boss has been defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
|
||||
story: { completedChapters: [], unlockedChapterIds: [] },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { storyUnlocked: number };
|
||||
expect(body.storyUnlocked).toBe(1);
|
||||
});
|
||||
|
||||
it("does not unlock story chapter when boss is not defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"],
|
||||
story: { completedChapters: [], unlockedChapterIds: [] },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { storyUnlocked: number };
|
||||
expect(body.storyUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("does not unlock story chapter when it is already unlocked", async () => {
|
||||
const state = makeState({
|
||||
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
|
||||
story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await forceUnlocks();
|
||||
const body = await res.json() as { storyUnlocked: number };
|
||||
expect(body.storyUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const state = makeState();
|
||||
|
||||
@@ -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 CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
@@ -200,6 +204,75 @@ describe("game route", () => {
|
||||
expect(body.offlineGold).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", () => {
|
||||
|
||||
@@ -104,4 +104,53 @@ describe("discord service", () => {
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/web",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
@@ -267,6 +268,16 @@ const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs any content added after the player's save was created into their save.
|
||||
* @returns The updated game state and counts of what was added per content type.
|
||||
*/
|
||||
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
|
||||
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
||||
* @returns The fresh game state as a LoadResponse.
|
||||
@@ -309,6 +320,7 @@ export {
|
||||
craftRecipe,
|
||||
debugHardReset,
|
||||
forceUnlocks,
|
||||
syncNewContent,
|
||||
getAbout,
|
||||
getAuthUrl,
|
||||
getPublicProfile,
|
||||
|
||||
@@ -143,6 +143,10 @@ const AdventurerCard = ({
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{formatNumber(adventurer.combatPower)}
|
||||
{" combat power each"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="adventurer-count">
|
||||
{"×"}
|
||||
@@ -171,7 +175,7 @@ const AdventurerCard = ({
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const { state, formatNumber, toggleAutoAdventurer } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
|
||||
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
|
||||
@@ -203,6 +207,11 @@ const AdventurerPanel = (): JSX.Element => {
|
||||
}
|
||||
}
|
||||
|
||||
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
|
||||
"auto_adventurer",
|
||||
);
|
||||
const autoAdventurerOn = state.autoAdventurer === true;
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
@@ -213,11 +222,34 @@ const AdventurerPanel = (): JSX.Element => {
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Adventurers"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
<div className="panel-header-controls">
|
||||
{autoAdventurerUnlocked
|
||||
? <button
|
||||
className={`auto-toggle-btn ${
|
||||
autoAdventurerOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={toggleAutoAdventurer}
|
||||
title={
|
||||
"Automatically purchase the highest-tier"
|
||||
+ " affordable adventurer"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoAdventurerOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
: null
|
||||
}
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((option) => {
|
||||
|
||||
@@ -267,6 +267,23 @@ const BossPanel = (): JSX.Element => {
|
||||
}
|
||||
|
||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||
|
||||
const activeZone = zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsLocked = activeZone?.status === "locked";
|
||||
const unlockBoss = activeZone?.unlockBossId === null
|
||||
|| activeZone?.unlockBossId === undefined
|
||||
? undefined
|
||||
: bosses.find((boss) => {
|
||||
return boss.id === activeZone.unlockBossId;
|
||||
});
|
||||
const unlockQuest = activeZone?.unlockQuestId === null
|
||||
|| activeZone?.unlockQuestId === undefined
|
||||
? undefined
|
||||
: quests.find((quest) => {
|
||||
return quest.id === activeZone.unlockQuestId;
|
||||
});
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
@@ -393,6 +410,27 @@ const BossPanel = (): JSX.Element => {
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
|
||||
{unlockBoss === undefined
|
||||
? null
|
||||
: <p>
|
||||
{"⚔️ Defeat: "}
|
||||
{unlockBoss.name}
|
||||
</p>
|
||||
}
|
||||
{unlockQuest === undefined
|
||||
? null
|
||||
: <p>
|
||||
{"📜 Complete: "}
|
||||
{unlockQuest.name}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||
|
||||
@@ -10,22 +10,114 @@ import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||
|
||||
type ActiveModal = "force-unlocks" | "hard-reset" | null;
|
||||
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||
|
||||
interface SyncNewContentResult {
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable summary of what the sync-new-content operation added.
|
||||
* @param result - The counts returned by the operation.
|
||||
* @returns A message string describing what was added, or a confirmation nothing was needed.
|
||||
*/
|
||||
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||
const entries: Array<[ number, string ]> = [
|
||||
[ result.zonesAdded, "zone(s)" ],
|
||||
[ result.questsAdded, "quest(s)" ],
|
||||
[ result.bossesAdded, "boss(es)" ],
|
||||
[ result.explorationAreasAdded, "exploration area(s)" ],
|
||||
[ result.adventurersAdded, "adventurer tier(s)" ],
|
||||
[ result.upgradesAdded, "upgrade(s)" ],
|
||||
[ result.equipmentAdded, "equipment item(s)" ],
|
||||
[ result.achievementsAdded, "achievement(s)" ],
|
||||
];
|
||||
const parts = entries.
|
||||
filter(([ count ]) => {
|
||||
return count > 0;
|
||||
}).
|
||||
map(([ count, label ]) => {
|
||||
return `${String(count)} ${label}`;
|
||||
});
|
||||
if (parts.length === 0) {
|
||||
return "Your save is already up to date — no new content was found.";
|
||||
}
|
||||
const total = entries.reduce((sum, [ count ]) => {
|
||||
return sum + count;
|
||||
}, 0);
|
||||
return `Added ${String(total)} new item(s) to your save: ${parts.join(", ")}.`;
|
||||
};
|
||||
|
||||
interface ForceUnlocksResult {
|
||||
adventurersUnlocked: number;
|
||||
bossesUnlocked: number;
|
||||
equipmentUnlocked: number;
|
||||
explorationUnlocked: number;
|
||||
questsUnlocked: number;
|
||||
storyUnlocked: number;
|
||||
upgradesUnlocked: number;
|
||||
zonesUnlocked: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable summary of what the force-unlock operation corrected.
|
||||
* @param result - The counts returned by the force-unlock operation.
|
||||
* @returns A message string describing what was fixed, or a confirmation that nothing needed fixing.
|
||||
*/
|
||||
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
|
||||
const entries: Array<[ number, string ]> = [
|
||||
[ result.zonesUnlocked, "zone(s)" ],
|
||||
[ result.questsUnlocked, "quest(s)" ],
|
||||
[ result.bossesUnlocked, "boss(es)" ],
|
||||
[ result.explorationUnlocked, "exploration area(s)" ],
|
||||
[ result.adventurersUnlocked, "adventurer tier(s)" ],
|
||||
[ result.upgradesUnlocked, "upgrade(s)" ],
|
||||
[ result.equipmentUnlocked, "equipment item(s)" ],
|
||||
[ result.storyUnlocked, "story chapter(s)" ],
|
||||
];
|
||||
const parts = entries.
|
||||
filter(([ count ]) => {
|
||||
return count > 0;
|
||||
}).
|
||||
map(([ count, label ]) => {
|
||||
return `${String(count)} ${label}`;
|
||||
});
|
||||
if (parts.length === 0) {
|
||||
return "Everything looks correct — no missing unlocks were found.";
|
||||
}
|
||||
const total = entries.reduce((sum, [ count ]) => {
|
||||
return sum + count;
|
||||
}, 0);
|
||||
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the debug panel with tools for fixing stuck game state.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DebugPanel = (): JSX.Element => {
|
||||
const { forceUnlocks, debugHardReset, isLoading } = useGame();
|
||||
const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
|
||||
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
||||
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
||||
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
|
||||
|
||||
function handleOpenForceUnlocks(): void {
|
||||
setForceUnlocksResult(null);
|
||||
setActiveModal("force-unlocks");
|
||||
}
|
||||
|
||||
function handleOpenSyncNewContent(): void {
|
||||
setSyncNewContentResult(null);
|
||||
setActiveModal("sync-new-content");
|
||||
}
|
||||
|
||||
function handleOpenHardReset(): void {
|
||||
setActiveModal("hard-reset");
|
||||
}
|
||||
@@ -38,29 +130,15 @@ const DebugPanel = (): JSX.Element => {
|
||||
setActiveModal(null);
|
||||
void (async(): Promise<void> => {
|
||||
const result = await forceUnlocks();
|
||||
const parts: Array<string> = [];
|
||||
if (result.zonesUnlocked > 0) {
|
||||
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
|
||||
}
|
||||
if (result.questsUnlocked > 0) {
|
||||
parts.push(`${String(result.questsUnlocked)} quest(s)`);
|
||||
}
|
||||
if (result.bossesUnlocked > 0) {
|
||||
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
|
||||
}
|
||||
if (result.explorationUnlocked > 0) {
|
||||
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
|
||||
}
|
||||
const total
|
||||
= result.zonesUnlocked
|
||||
+ result.questsUnlocked
|
||||
+ result.bossesUnlocked
|
||||
+ result.explorationUnlocked;
|
||||
const message
|
||||
= parts.length === 0
|
||||
? "Everything looks correct — no missing unlocks were found."
|
||||
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
||||
setForceUnlocksResult(message);
|
||||
setForceUnlocksResult(buildForceUnlocksMessage(result));
|
||||
})();
|
||||
}
|
||||
|
||||
function handleConfirmSyncNewContent(): void {
|
||||
setActiveModal(null);
|
||||
void (async(): Promise<void> => {
|
||||
const result = await syncNewContent();
|
||||
setSyncNewContentResult(buildSyncNewContentMessage(result));
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -99,6 +177,26 @@ const DebugPanel = (): JSX.Element => {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="debug-action-card">
|
||||
<h3>{"🔄 Sync New Content"}</h3>
|
||||
<p>
|
||||
{
|
||||
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
className="action-button"
|
||||
disabled={isLoading}
|
||||
onClick={handleOpenSyncNewContent}
|
||||
type="button"
|
||||
>
|
||||
{"Sync New Content"}
|
||||
</button>
|
||||
{syncNewContentResult !== null
|
||||
&& <p className="debug-result-message">{syncNewContentResult}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="debug-action-card">
|
||||
<h3>{"💀 Hard Reset"}</h3>
|
||||
<p>
|
||||
@@ -128,6 +226,17 @@ const DebugPanel = (): JSX.Element => {
|
||||
/>
|
||||
}
|
||||
|
||||
{activeModal === "sync-new-content"
|
||||
&& <ConfirmationModal
|
||||
confirmLabel="Yes, Sync New Content"
|
||||
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
|
||||
isLoading={isLoading}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirmSyncNewContent}
|
||||
title="Sync New Content"
|
||||
/>
|
||||
}
|
||||
|
||||
{activeModal === "hard-reset"
|
||||
&& <ConfirmationModal
|
||||
confirmLabel="Yes, Wipe Everything"
|
||||
|
||||
@@ -135,7 +135,6 @@ const GameLayout = (): JSX.Element => {
|
||||
);
|
||||
}
|
||||
|
||||
const profileUrl = `/profile/${state.player.discordId}`;
|
||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||
|
||||
@@ -160,7 +159,6 @@ const GameLayout = (): JSX.Element => {
|
||||
onEditProfile={handleOpenEditProfile}
|
||||
onForceSync={forceSync}
|
||||
prestigeCount={state.prestige.count}
|
||||
profileUrl={profileUrl}
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
|
||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
@@ -148,8 +149,7 @@ const QuestCard = ({
|
||||
&& <p className="quest-failure-chance">
|
||||
{"🎲 "}
|
||||
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
||||
{"% failure chance — if failed, the quest resets"}
|
||||
{" and must be retried."}
|
||||
{"% failure chance"}
|
||||
</p>
|
||||
}
|
||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||
@@ -208,7 +208,24 @@ const QuestPanel = (): JSX.Element => {
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, quests, zones } = state;
|
||||
const { adventurers, autoQuest, bosses, quests, zones } = state;
|
||||
|
||||
const activeZone = zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsLocked = activeZone?.status === "locked";
|
||||
const unlockBoss = activeZone?.unlockBossId === null
|
||||
|| activeZone?.unlockBossId === undefined
|
||||
? undefined
|
||||
: bosses.find((boss) => {
|
||||
return boss.id === activeZone.unlockBossId;
|
||||
});
|
||||
const unlockQuest = activeZone?.unlockQuestId === null
|
||||
|| activeZone?.unlockQuestId === undefined
|
||||
? undefined
|
||||
: quests.find((quest) => {
|
||||
return quest.id === activeZone.unlockQuestId;
|
||||
});
|
||||
let partyCombatPower = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const contribution = adventurer.combatPower * adventurer.count;
|
||||
@@ -307,6 +324,31 @@ const QuestPanel = (): JSX.Element => {
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
|
||||
{unlockBoss === undefined
|
||||
? null
|
||||
: <p>
|
||||
{"⚔️ Defeat: "}
|
||||
{unlockBoss.name}
|
||||
</p>
|
||||
}
|
||||
{unlockQuest === undefined
|
||||
? null
|
||||
: <p>
|
||||
{"📜 Complete: "}
|
||||
{unlockQuest.name}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<p className="quest-failure-note">
|
||||
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
|
||||
</p>
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => {
|
||||
return (
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
* @license Naomi's Public License
|
||||
* @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-statements -- Resource bar requires many local computations and handlers */
|
||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||
import { useState, type FocusEvent, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
||||
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ResourceBarProperties {
|
||||
readonly resources: Resource;
|
||||
@@ -17,7 +19,6 @@ interface ResourceBarProperties {
|
||||
readonly prestigeCount: number;
|
||||
readonly transcendenceCount: number;
|
||||
readonly apotheosisCount: number;
|
||||
readonly profileUrl: string;
|
||||
readonly onEditProfile: ()=> void;
|
||||
readonly lastSavedAt: number | null;
|
||||
readonly isSyncing: boolean;
|
||||
@@ -58,7 +59,6 @@ const resourceFullTooltip = [
|
||||
* @param props.prestigeCount - The number of prestiges completed.
|
||||
* @param props.transcendenceCount - The number of transcendences 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.lastSavedAt - Timestamp of the last cloud save.
|
||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||
@@ -71,84 +71,168 @@ const ResourceBar = ({
|
||||
prestigeCount,
|
||||
transcendenceCount,
|
||||
apotheosisCount,
|
||||
profileUrl,
|
||||
onEditProfile,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProperties): JSX.Element => {
|
||||
const { formatNumber, syncError, state } = useGame();
|
||||
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||
|
||||
const { gold, essence, crystals } = resources;
|
||||
let partyCombatPower = 0;
|
||||
let goldPerSecond = 0;
|
||||
if (state !== null) {
|
||||
for (const adventurer of state.adventurers) {
|
||||
const contribution = adventurer.combatPower * adventurer.count;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
goldPerSecond = computeGoldPerSecond(state);
|
||||
}
|
||||
const resourceValues = [ gold, essence, crystals ];
|
||||
const anyFull = resourceValues.some((v) => {
|
||||
return v >= RESOURCE_CAP;
|
||||
});
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
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 essenceFull = essence >= RESOURCE_CAP;
|
||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||
const anyFull = goldFull || essenceFull || crystalsFull;
|
||||
const hiddenResourcesFull = essenceFull || crystalsFull;
|
||||
|
||||
function handleForceSync(): void {
|
||||
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 (
|
||||
<>
|
||||
<header className="resource-bar">
|
||||
<div className={`resource${goldFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"🪙"}</span>
|
||||
<span className="resource-value">{formatNumber(gold)}</span>
|
||||
<span className="resource-label">{"Gold"}</span>
|
||||
{goldFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
<div
|
||||
className="resource-menu"
|
||||
onBlur={handleResourceBlur}
|
||||
>
|
||||
<button
|
||||
className={`resource resource-toggle${goldFull
|
||||
? " resource-full"
|
||||
: ""}`}
|
||||
onClick={handleToggleResources}
|
||||
title="Click to see all resources"
|
||||
type="button"
|
||||
>
|
||||
<span className="resource-icon">{"🪙"}</span>
|
||||
<span className="resource-value">{formatNumber(gold)}</span>
|
||||
<span className="resource-label">{"Gold"}</span>
|
||||
{goldFull
|
||||
? <span
|
||||
className="resource-cap-badge"
|
||||
title={resourceFullTooltip}
|
||||
>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: 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 className={`resource${essenceFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"✨"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(essence)}
|
||||
</span>
|
||||
<span className="resource-label">{"Essence"}</span>
|
||||
{essenceFull
|
||||
? <span
|
||||
className="resource-cap-badge"
|
||||
title={resourceFullTooltip}
|
||||
>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${crystalsFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"💎"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(crystals)}
|
||||
</span>
|
||||
<span className="resource-label">{"Crystals"}</span>
|
||||
{crystalsFull
|
||||
? <span
|
||||
className="resource-cap-badge"
|
||||
title={resourceFullTooltip}
|
||||
>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"🔮"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(runestones)}
|
||||
</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⚔️"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(partyCombatPower)}
|
||||
</span>
|
||||
<span className="resource-label">{"Combat Power"}</span>
|
||||
</div>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${essenceFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"✨"}</span>
|
||||
<span className="resource-value">{formatNumber(essence)}</span>
|
||||
<span className="resource-label">{"Essence"}</span>
|
||||
{essenceFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${crystalsFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"💎"}</span>
|
||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
||||
<span className="resource-label">{"Crystals"}</span>
|
||||
{crystalsFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"🔮"}</span>
|
||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⚔️"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(partyCombatPower)}
|
||||
</span>
|
||||
<span className="resource-label">{"Combat Power"}</span>
|
||||
</div>
|
||||
{apotheosisCount > 0
|
||||
&& <div className="apotheosis-badge">
|
||||
{"✨ Apotheosis "}
|
||||
@@ -167,34 +251,7 @@ const ResourceBar = ({
|
||||
{prestigeCount}
|
||||
</div>
|
||||
}
|
||||
<div className="profile-buttons">
|
||||
<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>
|
||||
<div className="resource-bar-actions">
|
||||
{syncError === null
|
||||
? null
|
||||
: <span className="save-status save-error" title={syncError}>
|
||||
@@ -221,23 +278,69 @@ const ResourceBar = ({
|
||||
? "⏳"
|
||||
: "💾"}
|
||||
</button>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href={profileUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View your public profile"
|
||||
>
|
||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
||||
</a>
|
||||
<button
|
||||
className="profile-edit-button"
|
||||
onClick={onEditProfile}
|
||||
title="Edit your profile"
|
||||
type="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
|
||||
className="profile-dropdown-item"
|
||||
href={profileUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{"👤 View Profile"}
|
||||
</a>
|
||||
<button
|
||||
className="profile-dropdown-item"
|
||||
onClick={handleEditProfile}
|
||||
type="button"
|
||||
>
|
||||
{"✏️ Edit Profile"}
|
||||
</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>
|
||||
</header>
|
||||
{anyFull
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
craftRecipe as craftRecipeApi,
|
||||
debugHardReset as debugHardResetApi,
|
||||
forceUnlocks as forceUnlocksApi,
|
||||
syncNewContent as syncNewContentApi,
|
||||
loadGame,
|
||||
prestige as prestigeApi,
|
||||
resetProgress as resetProgressApi,
|
||||
@@ -558,9 +559,13 @@ interface GameContextValue {
|
||||
* @returns Counts of what was corrected.
|
||||
*/
|
||||
forceUnlocks: ()=> Promise<{
|
||||
adventurersUnlocked: number;
|
||||
bossesUnlocked: number;
|
||||
equipmentUnlocked: number;
|
||||
explorationUnlocked: number;
|
||||
questsUnlocked: number;
|
||||
storyUnlocked: number;
|
||||
upgradesUnlocked: number;
|
||||
zonesUnlocked: number;
|
||||
}>;
|
||||
|
||||
@@ -570,6 +575,21 @@ interface GameContextValue {
|
||||
*/
|
||||
debugHardReset: ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Syncs any content added to the game after the player's save was created.
|
||||
* @returns Counts of what was added per content type.
|
||||
*/
|
||||
syncNewContent: ()=> Promise<{
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Last auto-boss fight result — null until the first auto fight completes or
|
||||
* when auto-boss is toggled off.
|
||||
@@ -1090,11 +1110,7 @@ export const GameProvider = ({
|
||||
return adventurer.unlocked && next.resources.gold >= cost;
|
||||
}).
|
||||
sort((adventurerA, adventurerB) => {
|
||||
const costA
|
||||
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
|
||||
const costB
|
||||
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
|
||||
return costB - costA;
|
||||
return adventurerB.combatPower - adventurerA.combatPower;
|
||||
});
|
||||
if (bestAdventurer !== undefined) {
|
||||
const purchaseCost
|
||||
@@ -1144,14 +1160,6 @@ export const GameProvider = ({
|
||||
},
|
||||
);
|
||||
|
||||
// Quest failure — turn off auto-quest so the player can reassess
|
||||
if (
|
||||
newlyFailedQuestsReference.current.length > 0
|
||||
&& next.autoQuest === true
|
||||
) {
|
||||
next = { ...next, autoQuest: false };
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -1289,7 +1297,26 @@ export const GameProvider = ({
|
||||
if (availableBoss !== undefined) {
|
||||
const { id: bossId, name: bossName } = availableBoss;
|
||||
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) => {
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
@@ -1316,11 +1343,13 @@ export const GameProvider = ({
|
||||
|
||||
/*
|
||||
* "Boss is not currently available" is an expected race condition
|
||||
* in the tick loop — suppress telemetry for this case only
|
||||
* when the client is ahead of the server save — silently skip and
|
||||
* let the next tick retry rather than halting automation.
|
||||
*/
|
||||
if (message !== "Boss is not currently available") {
|
||||
logError("auto_boss", error_);
|
||||
if (message === "Boss is not currently available") {
|
||||
return;
|
||||
}
|
||||
logError("auto_boss", error_);
|
||||
setAutoBossError(message);
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
@@ -2110,9 +2139,13 @@ export const GameProvider = ({
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
return {
|
||||
adventurersUnlocked: data.adventurersUnlocked,
|
||||
bossesUnlocked: data.bossesUnlocked,
|
||||
equipmentUnlocked: data.equipmentUnlocked,
|
||||
explorationUnlocked: data.explorationUnlocked,
|
||||
questsUnlocked: data.questsUnlocked,
|
||||
storyUnlocked: data.storyUnlocked,
|
||||
upgradesUnlocked: data.upgradesUnlocked,
|
||||
zonesUnlocked: data.zonesUnlocked,
|
||||
};
|
||||
} catch (error_: unknown) {
|
||||
@@ -2122,14 +2155,55 @@ export const GameProvider = ({
|
||||
: "Failed to force unlocks",
|
||||
);
|
||||
return {
|
||||
adventurersUnlocked: 0,
|
||||
bossesUnlocked: 0,
|
||||
equipmentUnlocked: 0,
|
||||
explorationUnlocked: 0,
|
||||
questsUnlocked: 0,
|
||||
storyUnlocked: 0,
|
||||
upgradesUnlocked: 0,
|
||||
zonesUnlocked: 0,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncNewContent = useCallback(async() => {
|
||||
try {
|
||||
const data = await syncNewContentApi();
|
||||
setState(data.state);
|
||||
if (data.signature !== undefined) {
|
||||
signatureReference.current = data.signature;
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
return {
|
||||
achievementsAdded: data.achievementsAdded,
|
||||
adventurersAdded: data.adventurersAdded,
|
||||
bossesAdded: data.bossesAdded,
|
||||
equipmentAdded: data.equipmentAdded,
|
||||
explorationAreasAdded: data.explorationAreasAdded,
|
||||
questsAdded: data.questsAdded,
|
||||
upgradesAdded: data.upgradesAdded,
|
||||
zonesAdded: data.zonesAdded,
|
||||
};
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to sync new content",
|
||||
);
|
||||
return {
|
||||
achievementsAdded: 0,
|
||||
adventurersAdded: 0,
|
||||
bossesAdded: 0,
|
||||
equipmentAdded: 0,
|
||||
explorationAreasAdded: 0,
|
||||
questsAdded: 0,
|
||||
upgradesAdded: 0,
|
||||
zonesAdded: 0,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debugHardReset = useCallback(async() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -2230,6 +2304,7 @@ export const GameProvider = ({
|
||||
startQuest,
|
||||
state,
|
||||
syncError,
|
||||
syncNewContent,
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
@@ -2302,6 +2377,7 @@ export const GameProvider = ({
|
||||
startQuest,
|
||||
state,
|
||||
syncError,
|
||||
syncNewContent,
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
|
||||
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
||||
{
|
||||
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.",
|
||||
id: "upgrade_mage_1",
|
||||
sourceId: "mage_1",
|
||||
id: "upgrade_apprentice_1",
|
||||
sourceId: "apprentice_1",
|
||||
sourceType: "upgrade",
|
||||
title: "Arcane Tomes: The Written Knowledge",
|
||||
zoneId: "guild_library",
|
||||
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
||||
{
|
||||
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.",
|
||||
id: "upgrade_cleric_1",
|
||||
sourceId: "cleric_1",
|
||||
id: "upgrade_acolyte_1",
|
||||
sourceId: "acolyte_1",
|
||||
sourceType: "upgrade",
|
||||
title: "Holy Rites: The Sacred Routine",
|
||||
zoneId: "guild_library",
|
||||
|
||||
@@ -123,6 +123,78 @@ const capResource = (value: number): number => {
|
||||
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.
|
||||
* DeltaSeconds: time elapsed since last tick.
|
||||
|
||||
+124
-43
@@ -116,6 +116,66 @@ body::before {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -1492,57 +1552,87 @@ body::before {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */
|
||||
/* ── Resource bar actions (save + profile menu) ─────────────────────────── */
|
||||
|
||||
.profile-buttons {
|
||||
.resource-bar-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.profile-link-button {
|
||||
align-items: center;
|
||||
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-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-link-button:hover {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
border-color: var(--colour-primary);
|
||||
color: var(--colour-text);
|
||||
}
|
||||
|
||||
.profile-edit-button {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||
.profile-avatar-button {
|
||||
background: none;
|
||||
border: 2px solid rgba(147, 51, 234, 0.4);
|
||||
border-radius: 50%;
|
||||
color: var(--colour-text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition: all 0.2s;
|
||||
transition: border-color 0.2s;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.profile-edit-button:hover {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
.profile-avatar-button:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
.profile-dropdown-divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(147, 51, 234, 0.2);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
@@ -3167,10 +3257,10 @@ body::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Profile buttons fill their own row, aligned right */
|
||||
.profile-buttons {
|
||||
margin-left: 0;
|
||||
/* Resource bar actions fill their own row, aligned right */
|
||||
.resource-bar-actions {
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -3240,15 +3330,6 @@ body::before {
|
||||
|
||||
/* --- Small mobile (≤ 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 */
|
||||
.tab-button {
|
||||
font-size: 0.8rem;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "elysium",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/types",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
@@ -72,6 +72,7 @@ export type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
|
||||
@@ -425,12 +425,85 @@ interface ForceUnlocksResponse {
|
||||
*/
|
||||
explorationUnlocked: number;
|
||||
|
||||
/**
|
||||
* Number of adventurer tiers that were unlocked by this operation.
|
||||
*/
|
||||
adventurersUnlocked: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades that were unlocked by this operation.
|
||||
*/
|
||||
upgradesUnlocked: number;
|
||||
|
||||
/**
|
||||
* Number of equipment items that were marked as owned by this operation.
|
||||
*/
|
||||
equipmentUnlocked: number;
|
||||
|
||||
/**
|
||||
* Number of story chapters that were unlocked by this operation.
|
||||
*/
|
||||
storyUnlocked: number;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
|
||||
*/
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
interface SyncNewContentResponse {
|
||||
|
||||
/**
|
||||
* The updated game state after injecting all missing content entries.
|
||||
*/
|
||||
state: GameState;
|
||||
|
||||
/**
|
||||
* Number of adventurer tiers added to the save.
|
||||
*/
|
||||
adventurersAdded: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades added to the save.
|
||||
*/
|
||||
upgradesAdded: number;
|
||||
|
||||
/**
|
||||
* Number of quests added to the save.
|
||||
*/
|
||||
questsAdded: number;
|
||||
|
||||
/**
|
||||
* Number of bosses added to the save.
|
||||
*/
|
||||
bossesAdded: number;
|
||||
|
||||
/**
|
||||
* Number of equipment items added to the save.
|
||||
*/
|
||||
equipmentAdded: number;
|
||||
|
||||
/**
|
||||
* Number of achievements added to the save.
|
||||
*/
|
||||
achievementsAdded: number;
|
||||
|
||||
/**
|
||||
* Number of zones added to the save.
|
||||
*/
|
||||
zonesAdded: number;
|
||||
|
||||
/**
|
||||
* Number of exploration areas added to the save.
|
||||
*/
|
||||
explorationAreasAdded: number;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||
*/
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export type {
|
||||
AboutResponse,
|
||||
ApiError,
|
||||
@@ -462,6 +535,7 @@ export type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
|
||||
Reference in New Issue
Block a user