Feat/v0.6.0 #262

Open
nick wants to merge 2 commits from feat/v0.6.0 into main
54 changed files with 1846 additions and 532 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+17 -17
View File
@@ -93,7 +93,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Quest milestones // Quest milestones
{ {
condition: { amount: 1, type: "questsCompleted" }, condition: { amount: 1, type: "uniqueQuestsCompleted" },
description: "Complete your first quest.", description: "Complete your first quest.",
icon: "📜", icon: "📜",
id: "first_quest", id: "first_quest",
@@ -102,7 +102,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 5, type: "questsCompleted" }, condition: { amount: 5, type: "uniqueQuestsCompleted" },
description: "Complete 5 quests.", description: "Complete 5 quests.",
icon: "📚", icon: "📚",
id: "quest_veteran", id: "quest_veteran",
@@ -111,7 +111,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 15, type: "questsCompleted" }, condition: { amount: 15, type: "uniqueQuestsCompleted" },
description: "Complete 15 quests.", description: "Complete 15 quests.",
icon: "🗺️", icon: "🗺️",
id: "quest_master", id: "quest_master",
@@ -121,7 +121,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Boss milestones // Boss milestones
{ {
condition: { amount: 1, type: "bossesDefeated" }, condition: { amount: 1, type: "uniqueBossesDefeated" },
description: "Defeat your first boss.", description: "Defeat your first boss.",
icon: "⚔️", icon: "⚔️",
id: "boss_slayer", id: "boss_slayer",
@@ -130,7 +130,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 5, type: "bossesDefeated" }, condition: { amount: 5, type: "uniqueBossesDefeated" },
description: "Defeat 5 bosses.", description: "Defeat 5 bosses.",
icon: "🗡️", icon: "🗡️",
id: "boss_veteran", id: "boss_veteran",
@@ -139,7 +139,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 10, type: "bossesDefeated" }, condition: { amount: 10, type: "uniqueBossesDefeated" },
description: "Defeat 10 bosses.", description: "Defeat 10 bosses.",
icon: "🏆", icon: "🏆",
id: "legendary_hunter", id: "legendary_hunter",
@@ -148,7 +148,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 18, type: "bossesDefeated" }, condition: { amount: 18, type: "uniqueBossesDefeated" },
description: "Defeat the 18 bosses of the mortal realms.", description: "Defeat the 18 bosses of the mortal realms.",
icon: "🌟", icon: "🌟",
id: "devourer_slayer", id: "devourer_slayer",
@@ -298,7 +298,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Higher quest milestones // Higher quest milestones
{ {
condition: { amount: 30, type: "questsCompleted" }, condition: { amount: 30, type: "uniqueQuestsCompleted" },
description: "Complete 30 quests.", description: "Complete 30 quests.",
icon: "🏅", icon: "🏅",
id: "quest_champion", id: "quest_champion",
@@ -307,7 +307,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 50, type: "questsCompleted" }, condition: { amount: 50, type: "uniqueQuestsCompleted" },
description: "Complete 50 quests.", description: "Complete 50 quests.",
icon: "🎖️", icon: "🎖️",
id: "quest_grandmaster", id: "quest_grandmaster",
@@ -316,7 +316,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 75, type: "questsCompleted" }, condition: { amount: 75, type: "uniqueQuestsCompleted" },
description: "Complete 75 quests.", description: "Complete 75 quests.",
icon: "🌠", icon: "🌠",
id: "quest_hero", id: "quest_hero",
@@ -325,7 +325,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 100, type: "questsCompleted" }, condition: { amount: 100, type: "uniqueQuestsCompleted" },
description: "Complete 100 quests.", description: "Complete 100 quests.",
icon: "💫", icon: "💫",
id: "quest_legend", id: "quest_legend",
@@ -334,8 +334,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 122, type: "questsCompleted" }, condition: { amount: 112, type: "uniqueQuestsCompleted" },
description: "Complete all 122 quests across the known multiverse.", description: "Complete all 112 quests across the known multiverse.",
icon: "🌌", icon: "🌌",
id: "quest_eternal", id: "quest_eternal",
name: "Quest Eternal", name: "Quest Eternal",
@@ -344,7 +344,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Higher boss milestones // Higher boss milestones
{ {
condition: { amount: 20, type: "bossesDefeated" }, condition: { amount: 20, type: "uniqueBossesDefeated" },
description: "Defeat 20 bosses.", description: "Defeat 20 bosses.",
icon: "🦁", icon: "🦁",
id: "boss_champion", id: "boss_champion",
@@ -353,7 +353,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 30, type: "bossesDefeated" }, condition: { amount: 30, type: "uniqueBossesDefeated" },
description: "Defeat 30 bosses.", description: "Defeat 30 bosses.",
icon: "🔱", icon: "🔱",
id: "boss_grandmaster", id: "boss_grandmaster",
@@ -362,7 +362,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 50, type: "bossesDefeated" }, condition: { amount: 50, type: "uniqueBossesDefeated" },
description: "Defeat 50 bosses.", description: "Defeat 50 bosses.",
icon: "⚡", icon: "⚡",
id: "boss_legend", id: "boss_legend",
@@ -371,7 +371,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 72, type: "bossesDefeated" }, condition: { amount: 72, type: "uniqueBossesDefeated" },
description: "Defeat all 72 bosses across every plane of existence.", description: "Defeat all 72 bosses across every plane of existence.",
icon: "💀", icon: "💀",
id: "boss_eternal", id: "boss_eternal",
+9 -5
View File
@@ -61,7 +61,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Forest Giant", name: "The Forest Giant",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [ "archmage_1" ], upgradeRewards: [],
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
// ── Shattered Ruins ─────────────────────────────────────────────────────── // ── Shattered Ruins ───────────────────────────────────────────────────────
@@ -1258,7 +1258,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 5e110, damagePerSecond: 5e110,
description: description:
"The announcement of finality — not a creature but the moment before the last moment, given agency. It is here to tell your guild that this is where everything ends. Your guild declines to accept the announcement.", "The announcement of finality — not a creature but the moment before the last moment, given agency. It is here to tell your guild that this is where everything ends. Your guild declines to accept the announcement.",
equipmentRewards: [], equipmentRewards: [ "omniversal_core" ],
essenceReward: 2e113, essenceReward: 2e113,
goldReward: 2e117, goldReward: 2e117,
id: "absolute_herald", id: "absolute_herald",
@@ -1266,6 +1266,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Absolute Herald", name: "The Absolute Herald",
prestigeRequirement: 17, prestigeRequirement: 17,
status: "locked", status: "locked",
unlockQuestId: "final_paradox",
upgradeRewards: [ "primordial_mage_1" ], upgradeRewards: [ "primordial_mage_1" ],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
@@ -1276,7 +1277,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 3e119, damagePerSecond: 3e119,
description: description:
"Every void, every absence, every nothing that has ever existed converging into a single point. The gravitational pull of absolute nothingness. Your guild must push against the pull of all that is not.", "Every void, every absence, every nothing that has ever existed converging into a single point. The gravitational pull of absolute nothingness. Your guild must push against the pull of all that is not.",
equipmentRewards: [], equipmentRewards: [ "eternity_plate" ],
essenceReward: 1e122, essenceReward: 1e122,
goldReward: 1e126, goldReward: 1e126,
id: "void_convergence", id: "void_convergence",
@@ -1284,6 +1285,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Convergence", name: "The Void Convergence",
prestigeRequirement: 18, prestigeRequirement: 18,
status: "locked", status: "locked",
unlockQuestId: "terminal_approach",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
@@ -1294,7 +1296,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 1e129, damagePerSecond: 1e129,
description: description:
"The last thing that will ever exist — visiting now, ahead of schedule, drawn by the power your guild has accumulated. It does not consider this inconvenient. Everything ends eventually. It is simply efficient.", "The last thing that will ever exist — visiting now, ahead of schedule, drawn by the power your guild has accumulated. It does not consider this inconvenient. Everything ends eventually. It is simply efficient.",
equipmentRewards: [], equipmentRewards: [ "absolute_blade" ],
essenceReward: 5e131, essenceReward: 5e131,
goldReward: 5e135, goldReward: 5e135,
id: "eternal_end", id: "eternal_end",
@@ -1302,6 +1304,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Eternal End", name: "The Eternal End",
prestigeRequirement: 19, prestigeRequirement: 19,
status: "locked", status: "locked",
unlockQuestId: "absolute_depth",
upgradeRewards: [ "omniversal_champion_1" ], upgradeRewards: [ "omniversal_champion_1" ],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
@@ -1312,7 +1315,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 5e139, damagePerSecond: 5e139,
description: description:
"Beyond description. Beyond category. The terminal point of all power, all existence, all possibility. There is nothing after this. Your guild has come to this nothing and refused it. That, in itself, is the greatest achievement in the history of anything.", "Beyond description. Beyond category. The terminal point of all power, all existence, all possibility. There is nothing after this. Your guild has come to this nothing and refused it. That, in itself, is the greatest achievement in the history of anything.",
equipmentRewards: [ "absolute_blade", "eternity_plate", "omniversal_core" ], equipmentRewards: [],
essenceReward: 2e142, essenceReward: 2e142,
goldReward: 2e146, goldReward: 2e146,
id: "the_absolute_one", id: "the_absolute_one",
@@ -1320,6 +1323,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Absolute One", name: "The Absolute One",
prestigeRequirement: 20, prestigeRequirement: 20,
status: "locked", status: "locked",
unlockQuestId: "final_threshold",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
+72 -87
View File
@@ -13,7 +13,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
durationSeconds: 5 * 60, durationSeconds: 60,
events: [ events: [
{ {
effect: { amount: 1000, type: "gold_gain" }, effect: { amount: 1000, type: "gold_gain" },
@@ -42,7 +42,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "verdant_meadow", id: "verdant_meadow",
name: "The Verdant Meadow", name: "The Verdant Meadow",
// 5min
possibleMaterials: [ possibleMaterials: [
{ materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 3 }, { materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 3 },
], ],
@@ -52,7 +51,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
durationSeconds: 10 * 60, durationSeconds: 75,
events: [ events: [
{ {
effect: { amount: 3000, type: "gold_gain" }, effect: { amount: 3000, type: "gold_gain" },
@@ -81,7 +80,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "whispering_forest", id: "whispering_forest",
name: "The Whispering Forest", name: "The Whispering Forest",
// 10min
possibleMaterials: [ possibleMaterials: [
{ materialId: "verdant_sap", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "verdant_sap", maxQuantity: 5, minQuantity: 2, weight: 3 },
{ {
@@ -97,7 +95,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
durationSeconds: 15 * 60, durationSeconds: 90,
events: [ events: [
{ {
effect: { amount: 6000, type: "gold_gain" }, effect: { amount: 6000, type: "gold_gain" },
@@ -126,7 +124,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "ancient_grove", id: "ancient_grove",
name: "The Ancient Grove", name: "The Ancient Grove",
// 15min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "forest_crystal", materialId: "forest_crystal",
@@ -142,7 +139,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
durationSeconds: 20 * 60, durationSeconds: 105,
events: [ events: [
{ {
effect: { amount: 10_000, type: "gold_gain" }, effect: { amount: 10_000, type: "gold_gain" },
@@ -171,7 +168,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "forbidden_glen", id: "forbidden_glen",
name: "The Forbidden Glen", name: "The Forbidden Glen",
// 20min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "forest_crystal", materialId: "forest_crystal",
@@ -189,7 +185,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
durationSeconds: 10 * 60, durationSeconds: 75,
events: [ events: [
{ {
effect: { amount: 4000, type: "gold_gain" }, effect: { amount: 4000, type: "gold_gain" },
@@ -214,7 +210,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "collapsed_outpost", id: "collapsed_outpost",
name: "The Collapsed Outpost", name: "The Collapsed Outpost",
// 10min
possibleMaterials: [ possibleMaterials: [
{ materialId: "ruin_dust", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "ruin_dust", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -224,7 +219,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
durationSeconds: 20 * 60, durationSeconds: 90,
events: [ events: [
{ {
effect: { amount: 10_000, type: "gold_gain" }, effect: { amount: 10_000, type: "gold_gain" },
@@ -253,7 +248,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "cursed_lake", id: "cursed_lake",
name: "The Cursed Lake", name: "The Cursed Lake",
// 20min
possibleMaterials: [ possibleMaterials: [
{ materialId: "ruin_dust", maxQuantity: 6, minQuantity: 2, weight: 3 }, { materialId: "ruin_dust", maxQuantity: 6, minQuantity: 2, weight: 3 },
{ {
@@ -269,7 +263,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
durationSeconds: 30 * 60, durationSeconds: 120,
events: [ events: [
{ {
effect: { amount: 20_000, type: "gold_gain" }, effect: { amount: 20_000, type: "gold_gain" },
@@ -298,7 +292,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "runic_archive", id: "runic_archive",
name: "The Runic Archive", name: "The Runic Archive",
// 30min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "cursed_fragment", materialId: "cursed_fragment",
@@ -314,7 +307,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
durationSeconds: 40 * 60, durationSeconds: 150,
events: [ events: [
{ {
effect: { amount: 40_000, type: "gold_gain" }, effect: { amount: 40_000, type: "gold_gain" },
@@ -343,7 +336,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "dragon_throne", id: "dragon_throne",
name: "The Dragon's Throne", name: "The Dragon's Throne",
// 40min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "cursed_fragment", materialId: "cursed_fragment",
@@ -366,7 +358,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
durationSeconds: 15 * 60, durationSeconds: 105,
events: [ events: [
{ {
effect: { amount: 8000, type: "gold_gain" }, effect: { amount: 8000, type: "gold_gain" },
@@ -395,7 +387,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "glacial_cave", id: "glacial_cave",
name: "The Glacial Cave", name: "The Glacial Cave",
// 15min
possibleMaterials: [ possibleMaterials: [
{ materialId: "glacial_ice", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "glacial_ice", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -405,7 +396,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
durationSeconds: 30 * 60, durationSeconds: 135,
events: [ events: [
{ {
effect: { amount: 18_000, type: "gold_gain" }, effect: { amount: 18_000, type: "gold_gain" },
@@ -434,7 +425,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "frozen_tundra", id: "frozen_tundra",
name: "The Frozen Tundra", name: "The Frozen Tundra",
// 30min
possibleMaterials: [ possibleMaterials: [
{ materialId: "glacial_ice", maxQuantity: 7, minQuantity: 3, weight: 3 }, { materialId: "glacial_ice", maxQuantity: 7, minQuantity: 3, weight: 3 },
{ {
@@ -450,7 +440,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
durationSeconds: 45 * 60, durationSeconds: 180,
events: [ events: [
{ {
effect: { amount: 35_000, type: "gold_gain" }, effect: { amount: 35_000, type: "gold_gain" },
@@ -479,7 +469,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "void_rift", id: "void_rift",
name: "The Void Rift", name: "The Void Rift",
// 45min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "frost_crystal", materialId: "frost_crystal",
@@ -495,7 +484,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
durationSeconds: 1 * 60 * 60, durationSeconds: 240,
events: [ events: [
{ {
effect: { amount: 60_000, type: "gold_gain" }, effect: { amount: 60_000, type: "gold_gain" },
@@ -542,7 +531,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
durationSeconds: 25 * 60, durationSeconds: 105,
events: [ events: [
{ {
effect: { amount: 15_000, type: "gold_gain" }, effect: { amount: 15_000, type: "gold_gain" },
@@ -571,7 +560,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "fog_hollow", id: "fog_hollow",
name: "The Fog Hollow", name: "The Fog Hollow",
// 25min
possibleMaterials: [ possibleMaterials: [
{ materialId: "marsh_root", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "marsh_root", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -581,7 +569,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
durationSeconds: 50 * 60, durationSeconds: 150,
events: [ events: [
{ {
effect: { amount: 35_000, type: "gold_gain" }, effect: { amount: 35_000, type: "gold_gain" },
@@ -610,7 +598,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "dark_grotto", id: "dark_grotto",
name: "The Dark Grotto", name: "The Dark Grotto",
// 50min
possibleMaterials: [ possibleMaterials: [
{ materialId: "marsh_root", maxQuantity: 7, minQuantity: 3, weight: 3 }, { materialId: "marsh_root", maxQuantity: 7, minQuantity: 3, weight: 3 },
{ {
@@ -626,7 +613,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
durationSeconds: 90 * 60, durationSeconds: 195,
events: [ events: [
{ {
effect: { amount: 70_000, type: "gold_gain" }, effect: { amount: 70_000, type: "gold_gain" },
@@ -671,7 +658,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
durationSeconds: 90 * 60, durationSeconds: 255,
events: [ events: [
{ {
effect: { amount: 120_000, type: "gold_gain" }, effect: { amount: 120_000, type: "gold_gain" },
@@ -718,7 +705,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
durationSeconds: 35 * 60, durationSeconds: 120,
events: [ events: [
{ {
effect: { amount: 30_000, type: "gold_gain" }, effect: { amount: 30_000, type: "gold_gain" },
@@ -747,7 +734,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "magma_tunnel", id: "magma_tunnel",
name: "The Magma Tunnel", name: "The Magma Tunnel",
// 35min
possibleMaterials: [ possibleMaterials: [
{ materialId: "magma_stone", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "magma_stone", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -757,7 +743,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
durationSeconds: 1 * 60 * 60, durationSeconds: 165,
events: [ events: [
{ {
effect: { amount: 70_000, type: "gold_gain" }, effect: { amount: 70_000, type: "gold_gain" },
@@ -802,7 +788,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
durationSeconds: 2 * 60 * 60, durationSeconds: 225,
events: [ events: [
{ {
effect: { amount: 130_000, type: "gold_gain" }, effect: { amount: 130_000, type: "gold_gain" },
@@ -852,7 +838,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
durationSeconds: 150 * 60, durationSeconds: 300,
events: [ events: [
{ {
effect: { amount: 250_000, type: "gold_gain" }, effect: { amount: 250_000, type: "gold_gain" },
@@ -904,7 +890,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
durationSeconds: 50 * 60, durationSeconds: 150,
events: [ events: [
{ {
effect: { amount: 500_000, type: "gold_gain" }, effect: { amount: 500_000, type: "gold_gain" },
@@ -929,7 +915,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "star_field", id: "star_field",
name: "The Star Field", name: "The Star Field",
// 50min
possibleMaterials: [ possibleMaterials: [
{ materialId: "stardust", maxQuantity: 7, minQuantity: 3, weight: 3 }, { materialId: "stardust", maxQuantity: 7, minQuantity: 3, weight: 3 },
], ],
@@ -939,7 +924,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
durationSeconds: 90 * 60, durationSeconds: 210,
events: [ events: [
{ {
effect: { amount: 1_000_000, type: "gold_gain" }, effect: { amount: 1_000_000, type: "gold_gain" },
@@ -984,7 +969,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
durationSeconds: 150 * 60, durationSeconds: 270,
events: [ events: [
{ {
effect: { amount: 2_000_000, type: "gold_gain" }, effect: { amount: 2_000_000, type: "gold_gain" },
@@ -1029,7 +1014,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
durationSeconds: 210 * 60, durationSeconds: 375,
events: [ events: [
{ {
effect: { amount: 4_000_000, type: "gold_gain" }, effect: { amount: 4_000_000, type: "gold_gain" },
@@ -1076,7 +1061,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
durationSeconds: 1 * 60 * 60, durationSeconds: 300,
events: [ events: [
{ {
effect: { amount: 3_000_000, type: "gold_gain" }, effect: { amount: 3_000_000, type: "gold_gain" },
@@ -1120,7 +1105,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
durationSeconds: 2 * 60 * 60, durationSeconds: 390,
events: [ events: [
{ {
effect: { amount: 6_000_000, type: "gold_gain" }, effect: { amount: 6_000_000, type: "gold_gain" },
@@ -1170,7 +1155,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
durationSeconds: 3 * 60 * 60, durationSeconds: 540,
events: [ events: [
{ {
effect: { amount: 12_000_000, type: "gold_gain" }, effect: { amount: 12_000_000, type: "gold_gain" },
@@ -1215,7 +1200,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
durationSeconds: 4 * 60 * 60, durationSeconds: 720,
events: [ events: [
{ {
effect: { amount: 25_000_000, type: "gold_gain" }, effect: { amount: 25_000_000, type: "gold_gain" },
@@ -1262,7 +1247,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
durationSeconds: 1 * 60 * 60, durationSeconds: 420,
events: [ events: [
{ {
effect: { amount: 8_000_000, type: "gold_gain" }, effect: { amount: 8_000_000, type: "gold_gain" },
@@ -1301,7 +1286,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
durationSeconds: 150 * 60, durationSeconds: 570,
events: [ events: [
{ {
effect: { amount: 18_000_000, type: "gold_gain" }, effect: { amount: 18_000_000, type: "gold_gain" },
@@ -1341,7 +1326,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
durationSeconds: 210 * 60, durationSeconds: 750,
events: [ events: [
{ {
effect: { amount: 40_000_000, type: "gold_gain" }, effect: { amount: 40_000_000, type: "gold_gain" },
@@ -1386,7 +1371,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
durationSeconds: 270 * 60, durationSeconds: 990,
events: [ events: [
{ {
effect: { amount: 80_000_000, type: "gold_gain" }, effect: { amount: 80_000_000, type: "gold_gain" },
@@ -1433,7 +1418,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
durationSeconds: 90 * 60, durationSeconds: 540,
events: [ events: [
{ {
effect: { amount: 20_000_000, type: "gold_gain" }, effect: { amount: 20_000_000, type: "gold_gain" },
@@ -1477,7 +1462,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
durationSeconds: 150 * 60, durationSeconds: 720,
events: [ events: [
{ {
effect: { amount: 45_000_000, type: "gold_gain" }, effect: { amount: 45_000_000, type: "gold_gain" },
@@ -1522,7 +1507,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
durationSeconds: 4 * 60 * 60, durationSeconds: 960,
events: [ events: [
{ {
effect: { amount: 90_000_000, type: "gold_gain" }, effect: { amount: 90_000_000, type: "gold_gain" },
@@ -1562,7 +1547,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
durationSeconds: 330 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 180_000_000, type: "gold_gain" }, effect: { amount: 180_000_000, type: "gold_gain" },
@@ -1604,7 +1589,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
durationSeconds: 90 * 60, durationSeconds: 750,
events: [ events: [
{ {
effect: { amount: 60_000_000, type: "gold_gain" }, effect: { amount: 60_000_000, type: "gold_gain" },
@@ -1643,7 +1628,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
durationSeconds: 3 * 60 * 60, durationSeconds: 1005,
events: [ events: [
{ {
effect: { amount: 130_000_000, type: "gold_gain" }, effect: { amount: 130_000_000, type: "gold_gain" },
@@ -1688,7 +1673,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
durationSeconds: 270 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 270_000_000, type: "gold_gain" }, effect: { amount: 270_000_000, type: "gold_gain" },
@@ -1738,7 +1723,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
durationSeconds: 6 * 60 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 550_000_000, type: "gold_gain" }, effect: { amount: 550_000_000, type: "gold_gain" },
@@ -1790,7 +1775,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
durationSeconds: 90 * 60, durationSeconds: 990,
events: [ events: [
{ {
effect: { amount: 200_000_000, type: "gold_gain" }, effect: { amount: 200_000_000, type: "gold_gain" },
@@ -1829,7 +1814,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
durationSeconds: 210 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 420_000_000, type: "gold_gain" }, effect: { amount: 420_000_000, type: "gold_gain" },
@@ -1874,7 +1859,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
durationSeconds: 5 * 60 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 900_000_000, type: "gold_gain" }, effect: { amount: 900_000_000, type: "gold_gain" },
@@ -1919,7 +1904,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
durationSeconds: 7 * 60 * 60, durationSeconds: 2400,
events: [ events: [
{ {
effect: { amount: 1_800_000_000, type: "gold_gain" }, effect: { amount: 1_800_000_000, type: "gold_gain" },
@@ -1966,7 +1951,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
durationSeconds: 2 * 60 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 700_000_000, type: "gold_gain" }, effect: { amount: 700_000_000, type: "gold_gain" },
@@ -2005,7 +1990,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
durationSeconds: 210 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 1_400_000_000, type: "gold_gain" }, effect: { amount: 1_400_000_000, type: "gold_gain" },
@@ -2050,7 +2035,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
durationSeconds: 330 * 60, durationSeconds: 2400,
events: [ events: [
{ {
effect: { amount: 3_000_000_000, type: "gold_gain" }, effect: { amount: 3_000_000_000, type: "gold_gain" },
@@ -2100,7 +2085,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
durationSeconds: 7 * 60 * 60, durationSeconds: 3600,
events: [ events: [
{ {
effect: { amount: 6_000_000_000, type: "gold_gain" }, effect: { amount: 6_000_000_000, type: "gold_gain" },
@@ -2152,7 +2137,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
durationSeconds: 2 * 60 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 2_000_000_000, type: "gold_gain" }, effect: { amount: 2_000_000_000, type: "gold_gain" },
@@ -2196,7 +2181,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
durationSeconds: 4 * 60 * 60, durationSeconds: 2700,
events: [ events: [
{ {
effect: { amount: 4_000_000_000, type: "gold_gain" }, effect: { amount: 4_000_000_000, type: "gold_gain" },
@@ -2246,7 +2231,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
durationSeconds: 6 * 60 * 60, durationSeconds: 3600,
events: [ events: [
{ {
effect: { amount: 8_000_000_000, type: "gold_gain" }, effect: { amount: 8_000_000_000, type: "gold_gain" },
@@ -2296,7 +2281,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
durationSeconds: 8 * 60 * 60, durationSeconds: 4500,
events: [ events: [
{ {
effect: { amount: 16_000_000_000, type: "gold_gain" }, effect: { amount: 16_000_000_000, type: "gold_gain" },
@@ -2348,7 +2333,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
durationSeconds: 2 * 60 * 60, durationSeconds: 2400,
events: [ events: [
{ {
effect: { amount: 6_000_000_000, type: "gold_gain" }, effect: { amount: 6_000_000_000, type: "gold_gain" },
@@ -2387,7 +2372,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
durationSeconds: 270 * 60, durationSeconds: 3600,
events: [ events: [
{ {
effect: { amount: 12_000_000_000, type: "gold_gain" }, effect: { amount: 12_000_000_000, type: "gold_gain" },
@@ -2432,7 +2417,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
durationSeconds: 7 * 60 * 60, durationSeconds: 4500,
events: [ events: [
{ {
effect: { amount: 25_000_000_000, type: "gold_gain" }, effect: { amount: 25_000_000_000, type: "gold_gain" },
@@ -2482,7 +2467,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
durationSeconds: 9 * 60 * 60, durationSeconds: 5700,
events: [ events: [
{ {
effect: { amount: 50_000_000_000, type: "gold_gain" }, effect: { amount: 50_000_000_000, type: "gold_gain" },
@@ -2534,7 +2519,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
durationSeconds: 150 * 60, durationSeconds: 5400,
events: [ events: [
{ {
effect: { amount: 20_000_000_000, type: "gold_gain" }, effect: { amount: 20_000_000_000, type: "gold_gain" },
@@ -2569,7 +2554,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
durationSeconds: 270 * 60, durationSeconds: 7200,
events: [ events: [
{ {
effect: { amount: 40_000_000_000, type: "gold_gain" }, effect: { amount: 40_000_000_000, type: "gold_gain" },
@@ -2614,7 +2599,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
durationSeconds: 7 * 60 * 60, durationSeconds: 9000,
events: [ events: [
{ {
effect: { amount: 80_000_000_000, type: "gold_gain" }, effect: { amount: 80_000_000_000, type: "gold_gain" },
@@ -2664,7 +2649,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
durationSeconds: 9 * 60 * 60, durationSeconds: 12_600,
events: [ events: [
{ {
effect: { amount: 160_000_000_000, type: "gold_gain" }, effect: { amount: 160_000_000_000, type: "gold_gain" },
@@ -2716,7 +2701,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
durationSeconds: 150 * 60, durationSeconds: 7200,
events: [ events: [
{ {
effect: { amount: 60_000_000_000, type: "gold_gain" }, effect: { amount: 60_000_000_000, type: "gold_gain" },
@@ -2760,7 +2745,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
durationSeconds: 5 * 60 * 60, durationSeconds: 10_800,
events: [ events: [
{ {
effect: { amount: 120_000_000_000, type: "gold_gain" }, effect: { amount: 120_000_000_000, type: "gold_gain" },
@@ -2810,7 +2795,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
durationSeconds: 8 * 60 * 60, durationSeconds: 14_400,
events: [ events: [
{ {
effect: { amount: 250_000_000_000, type: "gold_gain" }, effect: { amount: 250_000_000_000, type: "gold_gain" },
@@ -2860,7 +2845,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
durationSeconds: 10 * 60 * 60, durationSeconds: 18_000,
events: [ events: [
{ {
effect: { amount: 500_000_000_000, type: "gold_gain" }, effect: { amount: 500_000_000_000, type: "gold_gain" },
@@ -2912,7 +2897,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
durationSeconds: 150 * 60, durationSeconds: 9000,
events: [ events: [
{ {
effect: { amount: 200_000_000_000, type: "gold_gain" }, effect: { amount: 200_000_000_000, type: "gold_gain" },
@@ -2951,7 +2936,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
durationSeconds: 330 * 60, durationSeconds: 16_200,
events: [ events: [
{ {
effect: { amount: 400_000_000_000, type: "gold_gain" }, effect: { amount: 400_000_000_000, type: "gold_gain" },
@@ -2991,7 +2976,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
durationSeconds: 8 * 60 * 60, durationSeconds: 22_500,
events: [ events: [
{ {
effect: { amount: 800_000_000_000, type: "gold_gain" }, effect: { amount: 800_000_000_000, type: "gold_gain" },
@@ -3036,7 +3021,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
durationSeconds: 11 * 60 * 60, durationSeconds: 29_700,
events: [ events: [
{ {
effect: { amount: 1_600_000_000_000, type: "gold_gain" }, effect: { amount: 1_600_000_000_000, type: "gold_gain" },
@@ -3083,7 +3068,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
durationSeconds: 3 * 60 * 60, durationSeconds: 10_800,
events: [ events: [
{ {
effect: { amount: 600_000_000_000, type: "gold_gain" }, effect: { amount: 600_000_000_000, type: "gold_gain" },
@@ -3127,7 +3112,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
durationSeconds: 6 * 60 * 60, durationSeconds: 21_600,
events: [ events: [
{ {
effect: { amount: 1_200_000_000_000, type: "gold_gain" }, effect: { amount: 1_200_000_000_000, type: "gold_gain" },
@@ -3177,7 +3162,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
durationSeconds: 9 * 60 * 60, durationSeconds: 32_400,
events: [ events: [
{ {
effect: { amount: 2_500_000_000_000, type: "gold_gain" }, effect: { amount: 2_500_000_000_000, type: "gold_gain" },
@@ -3227,7 +3212,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
durationSeconds: 12 * 60 * 60, durationSeconds: 43_200,
events: [ events: [
{ {
effect: { amount: 5_000_000_000_000, type: "gold_gain" }, effect: { amount: 5_000_000_000_000, type: "gold_gain" },
+66
View File
@@ -0,0 +1,66 @@
/**
* @file Draft release notes shown in the About panel ahead of the published
* Gitea releases. Local preview only — remove each entry once the release is
* published on the repository.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { GiteaRelease } from "@elysium/types";
const v060Notes = `## What's New
### Quests
- **Quest timers rebalanced** — all quest durations updated to a more reasonable length; a full run of all quests now takes close to one week excluding bonuses
- **Zone unlocks moved to final quests** — the zone unlock requirement is now the last quest of each zone (Shadow Marshes and the last 3 zones)
### Exploration
- **Exploration timers rebalanced** — timers updated to match the new quest times; each zone now allows 4 of the highest explorations before the quest timers complete
### Adventurers & Upgrades
- **Leyline Binding unlock moved** — the Archmage upgrade now unlocks from The Cursed Library quest instead of The Forest Giant boss
- **Dragon Rider unlock moved** — now unlocks in the Frozen Wastes instead of the Dragon's Lair
### Story
- **Full-size image modal** — the chapter image has been resized slightly and can now be clicked to view a full-size version in a modal
### Prestige
- **Runestone cap doubled** — base Max Runestones cap increased 200 → 400 (750 with Runestone bonuses)
- **Prestige notifications** — the prestige notification now fires every 5 prestiges
- **Number format support** — values in the Prestige window now use your number format preference
- **Silent save after prestige** — a silent save now fires 10 seconds after prestiging, helping with rapid prestiging
- **Milestone bonuses shown** — the Prestige button now properly displays the runestones earned including milestone bonuses
### Companions
- **Quest reduction display** — companion quest time reduction now properly displays in the client interface
- **Unlock requirements reduced** — Kael now requires 500 bosses defeated, Wren 250 quests completed, and Zuri 500 quests completed
### The Absolute
- **Equipment unlocks moved earlier** — equipment previously unlocked by The Absolute One now drops from earlier bosses: Omniversal Core from The Absolute Herald, Eternity Plate from Void Convergence, and Absolute Blade from Eternal End
- **Quest-gated bosses** — each Absolute boss now requires a quest in The Absolute to be completed before it unlocks: The Absolute Herald requires The Final Paradox, Void Convergence requires The Terminal Approach, Eternal End requires The Absolute Depth, and The Absolute One requires The Final Threshold
### Quality of Life
- **Scientific notation by default** — the default number format is now Scientific; the suffix format remains available in edit profile
- **Companions stay selected** — companions now remain selected after Prestige, Transcend, and Apotheosis
- **Prestige in transcendence notifications** — transcendence notifications now include your prestige in the message
### Bug Fixes
- Achievement progress no longer resets on prestige (mainly clicking)
- Runestone Shop and Echo Shop now display the correct cost
- Crystal rewards for gold earned achievements no longer require a prestige to unlock
- Achievements no longer count multiple times when prestiging
- Crafting dailies now give credit when the player crafts
- Quest Eternal achievement is now attainable — changed from 122 quests to the intended 112 quests
`;
export const pendingReleases: Array<GiteaRelease> = [
{
body: v060Notes,
name: "v0.6.0",
// eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name
published_at: "2026-07-15T00:00:00Z",
// eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name
tag_name: "v0.6.0",
},
];
+114 -113
View File
@@ -13,7 +13,7 @@ export const defaultQuests: Array<Quest> = [
{ {
description: description:
"Every legend begins somewhere. Send your first adventurer into the field.", "Every legend begins somewhere. Send your first adventurer into the field.",
durationSeconds: 60, durationSeconds: 45,
id: "first_steps", id: "first_steps",
name: "First Steps", name: "First Steps",
prerequisiteIds: [], prerequisiteIds: [],
@@ -27,7 +27,7 @@ export const defaultQuests: Array<Quest> = [
}, },
{ {
description: "Clear out a troublesome goblin camp to the east.", description: "Clear out a troublesome goblin camp to the east.",
durationSeconds: 5 * 60, durationSeconds: 120,
id: "goblin_camp", id: "goblin_camp",
name: "Goblin Camp", name: "Goblin Camp",
prerequisiteIds: [ "first_steps" ], prerequisiteIds: [ "first_steps" ],
@@ -46,7 +46,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 10, combatPowerRequired: 10,
description: description:
"An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.",
durationSeconds: 15 * 60, durationSeconds: 150,
id: "haunted_mine", id: "haunted_mine",
name: "The Haunted Mine", name: "The Haunted Mine",
prerequisiteIds: [ "goblin_camp" ], prerequisiteIds: [ "goblin_camp" ],
@@ -63,7 +63,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 50, combatPowerRequired: 50,
description: description:
"Scholars believe the ruins hold secrets of a forgotten civilisation.", "Scholars believe the ruins hold secrets of a forgotten civilisation.",
durationSeconds: 30 * 60, durationSeconds: 180,
id: "ancient_ruins", id: "ancient_ruins",
name: "Ancient Ruins", name: "Ancient Ruins",
prerequisiteIds: [ "haunted_mine" ], prerequisiteIds: [ "haunted_mine" ],
@@ -79,7 +79,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 500, combatPowerRequired: 500,
description: description:
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.", "A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
durationSeconds: 30 * 60, durationSeconds: 120,
id: "necromancer_tower", id: "necromancer_tower",
name: "Necromancer's Tower", name: "Necromancer's Tower",
prerequisiteIds: [], prerequisiteIds: [],
@@ -96,7 +96,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2000, combatPowerRequired: 2000,
description: description:
"An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.", "An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.",
durationSeconds: 45 * 60, durationSeconds: 150,
id: "crumbling_fortress", id: "crumbling_fortress",
name: "The Crumbling Fortress", name: "The Crumbling Fortress",
prerequisiteIds: [ "necromancer_tower" ], prerequisiteIds: [ "necromancer_tower" ],
@@ -113,7 +113,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8000, combatPowerRequired: 8000,
description: description:
"A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.", "A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.",
durationSeconds: 60 * 60, durationSeconds: 180,
id: "cursed_library", id: "cursed_library",
name: "The Cursed Library", name: "The Cursed Library",
prerequisiteIds: [ "crumbling_fortress" ], prerequisiteIds: [ "crumbling_fortress" ],
@@ -121,6 +121,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 300, type: "essence" }, { amount: 300, type: "essence" },
{ amount: 30, type: "crystals" }, { amount: 30, type: "crystals" },
{ targetId: "archmage", type: "adventurer" }, { targetId: "archmage", type: "adventurer" },
{ targetId: "archmage_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -129,7 +130,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 30_000, combatPowerRequired: 30_000,
description: description:
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.", "The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.",
durationSeconds: 90 * 60, durationSeconds: 240,
id: "dragon_lair", id: "dragon_lair",
name: "Dragon's Lair", name: "Dragon's Lair",
prerequisiteIds: [ "cursed_library" ], prerequisiteIds: [ "cursed_library" ],
@@ -137,7 +138,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 500_000, type: "gold" }, { amount: 500_000, type: "gold" },
{ amount: 50, type: "crystals" }, { amount: 50, type: "crystals" },
{ targetId: "paladin", type: "adventurer" }, { targetId: "paladin", type: "adventurer" },
{ targetId: "dragon_rider", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -147,7 +147,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 100_000, combatPowerRequired: 100_000,
description: description:
"A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.", "A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.",
durationSeconds: 15 * 60, durationSeconds: 120,
id: "frozen_wastes", id: "frozen_wastes",
name: "The Frozen Wastes", name: "The Frozen Wastes",
prerequisiteIds: [], prerequisiteIds: [],
@@ -156,6 +156,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 100, type: "crystals" }, { amount: 100, type: "crystals" },
{ targetId: "global_3", type: "upgrade" }, { targetId: "global_3", type: "upgrade" },
{ targetId: "knight_1", type: "upgrade" }, { targetId: "knight_1", type: "upgrade" },
{ targetId: "dragon_rider", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -164,7 +165,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 200_000, combatPowerRequired: 200_000,
description: description:
"A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.", "A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.",
durationSeconds: 20 * 60, durationSeconds: 150,
id: "glacier_tomb", id: "glacier_tomb",
name: "The Glacier Tomb", name: "The Glacier Tomb",
prerequisiteIds: [ "frozen_wastes" ], prerequisiteIds: [ "frozen_wastes" ],
@@ -180,7 +181,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 400_000, combatPowerRequired: 400_000,
description: description:
"A labyrinthine network of crystal caverns that descend for miles. The cold here is a presence, not just a temperature.", "A labyrinthine network of crystal caverns that descend for miles. The cold here is a presence, not just a temperature.",
durationSeconds: 25 * 60, durationSeconds: 195,
id: "ice_caves", id: "ice_caves",
name: "The Ice Caves", name: "The Ice Caves",
prerequisiteIds: [ "glacier_tomb" ], prerequisiteIds: [ "glacier_tomb" ],
@@ -196,7 +197,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1_500_000, combatPowerRequired: 1_500_000,
description: description:
"A fortress suspended in a permanent blizzard, built by a mage who wanted to be left alone — and succeeded for three hundred years.", "A fortress suspended in a permanent blizzard, built by a mage who wanted to be left alone — and succeeded for three hundred years.",
durationSeconds: 45 * 60, durationSeconds: 240,
id: "storm_citadel", id: "storm_citadel",
name: "The Storm Citadel", name: "The Storm Citadel",
prerequisiteIds: [ "ice_caves" ], prerequisiteIds: [ "ice_caves" ],
@@ -211,7 +212,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3_000_000, combatPowerRequired: 3_000_000,
description: description:
"Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.", "Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.",
durationSeconds: 1 * 60 * 60, durationSeconds: 300,
id: "frozen_throne", id: "frozen_throne",
name: "The Frozen Throne", name: "The Frozen Throne",
prerequisiteIds: [ "storm_citadel" ], prerequisiteIds: [ "storm_citadel" ],
@@ -228,7 +229,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2_000_000, combatPowerRequired: 2_000_000,
description: description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 5 * 60, durationSeconds: 195,
id: "shadow_mere", id: "shadow_mere",
name: "The Shadow Mere", name: "The Shadow Mere",
prerequisiteIds: [], prerequisiteIds: [],
@@ -245,7 +246,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 20_000_000, combatPowerRequired: 20_000_000,
description: description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.", "Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 15 * 60, durationSeconds: 240,
id: "witch_coven", id: "witch_coven",
name: "The Witch Coven", name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ], prerequisiteIds: [ "shadow_mere" ],
@@ -262,7 +263,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 80_000_000, combatPowerRequired: 80_000_000,
description: description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.", "An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 15 * 60, durationSeconds: 285,
id: "sunken_temple", id: "sunken_temple",
name: "The Sunken Temple", name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ], prerequisiteIds: [ "witch_coven" ],
@@ -278,7 +279,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 300_000_000, combatPowerRequired: 300_000_000,
description: description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.", "A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 25 * 60, durationSeconds: 360,
id: "plague_ruins", id: "plague_ruins",
name: "The Plague Ruins", name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ], prerequisiteIds: [ "sunken_temple" ],
@@ -296,7 +297,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1_200_000_000, combatPowerRequired: 1_200_000_000,
description: description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 25 * 60, durationSeconds: 210,
id: "lava_flows", id: "lava_flows",
name: "The Lava Flows", name: "The Lava Flows",
prerequisiteIds: [], prerequisiteIds: [],
@@ -312,7 +313,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4_800_000_000, combatPowerRequired: 4_800_000_000,
description: description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 45 * 60, durationSeconds: 270,
id: "fire_temple", id: "fire_temple",
name: "The Temple of the Flame", name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ], prerequisiteIds: [ "lava_flows" ],
@@ -328,7 +329,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 18_000_000_000, combatPowerRequired: 18_000_000_000,
description: 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.", "Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
durationSeconds: 1 * 60 * 60, durationSeconds: 330,
id: "magma_caverns", id: "magma_caverns",
name: "The Magma Caverns", name: "The Magma Caverns",
prerequisiteIds: [ "fire_temple" ], prerequisiteIds: [ "fire_temple" ],
@@ -344,7 +345,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 72_000_000_000, combatPowerRequired: 72_000_000_000,
description: description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", "The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
durationSeconds: 90 * 60, durationSeconds: 420,
id: "the_forge", id: "the_forge",
name: "The Primordial Forge", name: "The Primordial Forge",
prerequisiteIds: [ "magma_caverns" ], prerequisiteIds: [ "magma_caverns" ],
@@ -361,7 +362,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 300_000_000_000, combatPowerRequired: 300_000_000_000,
description: description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 35 * 60, durationSeconds: 240,
id: "void_rift", id: "void_rift",
name: "Void Rift", name: "Void Rift",
prerequisiteIds: [], prerequisiteIds: [],
@@ -377,7 +378,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1_200_000_000_000, combatPowerRequired: 1_200_000_000_000,
description: 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.", "A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
durationSeconds: 1 * 60 * 60, durationSeconds: 300,
id: "star_graveyard", id: "star_graveyard",
name: "The Star Graveyard", name: "The Star Graveyard",
prerequisiteIds: [ "void_rift" ], prerequisiteIds: [ "void_rift" ],
@@ -393,7 +394,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4_800_000_000_000, combatPowerRequired: 4_800_000_000_000,
description: description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", "The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
durationSeconds: 90 * 60, durationSeconds: 420,
id: "between_worlds", id: "between_worlds",
name: "Between Worlds", name: "Between Worlds",
prerequisiteIds: [ "star_graveyard" ], prerequisiteIds: [ "star_graveyard" ],
@@ -410,7 +411,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 18_000_000_000_000, combatPowerRequired: 18_000_000_000_000,
description: description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", "There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
durationSeconds: 210 * 60, durationSeconds: 600,
id: "the_end", id: "the_end",
name: "The End of All Things", name: "The End of All Things",
prerequisiteIds: [ "between_worlds" ], prerequisiteIds: [ "between_worlds" ],
@@ -427,7 +428,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e13, combatPowerRequired: 7.2e13,
description: description:
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.", "The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
durationSeconds: 90 * 60, durationSeconds: 225,
id: "heavens_gate", id: "heavens_gate",
name: "The Heaven's Gate", name: "The Heaven's Gate",
prerequisiteIds: [], prerequisiteIds: [],
@@ -443,7 +444,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e14, combatPowerRequired: 3e14,
description: description:
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.", "A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
durationSeconds: 25 * 60, durationSeconds: 300,
id: "angelic_choir", id: "angelic_choir",
name: "The Angelic Choir", name: "The Angelic Choir",
prerequisiteIds: [ "heavens_gate" ], prerequisiteIds: [ "heavens_gate" ],
@@ -458,7 +459,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e15, combatPowerRequired: 1.2e15,
description: description:
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.", "Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
durationSeconds: 45 * 60, durationSeconds: 390,
id: "divine_library", id: "divine_library",
name: "The Divine Library", name: "The Divine Library",
prerequisiteIds: [ "angelic_choir" ], prerequisiteIds: [ "angelic_choir" ],
@@ -474,7 +475,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e15, combatPowerRequired: 4.8e15,
description: description:
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.", "A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
durationSeconds: 1 * 60 * 60, durationSeconds: 510,
id: "cloud_citadel", id: "cloud_citadel",
name: "The Cloud Citadel", name: "The Cloud Citadel",
prerequisiteIds: [ "divine_library" ], prerequisiteIds: [ "divine_library" ],
@@ -490,7 +491,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e16, combatPowerRequired: 1.8e16,
description: description:
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.", "The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
durationSeconds: 90 * 60, durationSeconds: 660,
id: "trial_of_virtue", id: "trial_of_virtue",
name: "The Trial of Virtue", name: "The Trial of Virtue",
prerequisiteIds: [ "cloud_citadel" ], prerequisiteIds: [ "cloud_citadel" ],
@@ -507,7 +508,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e16, combatPowerRequired: 7.2e16,
description: description:
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.", "The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
durationSeconds: 3 * 60 * 60, durationSeconds: 900,
id: "celestial_archive", id: "celestial_archive",
name: "The Celestial Archive", name: "The Celestial Archive",
prerequisiteIds: [ "trial_of_virtue" ], prerequisiteIds: [ "trial_of_virtue" ],
@@ -524,7 +525,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e17, combatPowerRequired: 3e17,
description: description:
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.", "The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
durationSeconds: 15 * 60, durationSeconds: 360,
id: "the_dark_waters", id: "the_dark_waters",
name: "The Dark Waters", name: "The Dark Waters",
prerequisiteIds: [], prerequisiteIds: [],
@@ -540,7 +541,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e18, combatPowerRequired: 1.2e18,
description: description:
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.", "The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
durationSeconds: 35 * 60, durationSeconds: 450,
id: "bioluminescent_ruins", id: "bioluminescent_ruins",
name: "The Bioluminescent Ruins", name: "The Bioluminescent Ruins",
prerequisiteIds: [ "the_dark_waters" ], prerequisiteIds: [ "the_dark_waters" ],
@@ -556,7 +557,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e18, combatPowerRequired: 4.8e18,
description: description:
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.", "Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
durationSeconds: 1 * 60 * 60, durationSeconds: 570,
id: "pressure_caves", id: "pressure_caves",
name: "The Pressure Caves", name: "The Pressure Caves",
prerequisiteIds: [ "bioluminescent_ruins" ], prerequisiteIds: [ "bioluminescent_ruins" ],
@@ -572,7 +573,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e19, combatPowerRequired: 1.8e19,
description: description:
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.", "Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
durationSeconds: 90 * 60, durationSeconds: 660,
id: "leviathan_graveyard", id: "leviathan_graveyard",
name: "The Leviathan Graveyard", name: "The Leviathan Graveyard",
prerequisiteIds: [ "pressure_caves" ], prerequisiteIds: [ "pressure_caves" ],
@@ -588,7 +589,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e19, combatPowerRequired: 7.2e19,
description: description:
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.", "A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
durationSeconds: 150 * 60, durationSeconds: 900,
id: "black_throne", id: "black_throne",
name: "The Black Throne", name: "The Black Throne",
prerequisiteIds: [ "leviathan_graveyard" ], prerequisiteIds: [ "leviathan_graveyard" ],
@@ -605,7 +606,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e20, combatPowerRequired: 3e20,
description: description:
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.", "The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
durationSeconds: 270 * 60, durationSeconds: 1200,
id: "abyssal_chronicle", id: "abyssal_chronicle",
name: "The Abyssal Chronicle", name: "The Abyssal Chronicle",
prerequisiteIds: [ "black_throne" ], prerequisiteIds: [ "black_throne" ],
@@ -622,7 +623,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e21, combatPowerRequired: 1.2e21,
description: description:
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.", "The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
durationSeconds: 25 * 60, durationSeconds: 390,
id: "brimstone_wastes", id: "brimstone_wastes",
name: "The Brimstone Wastes", name: "The Brimstone Wastes",
prerequisiteIds: [], prerequisiteIds: [],
@@ -638,7 +639,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e21, combatPowerRequired: 4.8e21,
description: description:
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.", "The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
durationSeconds: 50 * 60, durationSeconds: 540,
id: "pit_of_souls", id: "pit_of_souls",
name: "The Pit of Souls", name: "The Pit of Souls",
prerequisiteIds: [ "brimstone_wastes" ], prerequisiteIds: [ "brimstone_wastes" ],
@@ -654,7 +655,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e22, combatPowerRequired: 1.8e22,
description: description:
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.", "The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
durationSeconds: 90 * 60, durationSeconds: 690,
id: "court_of_blood", id: "court_of_blood",
name: "The Court of Blood", name: "The Court of Blood",
prerequisiteIds: [ "pit_of_souls" ], prerequisiteIds: [ "pit_of_souls" ],
@@ -670,7 +671,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e22, combatPowerRequired: 7.2e22,
description: description:
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.", "Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
durationSeconds: 150 * 60, durationSeconds: 900,
id: "nine_hells", id: "nine_hells",
name: "The Nine Hells", name: "The Nine Hells",
prerequisiteIds: [ "court_of_blood" ], prerequisiteIds: [ "court_of_blood" ],
@@ -686,7 +687,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e23, combatPowerRequired: 3e23,
description: description:
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.", "The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
durationSeconds: 210 * 60, durationSeconds: 1200,
id: "demon_forge", id: "demon_forge",
name: "The Demon Forge", name: "The Demon Forge",
prerequisiteIds: [ "nine_hells" ], prerequisiteIds: [ "nine_hells" ],
@@ -703,7 +704,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e24, combatPowerRequired: 1.2e24,
description: description:
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.", "The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
durationSeconds: 330 * 60, durationSeconds: 1500,
id: "infernal_codex", id: "infernal_codex",
name: "The Infernal Codex", name: "The Infernal Codex",
prerequisiteIds: [ "demon_forge" ], prerequisiteIds: [ "demon_forge" ],
@@ -720,7 +721,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e24, combatPowerRequired: 4.8e24,
description: description:
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.", "The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
durationSeconds: 35 * 60, durationSeconds: 600,
id: "prism_gate", id: "prism_gate",
name: "The Prism Gate", name: "The Prism Gate",
prerequisiteIds: [], prerequisiteIds: [],
@@ -736,7 +737,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e25, combatPowerRequired: 1.8e25,
description: description:
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.", "A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
durationSeconds: 1 * 60 * 60, durationSeconds: 780,
id: "crystal_labyrinth", id: "crystal_labyrinth",
name: "The Crystal Labyrinth", name: "The Crystal Labyrinth",
prerequisiteIds: [ "prism_gate" ], prerequisiteIds: [ "prism_gate" ],
@@ -752,7 +753,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e25, combatPowerRequired: 7.2e25,
description: description:
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.", "A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
durationSeconds: 2 * 60 * 60, durationSeconds: 960,
id: "faceted_realm", id: "faceted_realm",
name: "The Faceted Realm", name: "The Faceted Realm",
prerequisiteIds: [ "crystal_labyrinth" ], prerequisiteIds: [ "crystal_labyrinth" ],
@@ -768,7 +769,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e26, combatPowerRequired: 3e26,
description: description:
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.", "The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
durationSeconds: 3 * 60 * 60, durationSeconds: 1200,
id: "diamond_vault", id: "diamond_vault",
name: "The Diamond Vault", name: "The Diamond Vault",
prerequisiteIds: [ "faceted_realm" ], prerequisiteIds: [ "faceted_realm" ],
@@ -784,7 +785,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e27, combatPowerRequired: 1.2e27,
description: description:
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.", "The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
durationSeconds: 270 * 60, durationSeconds: 1500,
id: "sovereign_spire", id: "sovereign_spire",
name: "The Sovereign's Spire", name: "The Sovereign's Spire",
prerequisiteIds: [ "diamond_vault" ], prerequisiteIds: [ "diamond_vault" ],
@@ -801,7 +802,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e27, combatPowerRequired: 4.8e27,
description: description:
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.", "The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
durationSeconds: 7 * 60 * 60, durationSeconds: 1800,
id: "the_prism_vault", id: "the_prism_vault",
name: "The Prism Vault", name: "The Prism Vault",
prerequisiteIds: [ "sovereign_spire" ], prerequisiteIds: [ "sovereign_spire" ],
@@ -818,7 +819,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e28, combatPowerRequired: 1.8e28,
description: description:
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.", "The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
durationSeconds: 50 * 60, durationSeconds: 900,
id: "void_threshold", id: "void_threshold",
name: "The Void Threshold", name: "The Void Threshold",
prerequisiteIds: [], prerequisiteIds: [],
@@ -834,7 +835,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e28, combatPowerRequired: 7.2e28,
description: description:
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.", "Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
durationSeconds: 90 * 60, durationSeconds: 1200,
id: "eternal_dark", id: "eternal_dark",
name: "The Eternal Dark", name: "The Eternal Dark",
prerequisiteIds: [ "void_threshold" ], prerequisiteIds: [ "void_threshold" ],
@@ -850,7 +851,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e29, combatPowerRequired: 3e29,
description: description:
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.", "The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
durationSeconds: 3 * 60 * 60, durationSeconds: 1500,
id: "sanctum_depths", id: "sanctum_depths",
name: "The Sanctum Depths", name: "The Sanctum Depths",
prerequisiteIds: [ "eternal_dark" ], prerequisiteIds: [ "eternal_dark" ],
@@ -866,7 +867,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e30, combatPowerRequired: 1.2e30,
description: description:
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.", "Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
durationSeconds: 270 * 60, durationSeconds: 1800,
id: "unmaking_grounds", id: "unmaking_grounds",
name: "The Unmaking Grounds", name: "The Unmaking Grounds",
prerequisiteIds: [ "sanctum_depths" ], prerequisiteIds: [ "sanctum_depths" ],
@@ -882,7 +883,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e30, combatPowerRequired: 4.8e30,
description: description:
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.", "The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
durationSeconds: 7 * 60 * 60, durationSeconds: 2100,
id: "emperor_approach", id: "emperor_approach",
name: "The Emperor's Approach", name: "The Emperor's Approach",
prerequisiteIds: [ "unmaking_grounds" ], prerequisiteIds: [ "unmaking_grounds" ],
@@ -899,7 +900,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e31, combatPowerRequired: 1.8e31,
description: description:
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.", "The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
durationSeconds: 10 * 60 * 60, durationSeconds: 2400,
id: "heart_of_void", id: "heart_of_void",
name: "The Heart of the Void", name: "The Heart of the Void",
prerequisiteIds: [ "emperor_approach" ], prerequisiteIds: [ "emperor_approach" ],
@@ -916,7 +917,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e31, combatPowerRequired: 7.2e31,
description: description:
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.", "The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
durationSeconds: 1 * 60 * 60, durationSeconds: 1200,
id: "throne_antechamber", id: "throne_antechamber",
name: "The Throne Antechamber", name: "The Throne Antechamber",
prerequisiteIds: [], prerequisiteIds: [],
@@ -932,7 +933,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e32, combatPowerRequired: 3e32,
description: description:
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.", "A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
durationSeconds: 150 * 60, durationSeconds: 1500,
id: "eternal_gauntlet", id: "eternal_gauntlet",
name: "The Eternal Gauntlet", name: "The Eternal Gauntlet",
prerequisiteIds: [ "throne_antechamber" ], prerequisiteIds: [ "throne_antechamber" ],
@@ -948,7 +949,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e33, combatPowerRequired: 1.2e33,
description: description:
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.", "The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
durationSeconds: 4 * 60 * 60, durationSeconds: 1800,
id: "apex_trials", id: "apex_trials",
name: "The Apex Trials", name: "The Apex Trials",
prerequisiteIds: [ "eternal_gauntlet" ], prerequisiteIds: [ "eternal_gauntlet" ],
@@ -964,7 +965,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e33, combatPowerRequired: 4.8e33,
description: description:
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.", "The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
durationSeconds: 330 * 60, durationSeconds: 2100,
id: "sovereign_hall", id: "sovereign_hall",
name: "The Sovereign's Hall", name: "The Sovereign's Hall",
prerequisiteIds: [ "apex_trials" ], prerequisiteIds: [ "apex_trials" ],
@@ -981,7 +982,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e34, combatPowerRequired: 1.8e34,
description: description:
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.", "The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
durationSeconds: 9 * 60 * 60, durationSeconds: 2700,
id: "the_final_ascent", id: "the_final_ascent",
name: "The Final Ascent", name: "The Final Ascent",
prerequisiteIds: [ "sovereign_hall" ], prerequisiteIds: [ "sovereign_hall" ],
@@ -997,7 +998,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e34, combatPowerRequired: 7.2e34,
description: description:
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.", "The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
durationSeconds: 14 * 60 * 60, durationSeconds: 3600,
id: "eternal_dominion", id: "eternal_dominion",
name: "Eternal Dominion", name: "Eternal Dominion",
prerequisiteIds: [ "the_final_ascent" ], prerequisiteIds: [ "the_final_ascent" ],
@@ -1014,7 +1015,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e35, combatPowerRequired: 3e35,
description: description:
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.", "Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
durationSeconds: 90 * 60, durationSeconds: 1500,
id: "chaos_entry", id: "chaos_entry",
name: "Into the Chaos", name: "Into the Chaos",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1030,7 +1031,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e36, combatPowerRequired: 1.2e36,
description: description:
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.", "Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
durationSeconds: 150 * 60, durationSeconds: 1800,
id: "chaos_currents", id: "chaos_currents",
name: "The Chaos Currents", name: "The Chaos Currents",
prerequisiteIds: [ "chaos_entry" ], prerequisiteIds: [ "chaos_entry" ],
@@ -1046,7 +1047,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e36, combatPowerRequired: 4.8e36,
description: description:
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.", "A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
durationSeconds: 270 * 60, durationSeconds: 2400,
id: "unformed_wastes", id: "unformed_wastes",
name: "The Unformed Wastes", name: "The Unformed Wastes",
prerequisiteIds: [ "chaos_currents" ], prerequisiteIds: [ "chaos_currents" ],
@@ -1063,7 +1064,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e37, combatPowerRequired: 1.8e37,
description: description:
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.", "Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
durationSeconds: 6 * 60 * 60, durationSeconds: 3000,
id: "potential_vaults", id: "potential_vaults",
name: "The Vaults of Potential", name: "The Vaults of Potential",
prerequisiteIds: [ "unformed_wastes" ], prerequisiteIds: [ "unformed_wastes" ],
@@ -1079,7 +1080,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e37, combatPowerRequired: 7.2e37,
description: description:
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.", "The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
durationSeconds: 9 * 60 * 60, durationSeconds: 3600,
id: "creation_cradle", id: "creation_cradle",
name: "The Creation Cradle", name: "The Creation Cradle",
prerequisiteIds: [ "potential_vaults" ], prerequisiteIds: [ "potential_vaults" ],
@@ -1096,7 +1097,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e38, combatPowerRequired: 3e38,
description: description:
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.", "The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
durationSeconds: 13 * 60 * 60, durationSeconds: 4800,
id: "chaos_chronicle", id: "chaos_chronicle",
name: "The Chaos Chronicle", name: "The Chaos Chronicle",
prerequisiteIds: [ "creation_cradle" ], prerequisiteIds: [ "creation_cradle" ],
@@ -1113,7 +1114,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e39, combatPowerRequired: 1.2e39,
description: description:
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.", "The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
durationSeconds: 90 * 60, durationSeconds: 1800,
id: "first_horizon", id: "first_horizon",
name: "The First Horizon", name: "The First Horizon",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1129,7 +1130,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e39, combatPowerRequired: 4.8e39,
description: description:
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.", "An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
durationSeconds: 3 * 60 * 60, durationSeconds: 2400,
id: "endless_sea", id: "endless_sea",
name: "The Endless Sea", name: "The Endless Sea",
prerequisiteIds: [ "first_horizon" ], prerequisiteIds: [ "first_horizon" ],
@@ -1145,7 +1146,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e40, combatPowerRequired: 1.8e40,
description: description:
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.", "Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
durationSeconds: 5 * 60 * 60, durationSeconds: 3000,
id: "expanse_ruins", id: "expanse_ruins",
name: "The Expanse Ruins", name: "The Expanse Ruins",
prerequisiteIds: [ "endless_sea" ], prerequisiteIds: [ "endless_sea" ],
@@ -1162,7 +1163,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e40, combatPowerRequired: 7.2e40,
description: description:
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.", "A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
durationSeconds: 8 * 60 * 60, durationSeconds: 3900,
id: "infinite_archive", id: "infinite_archive",
name: "The Infinite Archive", name: "The Infinite Archive",
prerequisiteIds: [ "expanse_ruins" ], prerequisiteIds: [ "expanse_ruins" ],
@@ -1179,7 +1180,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e41, combatPowerRequired: 3e41,
description: description:
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.", "A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
durationSeconds: 11 * 60 * 60, durationSeconds: 5100,
id: "paradox_plains", id: "paradox_plains",
name: "The Paradox Plains", name: "The Paradox Plains",
prerequisiteIds: [ "infinite_archive" ], prerequisiteIds: [ "infinite_archive" ],
@@ -1196,7 +1197,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e42, combatPowerRequired: 1.2e42,
description: description:
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.", "The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
durationSeconds: 16 * 60 * 60, durationSeconds: 6600,
id: "expanse_codex", id: "expanse_codex",
name: "The Expanse Codex", name: "The Expanse Codex",
prerequisiteIds: [ "paradox_plains" ], prerequisiteIds: [ "paradox_plains" ],
@@ -1213,7 +1214,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e42, combatPowerRequired: 4.8e42,
description: description:
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.", "The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
durationSeconds: 2 * 60 * 60, durationSeconds: 1800,
id: "forge_entrance", id: "forge_entrance",
name: "The Forge Entrance", name: "The Forge Entrance",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1229,7 +1230,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e43, combatPowerRequired: 1.8e43,
description: description:
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.", "The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
durationSeconds: 210 * 60, durationSeconds: 2400,
id: "blueprint_vault", id: "blueprint_vault",
name: "The Blueprint Vault", name: "The Blueprint Vault",
prerequisiteIds: [ "forge_entrance" ], prerequisiteIds: [ "forge_entrance" ],
@@ -1245,7 +1246,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e43, combatPowerRequired: 7.2e43,
description: description:
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.", "The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
durationSeconds: 330 * 60, durationSeconds: 3000,
id: "creation_workshop", id: "creation_workshop",
name: "The Creation Workshop", name: "The Creation Workshop",
prerequisiteIds: [ "blueprint_vault" ], prerequisiteIds: [ "blueprint_vault" ],
@@ -1262,7 +1263,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e44, combatPowerRequired: 3e44,
description: description:
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.", "The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
durationSeconds: 9 * 60 * 60, durationSeconds: 3600,
id: "laws_engine", id: "laws_engine",
name: "The Laws Engine", name: "The Laws Engine",
prerequisiteIds: [ "creation_workshop" ], prerequisiteIds: [ "creation_workshop" ],
@@ -1279,7 +1280,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e45, combatPowerRequired: 1.2e45,
description: description:
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.", "The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
durationSeconds: 12 * 60 * 60, durationSeconds: 4800,
id: "forge_heart", id: "forge_heart",
name: "The Forge Heart", name: "The Forge Heart",
prerequisiteIds: [ "laws_engine" ], prerequisiteIds: [ "laws_engine" ],
@@ -1295,7 +1296,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e45, combatPowerRequired: 4.8e45,
description: description:
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.", "The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
durationSeconds: 17 * 60 * 60, durationSeconds: 6000,
id: "forge_chronicle", id: "forge_chronicle",
name: "The Forge Chronicle", name: "The Forge Chronicle",
prerequisiteIds: [ "forge_heart" ], prerequisiteIds: [ "forge_heart" ],
@@ -1312,7 +1313,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e59, combatPowerRequired: 1e59,
description: description:
"The deep levels of the Forge, where the most experimental realities are stored in a state of near-completion. Your guild discovers that several of these abandoned projects are disturbingly familiar.", "The deep levels of the Forge, where the most experimental realities are stored in a state of near-completion. Your guild discovers that several of these abandoned projects are disturbingly familiar.",
durationSeconds: 24 * 60 * 60, durationSeconds: 7200,
id: "forge_depths", id: "forge_depths",
name: "The Forge Depths", name: "The Forge Depths",
prerequisiteIds: [ "forge_chronicle" ], prerequisiteIds: [ "forge_chronicle" ],
@@ -1328,7 +1329,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e64, combatPowerRequired: 3e64,
description: description:
"The underlying structure that defines the laws for every reality the Forge produces — a lattice of constraints so fundamental that violating them would undo everything. Your guild crosses it carefully.", "The underlying structure that defines the laws for every reality the Forge produces — a lattice of constraints so fundamental that violating them would undo everything. Your guild crosses it carefully.",
durationSeconds: 24 * 60 * 60, durationSeconds: 9300,
id: "prime_matrix", id: "prime_matrix",
name: "The Prime Matrix", name: "The Prime Matrix",
prerequisiteIds: [ "forge_depths" ], prerequisiteIds: [ "forge_depths" ],
@@ -1344,7 +1345,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8e69, combatPowerRequired: 8e69,
description: description:
"The complete record of every reality the Forge has ever produced, indexed, annotated, and preserved. Your universe has a surprisingly detailed entry with several editorial notes.", "The complete record of every reality the Forge has ever produced, indexed, annotated, and preserved. Your universe has a surprisingly detailed entry with several editorial notes.",
durationSeconds: 24 * 60 * 60, durationSeconds: 11_400,
id: "creation_archive", id: "creation_archive",
name: "The Creation Archive", name: "The Creation Archive",
prerequisiteIds: [ "prime_matrix" ], prerequisiteIds: [ "prime_matrix" ],
@@ -1361,7 +1362,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e46, combatPowerRequired: 1.8e46,
description: description:
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.", "The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
durationSeconds: 150 * 60, durationSeconds: 3000,
id: "maelstrom_entry", id: "maelstrom_entry",
name: "The Maelstrom's Edge", name: "The Maelstrom's Edge",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1377,7 +1378,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e46, combatPowerRequired: 7.2e46,
description: description:
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.", "The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
durationSeconds: 4 * 60 * 60, durationSeconds: 3900,
id: "force_nexus", id: "force_nexus",
name: "The Force Nexus", name: "The Force Nexus",
prerequisiteIds: [ "maelstrom_entry" ], prerequisiteIds: [ "maelstrom_entry" ],
@@ -1393,7 +1394,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e47, combatPowerRequired: 3e47,
description: description:
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.", "A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
durationSeconds: 6 * 60 * 60, durationSeconds: 4800,
id: "storm_cauldron", id: "storm_cauldron",
name: "The Storm Cauldron", name: "The Storm Cauldron",
prerequisiteIds: [ "force_nexus" ], prerequisiteIds: [ "force_nexus" ],
@@ -1410,7 +1411,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e48, combatPowerRequired: 1.2e48,
description: description:
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.", "Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
durationSeconds: 9 * 60 * 60, durationSeconds: 6000,
id: "annihilation_fields", id: "annihilation_fields",
name: "The Annihilation Fields", name: "The Annihilation Fields",
prerequisiteIds: [ "storm_cauldron" ], prerequisiteIds: [ "storm_cauldron" ],
@@ -1426,7 +1427,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e48, combatPowerRequired: 4.8e48,
description: description:
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.", "The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
durationSeconds: 13 * 60 * 60, durationSeconds: 7200,
id: "convergence_point", id: "convergence_point",
name: "The Convergence Point", name: "The Convergence Point",
prerequisiteIds: [ "annihilation_fields" ], prerequisiteIds: [ "annihilation_fields" ],
@@ -1443,7 +1444,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e49, combatPowerRequired: 1.8e49,
description: description:
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.", "The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
durationSeconds: 19 * 60 * 60, durationSeconds: 9300,
id: "maelstrom_codex", id: "maelstrom_codex",
name: "The Maelstrom Codex", name: "The Maelstrom Codex",
prerequisiteIds: [ "convergence_point" ], prerequisiteIds: [ "convergence_point" ],
@@ -1460,7 +1461,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2e73, combatPowerRequired: 2e73,
description: description:
"The deepest layer of the maelstrom — where the storms have been spiralling for so long they have created something resembling order. Your guild navigates it by learning to read the shape of chaos.", "The deepest layer of the maelstrom — where the storms have been spiralling for so long they have created something resembling order. Your guild navigates it by learning to read the shape of chaos.",
durationSeconds: 24 * 60 * 60, durationSeconds: 11_400,
id: "maelstrom_deep", id: "maelstrom_deep",
name: "The Deep Maelstrom", name: "The Deep Maelstrom",
prerequisiteIds: [ "maelstrom_codex" ], prerequisiteIds: [ "maelstrom_codex" ],
@@ -1476,7 +1477,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 5e79, combatPowerRequired: 5e79,
description: description:
"The point at which all the storm currents converge — not because they are drawn there, but because there is nowhere else left to go. Your guild stands in the geometric centre of cosmic fury.", "The point at which all the storm currents converge — not because they are drawn there, but because there is nowhere else left to go. Your guild stands in the geometric centre of cosmic fury.",
durationSeconds: 24 * 60 * 60, durationSeconds: 14_400,
id: "maelstrom_nexus", id: "maelstrom_nexus",
name: "The Storm Nexus", name: "The Storm Nexus",
prerequisiteIds: [ "maelstrom_deep" ], prerequisiteIds: [ "maelstrom_deep" ],
@@ -1492,7 +1493,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e86, combatPowerRequired: 1e86,
description: description:
"The record of every storm that has ever been — an archive written in lightning, indexed in thunder, preserved in the kind of silence that only exists at the exact centre of infinite noise.", "The record of every storm that has ever been — an archive written in lightning, indexed in thunder, preserved in the kind of silence that only exists at the exact centre of infinite noise.",
durationSeconds: 24 * 60 * 60, durationSeconds: 18_000,
id: "storm_chronicle", id: "storm_chronicle",
name: "The Storm Chronicle", name: "The Storm Chronicle",
prerequisiteIds: [ "maelstrom_nexus" ], prerequisiteIds: [ "maelstrom_nexus" ],
@@ -1509,7 +1510,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e49, combatPowerRequired: 7.2e49,
description: description:
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.", "The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
durationSeconds: 150 * 60, durationSeconds: 4800,
id: "sanctum_gate", id: "sanctum_gate",
name: "The Sanctum Gate", name: "The Sanctum Gate",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1525,7 +1526,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e50, combatPowerRequired: 3e50,
description: description:
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.", "The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
durationSeconds: 270 * 60, durationSeconds: 5700,
id: "memory_vaults", id: "memory_vaults",
name: "The Memory Vaults", name: "The Memory Vaults",
prerequisiteIds: [ "sanctum_gate" ], prerequisiteIds: [ "sanctum_gate" ],
@@ -1541,7 +1542,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e51, combatPowerRequired: 1.2e51,
description: description:
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.", "The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
durationSeconds: 7 * 60 * 60, durationSeconds: 7200,
id: "origin_halls", id: "origin_halls",
name: "The Origin Halls", name: "The Origin Halls",
prerequisiteIds: [ "memory_vaults" ], prerequisiteIds: [ "memory_vaults" ],
@@ -1558,7 +1559,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e51, combatPowerRequired: 4.8e51,
description: description:
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.", "The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
durationSeconds: 10 * 60 * 60, durationSeconds: 9000,
id: "first_light_hall", id: "first_light_hall",
name: "The Hall of First Light", name: "The Hall of First Light",
prerequisiteIds: [ "origin_halls" ], prerequisiteIds: [ "origin_halls" ],
@@ -1574,7 +1575,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e52, combatPowerRequired: 1.8e52,
description: description:
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.", "A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
durationSeconds: 14 * 60 * 60, durationSeconds: 11_400,
id: "before_time", id: "before_time",
name: "Before Time", name: "Before Time",
prerequisiteIds: [ "first_light_hall" ], prerequisiteIds: [ "first_light_hall" ],
@@ -1590,7 +1591,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e52, combatPowerRequired: 7.2e52,
description: description:
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.", "The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
durationSeconds: 21 * 60 * 60, durationSeconds: 14_400,
id: "sanctum_chronicle", id: "sanctum_chronicle",
name: "The Sanctum Chronicle", name: "The Sanctum Chronicle",
prerequisiteIds: [ "before_time" ], prerequisiteIds: [ "before_time" ],
@@ -1606,7 +1607,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e92, combatPowerRequired: 3e92,
description: description:
"The deepest chambers of the sanctum — where the primordia are not preserved but still occurring, still being, still becoming for the first and only time. The floor hums with unfinished creation.", "The deepest chambers of the sanctum — where the primordia are not preserved but still occurring, still being, still becoming for the first and only time. The floor hums with unfinished creation.",
durationSeconds: 24 * 60 * 60, durationSeconds: 18_000,
id: "sanctum_deep", id: "sanctum_deep",
name: "The Deep Sanctum", name: "The Deep Sanctum",
prerequisiteIds: [ "sanctum_chronicle" ], prerequisiteIds: [ "sanctum_chronicle" ],
@@ -1622,7 +1623,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8e98, combatPowerRequired: 8e98,
description: description:
"The crossroads between everything primeval and everything that came after — a threshold so old that every subsequent age of the universe is, from its perspective, still ongoing.", "The crossroads between everything primeval and everything that came after — a threshold so old that every subsequent age of the universe is, from its perspective, still ongoing.",
durationSeconds: 24 * 60 * 60, durationSeconds: 22_500,
id: "sanctum_nexus", id: "sanctum_nexus",
name: "The Primeval Nexus", name: "The Primeval Nexus",
prerequisiteIds: [ "sanctum_deep" ], prerequisiteIds: [ "sanctum_deep" ],
@@ -1638,7 +1639,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2e105, combatPowerRequired: 2e105,
description: description:
"The sanctum's final gift to those who reached its depths: a full accounting of what it means to have existed before time had opinions about how things should go. Your guild is the first to read it.", "The sanctum's final gift to those who reached its depths: a full accounting of what it means to have existed before time had opinions about how things should go. Your guild is the first to read it.",
durationSeconds: 24 * 60 * 60, durationSeconds: 27_900,
id: "primeval_archive", id: "primeval_archive",
name: "The Primeval Archive", name: "The Primeval Archive",
prerequisiteIds: [ "sanctum_nexus" ], prerequisiteIds: [ "sanctum_nexus" ],
@@ -1655,7 +1656,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e53, combatPowerRequired: 3e53,
description: description:
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.", "The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
durationSeconds: 3 * 60 * 60, durationSeconds: 5100,
id: "absolute_threshold", id: "absolute_threshold",
name: "The Absolute Threshold", name: "The Absolute Threshold",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1671,7 +1672,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e54, combatPowerRequired: 1.2e54,
description: description:
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.", "Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
durationSeconds: 5 * 60 * 60, durationSeconds: 6300,
id: "nothing_wastes", id: "nothing_wastes",
name: "The Nothing Wastes", name: "The Nothing Wastes",
prerequisiteIds: [ "absolute_threshold" ], prerequisiteIds: [ "absolute_threshold" ],
@@ -1687,7 +1688,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e54, combatPowerRequired: 4.8e54,
description: description:
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.", "A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
durationSeconds: 8 * 60 * 60, durationSeconds: 7800,
id: "final_paradox", id: "final_paradox",
name: "The Final Paradox", name: "The Final Paradox",
prerequisiteIds: [ "nothing_wastes" ], prerequisiteIds: [ "nothing_wastes" ],
@@ -1703,7 +1704,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e55, combatPowerRequired: 1.8e55,
description: description:
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.", "Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
durationSeconds: 11 * 60 * 60, durationSeconds: 9600,
id: "end_vault", id: "end_vault",
name: "The Vault of Ends", name: "The Vault of Ends",
prerequisiteIds: [ "final_paradox" ], prerequisiteIds: [ "final_paradox" ],
@@ -1719,7 +1720,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e55, combatPowerRequired: 7.2e55,
description: description:
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.", "The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
durationSeconds: 17 * 60 * 60, durationSeconds: 12_000,
id: "terminal_approach", id: "terminal_approach",
name: "The Terminal Approach", name: "The Terminal Approach",
prerequisiteIds: [ "end_vault" ], prerequisiteIds: [ "end_vault" ],
@@ -1735,7 +1736,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e56, combatPowerRequired: 3e56,
description: description:
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.", "This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
durationSeconds: 24 * 60 * 60, durationSeconds: 15_300,
id: "absolute_dominion", id: "absolute_dominion",
name: "Absolute Dominion", name: "Absolute Dominion",
prerequisiteIds: [ "terminal_approach" ], prerequisiteIds: [ "terminal_approach" ],
@@ -1751,7 +1752,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 5e111, combatPowerRequired: 5e111,
description: description:
"Beyond the end of everything, there is more. Not in contradiction — but in the way that answers, once found, reveal the next question. Your guild goes further than the concept of further was designed to accommodate.", "Beyond the end of everything, there is more. Not in contradiction — but in the way that answers, once found, reveal the next question. Your guild goes further than the concept of further was designed to accommodate.",
durationSeconds: 24 * 60 * 60, durationSeconds: 18_900,
id: "absolute_beyond", id: "absolute_beyond",
name: "Beyond the Absolute", name: "Beyond the Absolute",
prerequisiteIds: [ "absolute_dominion" ], prerequisiteIds: [ "absolute_dominion" ],
@@ -1767,7 +1768,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e118, combatPowerRequired: 1e118,
description: description:
"The region that exists past the end of existence — a space defined not by what it contains but by being the place where containment no longer applies. Your guild navigates it by not needing it to make sense.", "The region that exists past the end of existence — a space defined not by what it contains but by being the place where containment no longer applies. Your guild navigates it by not needing it to make sense.",
durationSeconds: 24 * 60 * 60, durationSeconds: 23_400,
id: "absolute_depth", id: "absolute_depth",
name: "The Absolute Depth", name: "The Absolute Depth",
prerequisiteIds: [ "absolute_beyond" ], prerequisiteIds: [ "absolute_beyond" ],
@@ -1783,7 +1784,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e124, combatPowerRequired: 3e124,
description: description:
"The final record: not of what happened, but of the fact that it happened at all. A guild from a mortal realm reached the end of all things and chose to keep going. The universe notes this with something that is not quite surprise.", "The final record: not of what happened, but of the fact that it happened at all. A guild from a mortal realm reached the end of all things and chose to keep going. The universe notes this with something that is not quite surprise.",
durationSeconds: 24 * 60 * 60, durationSeconds: 29_700,
id: "absolute_chronicle", id: "absolute_chronicle",
name: "The Absolute Chronicle", name: "The Absolute Chronicle",
prerequisiteIds: [ "absolute_depth" ], prerequisiteIds: [ "absolute_depth" ],
@@ -1799,7 +1800,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2e130, combatPowerRequired: 2e130,
description: description:
"A region beyond the final record — where even the concept of record has ended and only raw, unwitnessed existence remains. Your guild walks it anyway, because that is what your guild does.", "A region beyond the final record — where even the concept of record has ended and only raw, unwitnessed existence remains. Your guild walks it anyway, because that is what your guild does.",
durationSeconds: 24 * 60 * 60, durationSeconds: 36_900,
id: "post_absolute_wastes", id: "post_absolute_wastes",
name: "The Post-Absolute Wastes", name: "The Post-Absolute Wastes",
prerequisiteIds: [ "absolute_chronicle" ], prerequisiteIds: [ "absolute_chronicle" ],
@@ -1815,7 +1816,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e137, combatPowerRequired: 1e137,
description: description:
"The space between the final end and whatever follows it — a silence so complete that your guild's presence is the loudest thing that has ever existed here. They proceed in hushed awe.", "The space between the final end and whatever follows it — a silence so complete that your guild's presence is the loudest thing that has ever existed here. They proceed in hushed awe.",
durationSeconds: 24 * 60 * 60, durationSeconds: 46_200,
id: "terminal_silence", id: "terminal_silence",
name: "The Terminal Silence", name: "The Terminal Silence",
prerequisiteIds: [ "post_absolute_wastes" ], prerequisiteIds: [ "post_absolute_wastes" ],
@@ -1831,7 +1832,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 5e141, combatPowerRequired: 5e141,
description: description:
"The last thing your guild will ever witness before the Absolute One. Not a place. Not a moment. A threshold so final that crossing it means there is only one thing left in existence worth confronting. Your guild crosses it.", "The last thing your guild will ever witness before the Absolute One. Not a place. Not a moment. A threshold so final that crossing it means there is only one thing left in existence worth confronting. Your guild crosses it.",
durationSeconds: 24 * 60 * 60, durationSeconds: 57_600,
id: "final_threshold", id: "final_threshold",
name: "The Final Threshold", name: "The Final Threshold",
prerequisiteIds: [ "terminal_silence" ], prerequisiteIds: [ "terminal_silence" ],
+4 -4
View File
@@ -46,7 +46,7 @@ export const defaultZones: Array<Zone> = [
name: "The Shadow Marshes", name: "The Shadow Marshes",
status: "locked", status: "locked",
unlockBossId: "void_titan", unlockBossId: "void_titan",
unlockQuestId: "storm_citadel", unlockQuestId: "frozen_throne",
}, },
{ {
description: description:
@@ -166,7 +166,7 @@ export const defaultZones: Array<Zone> = [
name: "The Cosmic Maelstrom", name: "The Cosmic Maelstrom",
status: "locked", status: "locked",
unlockBossId: "reality_architect", unlockBossId: "reality_architect",
unlockQuestId: "forge_chronicle", unlockQuestId: "creation_archive",
}, },
{ {
description: description:
@@ -176,7 +176,7 @@ export const defaultZones: Array<Zone> = [
name: "The Primeval Sanctum", name: "The Primeval Sanctum",
status: "locked", status: "locked",
unlockBossId: "cosmic_annihilator", unlockBossId: "cosmic_annihilator",
unlockQuestId: "maelstrom_codex", unlockQuestId: "storm_chronicle",
}, },
{ {
description: description:
@@ -186,6 +186,6 @@ export const defaultZones: Array<Zone> = [
name: "The Absolute", name: "The Absolute",
status: "locked", status: "locked",
unlockBossId: "primeval_god", unlockBossId: "primeval_god",
unlockQuestId: "sanctum_chronicle", unlockQuestId: "primeval_archive",
}, },
]; ];
+3 -2
View File
@@ -7,6 +7,7 @@
/* eslint-disable stylistic/max-len -- URL cannot be shortened */ /* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */ /* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono"; import { Hono } from "hono";
import { pendingReleases } from "../data/pendingReleases.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { AboutResponse, GiteaRelease } from "@elysium/types"; import type { AboutResponse, GiteaRelease } from "@elysium/types";
@@ -50,8 +51,8 @@ aboutRouter.get("/", async(context) => {
try { try {
const releases = await fetchReleases(); const releases = await fetchReleases();
const body: AboutResponse = { const body: AboutResponse = {
apiVersion, apiVersion: apiVersion,
releases, releases: [ ...pendingReleases, ...releases ],
}; };
return context.json(body); return context.json(body);
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
+12 -2
View File
@@ -65,10 +65,20 @@ apotheosisRouter.post("/", async(context) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => { const totalAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null; return a.unlockedAt !== null;
}).length; }).length;
/*
* Achievements now persist across resets, so `totalAchievementsUnlocked`
* already includes everything counted at previous resets — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked - state.player.lifetimeAchievementsUnlocked,
);
const { updatedState, updatedApotheosisData } = buildPostApotheosisState( const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state, state,
state.player.characterName, state.player.characterName,
@@ -87,7 +97,7 @@ apotheosisRouter.post("/", async(context) => {
lastSavedAt: now, lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAchievementsUnlocked: { increment: achievementsUnlockedDelta },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
+26
View File
@@ -44,6 +44,25 @@ const computeHmac = (data: string, secret: string): string => {
*/ */
const prestigeCombatBase = 4; const prestigeCombatBase = 4;
/**
* Returns whether a boss's own unlock quest (if any) has been completed.
* @param questId - The quest ID gating the boss, or null/undefined if none.
* @param quests - The player's current quest states.
* @returns True when there's no quest gate, or the gate quest is completed.
*/
const isBossQuestSatisfied = (
questId: string | null | undefined,
quests: GameState["quests"],
): boolean => {
return (
questId === null
|| questId === undefined
|| quests.some((q) => {
return q.id === questId && q.status === "completed";
})
);
};
const bossRouter = new Hono<HonoEnvironment>(); const bossRouter = new Hono<HonoEnvironment>();
bossRouter.use("*", authMiddleware); bossRouter.use("*", authMiddleware);
@@ -176,6 +195,10 @@ bossRouter.post("/challenge", async(context) => {
return context.json({ error: "Prestige requirement not met" }, 403); return context.json({ error: "Prestige requirement not met" }, 403);
} }
if (!isBossQuestSatisfied(boss.unlockQuestId, state.quests)) {
return context.json({ error: "Quest requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculatePartyStats(state); const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if ( if (
@@ -243,6 +266,7 @@ bossRouter.post("/challenge", async(context) => {
}); });
if (equipment) { if (equipment) {
equipment.owned = true; equipment.owned = true;
equipment.everOwned = true;
const slotAlreadyEquipped = state.equipment.some((item) => { const slotAlreadyEquipped = state.equipment.some((item) => {
return item.type === equipment.type && item.equipped; return item.type === equipment.type && item.equipped;
@@ -264,6 +288,7 @@ bossRouter.post("/challenge", async(context) => {
if ( if (
nextZoneBoss nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count && nextZoneBoss.prestigeRequirement <= state.prestige.count
&& isBossQuestSatisfied(nextZoneBoss.unlockQuestId, state.quests)
) { ) {
const nextBossInState = state.bosses.find((b) => { const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id; return b.id === nextZoneBoss.id;
@@ -317,6 +342,7 @@ bossRouter.post("/challenge", async(context) => {
if ( if (
firstUpdatedBoss firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count && firstUpdatedBoss.prestigeRequirement <= state.prestige.count
&& isBossQuestSatisfied(firstUpdatedBoss.unlockQuestId, state.quests)
) { ) {
firstUpdatedBoss.status = "available"; firstUpdatedBoss.status = "available";
} }
+19 -1
View File
@@ -166,6 +166,7 @@ interface BossUnlockCheck {
previousBossId: string | undefined; previousBossId: string | undefined;
isFirstInZone: boolean; isFirstInZone: boolean;
prestigeRequirement: number; prestigeRequirement: number;
unlockQuestId: string | null | undefined;
state: GameState; state: GameState;
prestigeCount: number; prestigeCount: number;
} }
@@ -177,6 +178,7 @@ interface BossUnlockCheck {
* @param options.previousBossId - The ID of the previous boss in the zone. * @param options.previousBossId - The ID of the previous boss in the zone.
* @param options.isFirstInZone - Whether this boss is the first in its zone. * @param options.isFirstInZone - Whether this boss is the first in its zone.
* @param options.prestigeRequirement - The prestige level required for this boss. * @param options.prestigeRequirement - The prestige level required for this boss.
* @param options.unlockQuestId - The quest ID that must be completed, if any.
* @param options.state - The current game state. * @param options.state - The current game state.
* @param options.prestigeCount - The player's current prestige count. * @param options.prestigeCount - The player's current prestige count.
* @returns True when the boss should be made available. * @returns True when the boss should be made available.
@@ -186,6 +188,7 @@ const shouldUnlockBoss = ({
previousBossId, previousBossId,
isFirstInZone, isFirstInZone,
prestigeRequirement, prestigeRequirement,
unlockQuestId,
state, state,
prestigeCount, prestigeCount,
}: BossUnlockCheck): boolean => { }: BossUnlockCheck): boolean => {
@@ -198,6 +201,15 @@ const shouldUnlockBoss = ({
if (prestigeRequirement > prestigeCount) { if (prestigeRequirement > prestigeCount) {
return false; return false;
} }
const questSatisfied
= unlockQuestId === null
|| unlockQuestId === undefined
|| state.quests.some((q) => {
return q.id === unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
return false;
}
if (isFirstInZone) { if (isFirstInZone) {
return true; return true;
} }
@@ -249,6 +261,7 @@ const applyBossUnlocks = (state: GameState): number => {
prestigeRequirement: bossDefinition.prestigeRequirement, prestigeRequirement: bossDefinition.prestigeRequirement,
previousBossId: previousBossDefinition?.id, previousBossId: previousBossDefinition?.id,
state: state, state: state,
unlockQuestId: bossDefinition.unlockQuestId,
}); });
if (unlock) { if (unlock) {
const bossInState = state.bosses.find((b) => { const bossInState = state.bosses.find((b) => {
@@ -407,6 +420,7 @@ const applyEquipmentUnlocks = (state: GameState): number => {
for (const item of state.equipment) { for (const item of state.equipment) {
if (!item.owned && earnedEquipmentIds.has(item.id)) { if (!item.owned && earnedEquipmentIds.has(item.id)) {
item.owned = true; item.owned = true;
item.everOwned = true;
count = count + 1; count = count + 1;
} }
} }
@@ -723,6 +737,8 @@ const patchBossStats = (state: GameState): number => {
} }
const savedRewards = JSON.stringify(savedBoss.equipmentRewards); const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards); const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
const savedUnlockQuestId = savedBoss.unlockQuestId ?? null;
const defaultUnlockQuestId = defaultBoss.unlockQuestId ?? null;
const hasChanged const hasChanged
= savedBoss.name !== defaultBoss.name = savedBoss.name !== defaultBoss.name
|| savedBoss.description !== defaultBoss.description || savedBoss.description !== defaultBoss.description
@@ -734,7 +750,8 @@ const patchBossStats = (state: GameState): number => {
|| savedRewards !== defaultRewards || savedRewards !== defaultRewards
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement || savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|| savedBoss.zoneId !== defaultBoss.zoneId || savedBoss.zoneId !== defaultBoss.zoneId
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones; || savedBoss.bountyRunestones !== defaultBoss.bountyRunestones
|| savedUnlockQuestId !== defaultUnlockQuestId;
savedBoss.name = defaultBoss.name; savedBoss.name = defaultBoss.name;
savedBoss.description = defaultBoss.description; savedBoss.description = defaultBoss.description;
savedBoss.maxHp = defaultBoss.maxHp; savedBoss.maxHp = defaultBoss.maxHp;
@@ -746,6 +763,7 @@ const patchBossStats = (state: GameState): number => {
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement; savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId; savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones; savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
savedBoss.unlockQuestId = defaultUnlockQuestId;
if (hasChanged) { if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
+17 -5
View File
@@ -102,10 +102,22 @@ prestigeRouter.post("/", async(context) => {
} }
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 5 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => { const totalAchievementsUnlocked = state.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null; return achievement.unlockedAt !== null;
}).length; },
).length;
/*
* Achievements now persist across resets, so `totalAchievementsUnlocked`
* already includes everything counted at previous resets — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked - state.player.lifetimeAchievementsUnlocked,
);
const now = Date.now(); const now = Date.now();
const { updatedAt } = record; const { updatedAt } = record;
@@ -131,7 +143,7 @@ prestigeRouter.post("/", async(context) => {
lastSavedAt: now, lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAchievementsUnlocked: { increment: achievementsUnlockedDelta },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
@@ -164,7 +176,7 @@ prestigeRouter.post("/", async(context) => {
const announcementsEnabled const announcementsEnabled
= playerSettings?.enablePrestigeAnnouncements !== false; = playerSettings?.enablePrestigeAnnouncements !== false;
if (announcementsEnabled) { if (announcementsEnabled && prestigeCount % 5 === 0) {
void postMilestoneWebhook(discordId, "prestige", { void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
+1 -1
View File
@@ -44,7 +44,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
const numberFormat = validNumberFormats.has(parsedNumberFormat) const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"]) ? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix"; : DEFAULT_PROFILE_SETTINGS.numberFormat;
return { return {
enableNotifications: rawObject.enableNotifications === true, enableNotifications: rawObject.enableNotifications === true,
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false, enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
+16 -4
View File
@@ -68,10 +68,22 @@ transcendenceRouter.post("/", async(context) => {
} }
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 5 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => { const totalAchievementsUnlocked = state.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null; return achievement.unlockedAt !== null;
}).length; },
).length;
/*
* Achievements now persist across resets, so `totalAchievementsUnlocked`
* already includes everything counted at previous resets — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked - state.player.lifetimeAchievementsUnlocked,
);
const now = Date.now(); const now = Date.now();
await prisma.gameState.update({ await prisma.gameState.update({
@@ -86,7 +98,7 @@ transcendenceRouter.post("/", async(context) => {
lastSavedAt: now, lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAchievementsUnlocked: { increment: achievementsUnlockedDelta },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
+91 -1
View File
@@ -4,10 +4,16 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- buildPostApotheosisState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { import {
defaultTranscendenceUpgrades, defaultTranscendenceUpgrades,
} from "../data/transcendenceUpgrades.js"; } from "../data/transcendenceUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { ApotheosisData, GameState } from "@elysium/types"; import type { ApotheosisData, GameState } from "@elysium/types";
/** /**
@@ -34,7 +40,8 @@ const isEligibleForApotheosis = (state: GameState): boolean => {
/** /**
* Builds the updated game state after Apotheosis — the ultimate nuclear reset. * Builds the updated game state after Apotheosis — the ultimate nuclear reset.
* Wipes absolutely everything including prestige and transcendence. * Wipes absolutely everything including prestige and transcendence.
* Only codex lore entries and the apotheosis count itself are preserved. * Only codex lore entries, story progress, the active companion selection, and
* the apotheosis count itself are preserved.
* @param currentState - The current game state before apotheosis. * @param currentState - The current game state before apotheosis.
* @param characterName - The character name to carry over. * @param characterName - The character name to carry over.
* @returns The updated game state and apotheosis data. * @returns The updated game state and apotheosis data.
@@ -47,9 +54,88 @@ const buildPostApotheosisState = (
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount }; const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the apotheosis reset.
*/
const bossesWithHistory = preserveBossHistory(
freshState.bosses,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the apotheosis reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the apotheosis reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after apotheosis.
*/
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
const runAdventurersRecruited = currentState.adventurers.reduce(
(sum, adventurer) => {
return sum + adventurer.count;
},
0,
);
const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const updatedState: GameState = { const updatedState: GameState = {
...freshState, ...freshState,
// Achievements are permanent — earned achievements survive apotheosis
achievements: currentState.achievements,
bosses: bossesWithHistory,
equipment: equipmentWithHistory,
lastTickAt: Date.now(), lastTickAt: Date.now(),
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ achievementsUnlockedDelta,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
lifetimeBossesDefeated:
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
lifetimeClicks:
freshState.player.lifetimeClicks + currentState.player.totalClicks,
lifetimeGoldEarned:
freshState.player.lifetimeGoldEarned
+ currentState.player.totalGoldEarned,
lifetimeQuestsCompleted:
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
// Codex lore persists through all resets — players keep their discovered entries // Codex lore persists through all resets — players keep their discovered entries
...currentState.codex ...currentState.codex
? { codex: currentState.codex } ? { codex: currentState.codex }
@@ -60,6 +146,10 @@ const buildPostApotheosisState = (
...currentState.story ...currentState.story
? { story: currentState.story } ? { story: currentState.story }
: {}, : {},
// Active companion selection persists across apotheosis
...currentState.companions
? { companions: currentState.companions }
: {},
}; };
return { updatedApotheosisData, updatedState }; return { updatedApotheosisData, updatedState };
+56 -5
View File
@@ -8,6 +8,11 @@
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */ /* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js"; import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { import type {
GameState, GameState,
PrestigeData, PrestigeData,
@@ -22,9 +27,9 @@ const milestoneRunestonesPerInterval = 25;
/* /*
* Hard cap on the base runestone yield (before multipliers) to prevent * Hard cap on the base runestone yield (before multipliers) to prevent
* extreme AFK accumulation from producing game-breaking runestone counts. * extreme AFK accumulation from producing game-breaking runestone counts.
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige. * With all upgrades (5.625× max) this caps out at ~2,250 per prestige.
*/ */
const maxBaseRunestones = 200; const maxBaseRunestones = 400;
/** /**
* Calculates the gold threshold required for the next prestige. * Calculates the gold threshold required for the next prestige.
@@ -240,6 +245,33 @@ const buildPostPrestigeState = (
return freshBoss; return freshBoss;
}); });
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the prestige reset.
*/
const bossesWithHistory = preserveBossHistory(
bossesWithBountyClaimed,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the prestige reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the prestige reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
// Compute current-run contributions to accumulate into lifetime totals // Compute current-run contributions to accumulate into lifetime totals
const runBossesDefeated = currentState.bosses.filter((boss) => { const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated"; return boss.status === "defeated";
@@ -251,12 +283,23 @@ const buildPostPrestigeState = (
for (const adventurer of currentState.adventurers) { for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count; runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
} }
const runAchievementsUnlocked = currentState.achievements.filter( const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => { (achievement) => {
return achievement.unlockedAt !== null; return achievement.unlockedAt !== null;
}, },
).length; ).length;
/*
* Achievements persist across prestiges, so `totalAchievementsUnlocked`
* already includes everything counted at previous prestiges — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const prestigeState: GameState = { const prestigeState: GameState = {
...freshState, ...freshState,
@@ -272,7 +315,9 @@ const buildPostPrestigeState = (
autoQuest: currentState.autoQuest ?? false, autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved // Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed, bosses: bossesWithHistory,
// Ownership resets for gameplay, but ever-owned history is preserved
equipment: equipmentWithHistory,
lastTickAt: Date.now(), lastTickAt: Date.now(),
/* /*
@@ -283,7 +328,7 @@ const buildPostPrestigeState = (
...freshState.player, ...freshState.player,
lifetimeAchievementsUnlocked: lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked freshState.player.lifetimeAchievementsUnlocked
+ runAchievementsUnlocked, + achievementsUnlockedDelta,
lifetimeAdventurersRecruited: lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited, + runAdventurersRecruited,
@@ -298,6 +343,12 @@ const buildPostPrestigeState = (
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted, freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
}, },
prestige: prestigeData, prestige: prestigeData,
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
// Active companion selection persists across prestige
...currentState.companions === undefined
? {}
: { companions: currentState.companions },
// Codex lore persists across prestiges — players keep their discovered entries // Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined ...currentState.codex === undefined
? {} ? {}
+89
View File
@@ -0,0 +1,89 @@
/**
* @file Shared helpers for merging permanent per-item history across
* prestige/transcendence/apotheosis resets.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Boss, Equipment, Quest } from "@elysium/types";
/**
* Carries forward each equipment item's ownership history across a reset.
* Ownership itself (and any stat bonuses it grants) still resets to the fresh
* defaults — only the "have I ever owned this" flag used by collection
* achievements survives, mirroring how boss bounty-claimed flags persist.
* @param freshEquipment - The freshly-reset equipment array.
* @param currentEquipment - The equipment array from the state being reset.
* @returns The fresh equipment array with `everOwned` merged in per item id.
*/
const preserveEquipmentHistory = (
freshEquipment: Array<Equipment>,
currentEquipment: Array<Equipment>,
): Array<Equipment> => {
return freshEquipment.map((freshItem) => {
const currentItem = currentEquipment.find((item) => {
return item.id === freshItem.id;
});
const everOwned
= currentItem?.everOwned === true || currentItem?.owned === true;
return everOwned
? { ...freshItem, everOwned: true }
: freshItem;
});
};
/**
* Carries forward each quest's completion history across a reset. Quest
* status itself still resets to the fresh defaults — only the "have I ever
* completed this" flag used by the all-quests achievement survives, so
* repeatedly re-completing the same subset of quests across resets can't
* be mistaken for having completed every distinct quest.
* @param freshQuests - The freshly-reset quests array.
* @param currentQuests - The quests array from the state being reset.
* @returns The fresh quests array with `everCompleted` merged in per quest id.
*/
const preserveQuestHistory = (
freshQuests: Array<Quest>,
currentQuests: Array<Quest>,
): Array<Quest> => {
return freshQuests.map((freshQuest) => {
const currentQuest = currentQuests.find((quest) => {
return quest.id === freshQuest.id;
});
const everCompleted
= currentQuest?.everCompleted === true
|| currentQuest?.status === "completed";
return everCompleted
? { ...freshQuest, everCompleted: true }
: freshQuest;
});
};
/**
* Carries forward each boss's defeat history across a reset. Boss status
* itself still resets to the fresh defaults — only the "have I ever
* defeated this" flag used by the all-bosses achievement survives, so
* repeatedly re-defeating the same subset of bosses across resets can't
* be mistaken for having defeated every distinct boss.
* @param freshBosses - The freshly-reset bosses array.
* @param currentBosses - The bosses array from the state being reset.
* @returns The fresh bosses array with `everDefeated` merged in per boss id.
*/
const preserveBossHistory = (
freshBosses: Array<Boss>,
currentBosses: Array<Boss>,
): Array<Boss> => {
return freshBosses.map((freshBoss) => {
const currentBoss = currentBosses.find((boss) => {
return boss.id === freshBoss.id;
});
const everDefeated
= currentBoss?.everDefeated === true
|| currentBoss?.status === "defeated";
return everDefeated
? { ...freshBoss, everDefeated: true }
: freshBoss;
});
};
export { preserveBossHistory, preserveEquipmentHistory, preserveQuestHistory };
+87
View File
@@ -4,8 +4,14 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- buildPostTranscendenceState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js"; import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { import type {
GameState, GameState,
TranscendenceData, TranscendenceData,
@@ -106,6 +112,8 @@ const buildPermanentSpreads = (
transcendenceData: TranscendenceData, transcendenceData: TranscendenceData,
): Partial<GameState> => { ): Partial<GameState> => {
return { return {
// Achievements are permanent — earned achievements survive all transcendences
achievements: currentState.achievements,
transcendence: transcendenceData, transcendence: transcendenceData,
...currentState.codex === undefined ...currentState.codex === undefined
? {} ? {}
@@ -116,6 +124,10 @@ const buildPermanentSpreads = (
...currentState.story === undefined ...currentState.story === undefined
? {} ? {}
: { story: currentState.story }, : { story: currentState.story },
// Active companion selection persists across transcendence
...currentState.companions === undefined
? {}
: { companions: currentState.companions },
}; };
}; };
@@ -153,9 +165,84 @@ const buildPostTranscendenceState = (
}; };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the transcendence reset.
*/
const bossesWithHistory = preserveBossHistory(
freshState.bosses,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the transcendence reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the transcendence reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after transcendence.
*/
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const transcendenceState: GameState = { const transcendenceState: GameState = {
...freshState, ...freshState,
bosses: bossesWithHistory,
equipment: equipmentWithHistory,
lastTickAt: Date.now(), lastTickAt: Date.now(),
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ achievementsUnlockedDelta,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
lifetimeBossesDefeated:
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
lifetimeClicks:
freshState.player.lifetimeClicks + currentState.player.totalClicks,
lifetimeGoldEarned:
freshState.player.lifetimeGoldEarned
+ currentState.player.totalGoldEarned,
lifetimeQuestsCompleted:
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
...buildPermanentSpreads(currentState, transcendenceData), ...buildPermanentSpreads(currentState, transcendenceData),
}; };
+13 -1
View File
@@ -107,6 +107,17 @@ const milestoneVerbs: Record<MilestoneType, string> = {
transcendence: "transcended", transcendence: "transcended",
}; };
type MilestoneSuffixBuilder = (counts: MilestoneCounts)=> string;
const milestoneSuffixes: Partial<
Record<MilestoneType, MilestoneSuffixBuilder>
> = {
transcendence: (counts) => {
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts.prestige is a number, intentionally stringified */
return ` at ${counts.prestige} prestige`;
},
};
/** /**
* Posts a milestone announcement to the configured Discord webhook. * Posts a milestone announcement to the configured Discord webhook.
* Fails silently so webhook errors do not affect the game action. * Fails silently so webhook errors do not affect the game action.
@@ -126,8 +137,9 @@ const postMilestoneWebhook = async(
} }
const verb = milestoneVerbs[milestone]; const verb = milestoneVerbs[milestone];
const suffix = milestoneSuffixes[milestone]?.(counts) ?? "";
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */ /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`; const content = `<@${discordId}> has ${verb}${suffix}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
try { try {
await fetch(webhookUrl, { await fetch(webhookUrl, {
+61 -37
View File
@@ -1,6 +1,21 @@
/**
* @file Tests for the about route, covering release fetching, caching, and the
* fallback to pending releases when the upstream request fails.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ /* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono"; import { Hono } from "hono";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pendingReleases } from "../../src/data/pendingReleases.js";
const makeApp = async(): Promise<Hono> => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
describe("about route", () => { describe("about route", () => {
const mockFetch = vi.fn(); const mockFetch = vi.fn();
@@ -15,59 +30,68 @@ describe("about route", () => {
mockFetch.mockReset(); mockFetch.mockReset();
}); });
const makeApp = async () => { it("returns pending and fetched releases on a successful fetch", async() => {
const { aboutRouter } = await import("../../src/routes/about.js"); expect.assertions(2);
const app = new Hono(); const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
app.route("/about", aboutRouter); mockFetch.mockResolvedValueOnce({ json: () => {
return app; return Promise.resolve(releases);
}; }, ok: true });
it("returns releases from a successful fetch", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { releases: unknown[] }; const body: { releases: Array<unknown> } = await response.json();
expect(body.releases).toEqual(releases); expect(body.releases, "should merge pending and fetched releases").
toStrictEqual([ ...pendingReleases, ...releases ]);
}); });
it("returns empty releases when fetch is not ok", async () => { it("returns only pending releases when fetch is not ok", async() => {
expect.assertions(2);
mockFetch.mockResolvedValueOnce({ ok: false }); mockFetch.mockResolvedValueOnce({ ok: false });
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { releases: unknown[] }; const body: { releases: Array<unknown> } = await response.json();
expect(body.releases).toEqual([]); expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
}); });
it("returns empty releases when fetch throws", async () => { it("returns only pending releases when fetch throws", async() => {
expect.assertions(2);
mockFetch.mockRejectedValueOnce(new Error("Network error")); mockFetch.mockRejectedValueOnce(new Error("Network error"));
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { releases: unknown[] }; const body: { releases: Array<unknown> } = await response.json();
expect(body.releases).toEqual([]); expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
}); });
it("returns cached releases on second call within TTL", async () => { it("returns cached releases on second call within TTL", async() => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }]; expect.assertions(2);
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) }); const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve(releases);
}, ok: true });
const app = await makeApp(); const app = await makeApp();
// First call populates cache // First call populates cache
await app.fetch(new Request("http://localhost/about")); await app.fetch(new Request("http://localhost/about"));
// Second call should use cache, not call fetch again // Second call should use cache, not call fetch again
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch, "should not re-fetch within the TTL").
toHaveBeenCalledTimes(1);
}); });
it("includes apiVersion in response", async () => { it("includes apiVersion in response", async() => {
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); expect.assertions(2);
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve([]);
}, ok: true });
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { apiVersion: string }; const body: { apiVersion: string } = await response.json();
expect(typeof body.apiVersion).toBe("string"); expect(typeof body.apiVersion, "should expose apiVersion as a string").
toBe("string");
}); });
}); });
+127
View File
@@ -121,6 +121,133 @@ describe("boss route", () => {
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
it("returns 403 when boss's own unlock quest is not completed", async () => {
const state = makeState({
bosses: [makeBoss({ unlockQuestId: "gate_quest" })] as GameState["bosses"],
quests: [{ id: "gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(403);
});
it("allows challenge when boss's own unlock quest is completed", async () => {
const state = makeState({
bosses: [makeBoss({
unlockQuestId: "gate_quest",
currentHp: 100,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
quests: [{ id: "gate_quest", status: "completed" }] as GameState["quests"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock next zone boss when its own unlock quest is not completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("locked");
});
it("unlocks next zone boss when its own unlock quest is completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("available");
});
it("does not unlock first zone boss when its own unlock quest is not completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedZone = savedState?.zones.find((z) => z.id === "new_zone");
expect(savedZone?.status).toBe("unlocked");
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("locked");
});
it("unlocks first zone boss when its own unlock quest is completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("available");
});
it("returns 400 when party has no adventurers", async () => { it("returns 400 when party has no adventurers", async () => {
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] }); const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
+43 -1
View File
@@ -312,6 +312,34 @@ describe("debug route", () => {
expect(body.bossesUnlocked).toBe(1); expect(body.bossesUnlocked).toBe(1);
}); });
it("does not unlock boss when its own unlock quest is not completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "active" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("unlocks boss when its own unlock quest is completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "completed" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(1);
});
it("returns explorationUnlocked=0 when exploration is undefined", async () => { it("returns explorationUnlocked=0 when exploration is undefined", async () => {
const state = makeState({ const state = makeState({
exploration: undefined as unknown as GameState["exploration"], exploration: undefined as unknown as GameState["exploration"],
@@ -830,7 +858,7 @@ describe("debug route", () => {
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => { it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({ const state = makeState({
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"], quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 150, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
}); });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
@@ -905,6 +933,20 @@ describe("debug route", () => {
expect(body.bossesPatched).toBe(1); expect(body.bossesPatched).toBe(1);
}); });
it("patches boss stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 1, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head.", unlockQuestId: "some_ghost_quest" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number; state: GameState };
expect(body.bossesPatched).toBe(1);
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.unlockQuestId).toBeNull();
});
it("skips boss stat patching for bosses not in defaults", async () => { it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({ const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"], bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
+46
View File
@@ -150,6 +150,52 @@ describe("prestige route", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything()); expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
}); });
it("skips webhook when the new prestige count is not a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
it("posts webhook when the new prestige count is a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(5);
expect(postMilestoneWebhook).toHaveBeenCalledWith(DISCORD_ID, "prestige", expect.objectContaining({ prestige: 5 }));
});
it("skips webhook on a multiple of 5 when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
}); });
describe("POST /buy-upgrade", () => { describe("POST /buy-upgrade", () => {
+2 -2
View File
@@ -157,7 +157,7 @@ describe("profile route", () => {
expect(body.profileSettings.showTotalGold).toBe(false); expect(body.profileSettings.showTotalGold).toBe(false);
}); });
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => { it("falls back to scientific numberFormat in GET when stored profileSettings has invalid format", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never, makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
); );
@@ -165,7 +165,7 @@ describe("profile route", () => {
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`)); const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string } }; const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix"); expect(body.profileSettings.numberFormat).toBe("scientific");
}); });
it("maps known and unknown unlocked title IDs to name and fallback id", async () => { it("maps known and unknown unlocked title IDs to name and fallback id", async () => {
+87 -1
View File
@@ -10,9 +10,26 @@ import type { GameState } from "@elysium/types";
const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id); const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id);
const makePlayer = (overrides: Partial<GameState["player"]> = {}) => ({
avatar: null,
characterName: "T",
discordId: "t",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
totalClicks: 0,
totalGoldEarned: 0,
username: "u",
...overrides,
});
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({ ({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, player: makePlayer(),
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [], adventurers: [],
upgrades: [], upgrades: [],
@@ -98,6 +115,19 @@ describe("buildPostApotheosisState", () => {
expect(updatedState.story).toEqual(story); expect(updatedState.story).toEqual(story);
}); });
it("persists active companion selection across apotheosis", () => {
const companions = { unlockedCompanionIds: ["lyra", "pria"], activeCompanionId: "pria" };
const state = makeMinimalState({ companions });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("wipes prestige data", () => { it("wipes prestige data", () => {
const state = makeMinimalState({ const state = makeMinimalState({
prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] }, prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] },
@@ -112,4 +142,60 @@ describe("buildPostApotheosisState", () => {
const { updatedState } = buildPostApotheosisState(state, "T"); const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.apotheosis?.count).toBe(1); expect(updatedState.apotheosis?.count).toBe(1);
}); });
it("accumulates current-run gold and clicks into lifetime totals", () => {
const state = makeMinimalState({
player: makePlayer({ totalGoldEarned: 4_000_000, lifetimeGoldEarned: 1_000_000, totalClicks: 500 }),
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeGoldEarned).toBe(5_000_000);
expect(updatedState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses, completed quests, and recruited adventurers into lifetime totals", () => {
const boss = { id: "boss_1", status: "defeated" as const };
const quest = { id: "q_1", status: "completed" as const };
const adventurer = { id: "adv_1", count: 5 };
const state = makeMinimalState({
bosses: [ boss ] as GameState["bosses"],
quests: [ quest ] as GameState["quests"],
adventurers: [ adventurer ] as GameState["adventurers"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeBossesDefeated).toBe(1);
expect(updatedState.player.lifetimeQuestsCompleted).toBe(1);
expect(updatedState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements and accumulates newly-unlocked ones into the lifetime total", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.achievements).toEqual([ achievement ]);
expect(updatedState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("does not re-count achievements already folded into the lifetime total by a previous reset", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
player: makePlayer({ lifetimeAchievementsUnlocked: 1 }),
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("preserves equipment ownership history across apotheosis", () => {
const equipment = { id: "rusty_sword", owned: true, everOwned: true };
const state = makeMinimalState({
equipment: [ equipment ] as GameState["equipment"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
const matchingItem = updatedState.equipment.find((item) => {
return item.id === "rusty_sword";
});
expect(matchingItem?.everOwned).toBe(true);
});
}); });
+15 -2
View File
@@ -120,9 +120,9 @@ describe("calculateRunestones", () => {
}); });
it("caps base runestones before multipliers", () => { it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200 // cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 400
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200); expect(result).toBe(400);
}); });
}); });
@@ -229,6 +229,19 @@ describe("buildPostPrestigeState", () => {
expect(prestigeState.story).toEqual(story); expect(prestigeState.story).toEqual(story);
}); });
it("persists active companion selection across prestige", () => {
const companions = { unlockedCompanionIds: ["lyra", "sera"], activeCompanionId: "lyra" };
const state = makeMinimalState({ companions });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("persists transcendence from current state", () => { it("persists transcendence from current state", () => {
const transcendence = { const transcendence = {
count: 1, echoes: 10, purchasedUpgradeIds: [], count: 1, echoes: 10, purchasedUpgradeIds: [],
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "../../src/services/resetHelpers.js";
import type { Boss, Equipment, Quest } from "@elysium/types";
const makeItem = (overrides: Partial<Equipment> = {}): Equipment => ({
bonus: {},
description: "An item",
equipped: false,
id: "item_1",
name: "Item",
owned: false,
rarity: "common",
type: "weapon",
...overrides,
});
const makeQuest = (overrides: Partial<Quest> = {}): Quest => ({
description: "A quest",
durationSeconds: 60,
id: "quest_1",
name: "Quest",
prerequisiteIds: [],
rewards: [],
status: "available",
zoneId: "zone_1",
...overrides,
});
const makeBoss = (overrides: Partial<Boss> = {}): Boss => ({
bountyRunestones: 0,
crystalReward: 0,
currentHp: 100,
damagePerSecond: 1,
description: "A boss",
equipmentRewards: [],
essenceReward: 0,
goldReward: 0,
id: "boss_1",
maxHp: 100,
name: "Boss",
prestigeRequirement: 0,
status: "available",
upgradeRewards: [],
zoneId: "zone_1",
...overrides,
});
describe("preserveEquipmentHistory", () => {
it("marks everOwned when the current item has everOwned true", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ everOwned: true, owned: false }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBe(true);
});
it("marks everOwned when the current item is owned but everOwned is unset", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ owned: true }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBe(true);
});
it("leaves the fresh item unchanged when the current item is neither owned nor everOwned", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ owned: false, everOwned: false }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBeUndefined();
});
it("leaves the fresh item unchanged when no matching id exists in current equipment", () => {
const fresh = [ makeItem({ id: "item_1" }) ];
const current = [ makeItem({ id: "item_2", owned: true, everOwned: true }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBeUndefined();
});
});
describe("preserveQuestHistory", () => {
it("marks everCompleted when the current quest has everCompleted true", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ everCompleted: true, status: "available" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBe(true);
});
it("marks everCompleted when the current quest is completed but everCompleted is unset", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ status: "completed" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBe(true);
});
it("leaves the fresh quest unchanged when the current quest is neither completed nor everCompleted", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ everCompleted: false, status: "locked" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBeUndefined();
});
it("leaves the fresh quest unchanged when no matching id exists in current quests", () => {
const fresh = [ makeQuest({ id: "quest_1" }) ];
const current = [
makeQuest({ everCompleted: true, id: "quest_2", status: "completed" }),
];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBeUndefined();
});
});
describe("preserveBossHistory", () => {
it("marks everDefeated when the current boss has everDefeated true", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ everDefeated: true, status: "available" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBe(true);
});
it("marks everDefeated when the current boss is defeated but everDefeated is unset", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ status: "defeated" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBe(true);
});
it("leaves the fresh boss unchanged when the current boss is neither defeated nor everDefeated", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ everDefeated: false, status: "locked" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBeUndefined();
});
it("leaves the fresh boss unchanged when no matching id exists in current bosses", () => {
const fresh = [ makeBoss({ id: "boss_1" }) ];
const current = [
makeBoss({ everDefeated: true, id: "boss_2", status: "defeated" }),
];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBeUndefined();
});
});
+87 -1
View File
@@ -9,9 +9,26 @@ import {
} from "../../src/services/transcendence.js"; } from "../../src/services/transcendence.js";
import type { GameState } from "@elysium/types"; import type { GameState } from "@elysium/types";
const makePlayer = (overrides: Partial<GameState["player"]> = {}) => ({
avatar: null,
characterName: "T",
discordId: "t",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
totalClicks: 0,
totalGoldEarned: 0,
username: "u",
...overrides,
});
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({ ({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, player: makePlayer(),
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [], adventurers: [],
upgrades: [], upgrades: [],
@@ -166,6 +183,19 @@ describe("buildPostTranscendenceState", () => {
expect(transcendenceState.apotheosis).toEqual(apotheosis); expect(transcendenceState.apotheosis).toEqual(apotheosis);
}); });
it("persists active companion selection across transcendence", () => {
const companions = { unlockedCompanionIds: ["lyra", "vex"], activeCompanionId: "vex" };
const state = makeMinimalState({ companions });
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("resets prestige to fresh state", () => { it("resets prestige to fresh state", () => {
const state = makeMinimalState({ const state = makeMinimalState({
prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] }, prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] },
@@ -174,4 +204,60 @@ describe("buildPostTranscendenceState", () => {
expect(transcendenceState.prestige.count).toBe(0); expect(transcendenceState.prestige.count).toBe(0);
expect(transcendenceState.prestige.runestones).toBe(0); expect(transcendenceState.prestige.runestones).toBe(0);
}); });
it("accumulates current-run gold and clicks into lifetime totals", () => {
const state = makeMinimalState({
player: makePlayer({ totalGoldEarned: 4_000_000, lifetimeGoldEarned: 1_000_000, totalClicks: 500 }),
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeGoldEarned).toBe(5_000_000);
expect(transcendenceState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses, completed quests, and recruited adventurers into lifetime totals", () => {
const boss = { id: "boss_1", status: "defeated" as const };
const quest = { id: "q_1", status: "completed" as const };
const adventurer = { id: "adv_1", count: 5 };
const state = makeMinimalState({
bosses: [ boss ] as GameState["bosses"],
quests: [ quest ] as GameState["quests"],
adventurers: [ adventurer ] as GameState["adventurers"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeBossesDefeated).toBe(1);
expect(transcendenceState.player.lifetimeQuestsCompleted).toBe(1);
expect(transcendenceState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements and accumulates newly-unlocked ones into the lifetime total", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.achievements).toEqual([ achievement ]);
expect(transcendenceState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("does not re-count achievements already folded into the lifetime total by a previous reset", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
player: makePlayer({ lifetimeAchievementsUnlocked: 1 }),
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("preserves equipment ownership history across transcendence", () => {
const equipment = { id: "rusty_sword", owned: true, everOwned: true };
const state = makeMinimalState({
equipment: [ equipment ] as GameState["equipment"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
const matchingItem = transcendenceState.equipment.find((item) => {
return item.id === "rusty_sword";
});
expect(matchingItem?.everOwned).toBe(true);
});
}); });
+2 -2
View File
@@ -138,10 +138,10 @@ describe("webhook service", () => {
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc"; process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
mockFetch.mockResolvedValueOnce({ ok: true }); mockFetch.mockResolvedValueOnce({ ok: true });
const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await postMilestoneWebhook("user123", "transcendence", { prestige: 0, transcendence: 1, apotheosis: 0 }); await postMilestoneWebhook("user123", "transcendence", { prestige: 3, transcendence: 1, apotheosis: 0 });
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string) as { content: string }; const body = JSON.parse(options.body as string) as { content: string };
expect(body.content).toContain("transcended"); expect(body.content).toContain("transcended at 3 prestige");
}); });
it("posts apotheosis message correctly", async () => { it("posts apotheosis message correctly", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+4 -1
View File
@@ -56,7 +56,10 @@ const howToPlay = [
"Challenge zone bosses to earn large one-time rewards and unlock new" "Challenge zone bosses to earn large one-time rewards and unlock new"
+ " zones. Your party's combat power is based on the number and tier of" + " zones. Your party's combat power is based on the number and tier of"
+ " adventurers you've recruited. Defeated bosses cannot be re-fought," + " adventurers you've recruited. Defeated bosses cannot be re-fought,"
+ " but undefeated bosses regenerate HP over time.", + " but undefeated bosses regenerate HP over time. Some late-game bosses"
+ " also require a specific quest to be completed before they become"
+ " available, in addition to defeating the previous boss in the zone —"
+ " the boss card shows exactly what's required while it's locked.",
title: "👹 Boss Fights", title: "👹 Boss Fights",
}, },
{ {
@@ -7,9 +7,10 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { getAchievementProgress } from "../../engine/achievementProgress.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement, GameState } from "@elysium/types"; import type { Achievement } from "@elysium/types";
/** /**
* Returns the plural form of a word based on a count. * Returns the plural form of a word based on a count.
@@ -18,9 +19,12 @@ import type { Achievement, GameState } from "@elysium/types";
* @returns The pluralised word string. * @returns The pluralised word string.
*/ */
const pluralise = (count: number, word: string): string => { const pluralise = (count: number, word: string): string => {
return count > 1 if (count <= 1) {
? `${word}s` return word;
: word; }
return word.endsWith("s")
? `${word}es`
: `${word}s`;
}; };
/** /**
@@ -39,9 +43,9 @@ const conditionDescription = (
return `Earn ${formatNumber(condition.amount)} total gold`; return `Earn ${formatNumber(condition.amount)} total gold`;
case "totalClicks": case "totalClicks":
return `Click ${formatNumber(condition.amount)} times`; return `Click ${formatNumber(condition.amount)} times`;
case "bossesDefeated": case "uniqueBossesDefeated":
return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`; return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`;
case "questsCompleted": case "uniqueQuestsCompleted":
return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`; return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`;
case "adventurerTotal": case "adventurerTotal":
return `Recruit ${formatNumber(condition.amount)} total adventurers`; return `Recruit ${formatNumber(condition.amount)} total adventurers`;
@@ -54,46 +58,6 @@ const conditionDescription = (
} }
}; };
/**
* Returns the player's current progress value toward an achievement's unlock condition,
* mirroring the logic used by the tick engine's checkAchievements function.
* @param achievement - The achievement to evaluate progress for.
* @param state - The current game state.
* @returns The current numeric progress toward the achievement condition.
*/
const getCurrentProgress = (
achievement: Achievement,
state: GameState,
): number => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
return state.player.totalGoldEarned;
case "totalClicks":
return state.player.totalClicks;
case "bossesDefeated":
return state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
case "questsCompleted":
return state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
case "adventurerTotal":
return state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.owned;
}).length;
default:
return 0;
}
};
interface AchievementCardProperties { interface AchievementCardProperties {
readonly achievement: Achievement; readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
@@ -232,7 +196,9 @@ const AchievementPanel = (): JSX.Element => {
achievement={achievement} achievement={achievement}
formatNumber={formatNumber} formatNumber={formatNumber}
key={achievement.id} key={achievement.id}
progressValue={getCurrentProgress(achievement, state)} progressValue={
getAchievementProgress(achievement.condition, state)
}
/> />
); );
})} })}
+22 -5
View File
@@ -23,6 +23,7 @@ interface BossCardProperties {
readonly onChallenge: (bossId: string)=> void; readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean; readonly isChallenging: boolean;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly questName: string | undefined;
readonly formatInteger: (n: number)=> string; readonly formatInteger: (n: number)=> string;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
} }
@@ -35,6 +36,7 @@ interface BossCardProperties {
* @param props.onChallenge - Callback to challenge this boss. * @param props.onChallenge - Callback to challenge this boss.
* @param props.isChallenging - Whether this boss is currently being challenged. * @param props.isChallenging - Whether this boss is currently being challenged.
* @param props.unlockHint - Optional hint for how to unlock this boss. * @param props.unlockHint - Optional hint for how to unlock this boss.
* @param props.questName - Optional name of this boss's own gating quest.
* @param props.formatInteger - The integer formatting utility function. * @param props.formatInteger - The integer formatting utility function.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @returns The JSX element. * @returns The JSX element.
@@ -45,6 +47,7 @@ const BossCard = ({
onChallenge, onChallenge,
isChallenging, isChallenging,
unlockHint, unlockHint,
questName,
formatInteger, formatInteger,
formatNumber, formatNumber,
}: BossCardProperties): JSX.Element => { }: BossCardProperties): JSX.Element => {
@@ -73,6 +76,9 @@ const BossCard = ({
? <p className="prestige-lock"> ? <p className="prestige-lock">
{"🔒 Requires Prestige "} {"🔒 Requires Prestige "}
{boss.prestigeRequirement} {boss.prestigeRequirement}
{questName === undefined
? null
: ` and Completing ${questName}`}
</p> </p>
: null} : null}
{!isPrestigeLocked {!isPrestigeLocked
@@ -243,6 +249,7 @@ const BossPanel = (): JSX.Element => {
}); });
const bossUnlockHints = new Map<string, string>(); const bossUnlockHints = new Map<string, string>();
const bossQuestNames = new Map<string, string>();
for (const zone of zones) { for (const zone of zones) {
const { id: zoneId, unlockBossId, unlockQuestId } = zone; const { id: zoneId, unlockBossId, unlockQuestId } = zone;
const allZoneBosses = bosses.filter((boss) => { const allZoneBosses = bosses.filter((boss) => {
@@ -253,8 +260,8 @@ const BossPanel = (): JSX.Element => {
if (boss === undefined || boss.status !== "locked") { if (boss === undefined || boss.status !== "locked") {
continue; continue;
} }
if (index === 0) {
const parts: Array<string> = []; const parts: Array<string> = [];
if (index === 0) {
if (unlockBossId !== null) { if (unlockBossId !== null) {
const gateBoss = bosses.find((candidate) => { const gateBoss = bosses.find((candidate) => {
return candidate.id === unlockBossId; return candidate.id === unlockBossId;
@@ -271,15 +278,24 @@ const BossPanel = (): JSX.Element => {
parts.push(`📜 Complete: ${gateQuest.name}`); parts.push(`📜 Complete: ${gateQuest.name}`);
} }
} }
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else { } else {
const previousBoss = allZoneBosses[index - 1]; const previousBoss = allZoneBosses[index - 1];
if (previousBoss !== undefined) { if (previousBoss !== undefined) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`); parts.push(`⚔️ Defeat: ${previousBoss.name} first`);
} }
} }
if (boss.unlockQuestId !== null && boss.unlockQuestId !== undefined) {
const bossGateQuest = quests.find((candidate) => {
return candidate.id === boss.unlockQuestId;
});
if (bossGateQuest !== undefined) {
parts.push(`📜 Complete: ${bossGateQuest.name}`);
bossQuestNames.set(boss.id, bossGateQuest.name);
}
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} }
} }
@@ -405,6 +421,7 @@ const BossPanel = (): JSX.Element => {
key={bossId} key={bossId}
onChallenge={handleChallengeClick} onChallenge={handleChallengeClick}
prestigeCount={prestigeCount} prestigeCount={prestigeCount}
questName={bossQuestNames.get(bossId)}
unlockHint={bossUnlockHints.get(bossId)} unlockHint={bossUnlockHints.get(bossId)}
/> />
); );
+19 -5
View File
@@ -16,6 +16,7 @@ import {
PRESTIGE_UPGRADES, PRESTIGE_UPGRADES,
} from "../../data/prestigeUpgrades.js"; } from "../../data/prestigeUpgrades.js";
import { import {
computeProjectedMilestoneBonus,
computeProjectedRunestones, computeProjectedRunestones,
} from "../../engine/tick.js"; } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -70,6 +71,7 @@ const PrestigePanel = (): JSX.Element => {
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones, toggleAutoPrestigeMaxRunestones,
triggerPrestigeToast, triggerPrestigeToast,
notifyPrestiged,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
@@ -93,12 +95,13 @@ const PrestigePanel = (): JSX.Element => {
const threshold = calculateThreshold(prestigeData.count); const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold; const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = computeProjectedRunestones(state); const runestonePreview = computeProjectedRunestones(state);
const milestoneBonusPreview = computeProjectedMilestoneBonus(state);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
const baseRunestones = Math.min( const baseRunestones = Math.min(
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15, Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
200, 400,
); );
const isAtMaxRunestones = baseRunestones >= 200; const isAtMaxRunestones = baseRunestones >= 400;
async function handlePrestige(): Promise<void> { async function handlePrestige(): Promise<void> {
setIsPending(true); setIsPending(true);
@@ -111,6 +114,7 @@ const PrestigePanel = (): JSX.Element => {
runestones: data.runestones, runestones: data.runestones,
}); });
triggerPrestigeToast(); triggerPrestigeToast();
notifyPrestiged();
if (enableSounds) { if (enableSounds) {
playSound("prestige"); playSound("prestige");
} }
@@ -230,14 +234,14 @@ const PrestigePanel = (): JSX.Element => {
{"Current production multiplier: "} {"Current production multiplier: "}
<strong> <strong>
{"×"} {"×"}
{prestigeData.productionMultiplier.toFixed(2)} {formatNumber(prestigeData.productionMultiplier)}
</strong> </strong>
</p> </p>
<p> <p>
{"After next prestige: "} {"After next prestige: "}
<strong> <strong>
{"×"} {"×"}
{nextMultiplier.toFixed(2)} {formatNumber(nextMultiplier)}
</strong> </strong>
</p> </p>
<p> <p>
@@ -257,10 +261,20 @@ const PrestigePanel = (): JSX.Element => {
} }
</p> </p>
: null} : null}
{isEligible && milestoneBonusPreview > 0
? <p className="runestone-milestone-preview">
{"🎉 Bonus: "}
<strong>
{"+"}
{formatInteger(milestoneBonusPreview)}
</strong>
{" Runestones (milestone prestige)"}
</p>
: null}
{isEligible && !isAtMaxRunestones {isEligible && !isAtMaxRunestones
? <p className="runestone-progress-hint"> ? <p className="runestone-progress-hint">
{"Earn more gold to increase your runestone yield " {"Earn more gold to increase your runestone yield "
+ "(capped at ×14³ the prestige threshold)."} + "(capped at ×27³ the prestige threshold)."}
</p> </p>
: null} : null}
{isEligible {isEligible
+42 -6
View File
@@ -9,6 +9,7 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Many local variables needed for quest state */ /* eslint-disable max-statements -- Many local variables needed for quest state */
import { getActiveCompanionBonus, type Quest } from "@elysium/types";
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import {
@@ -18,7 +19,6 @@ import {
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Quest } from "@elysium/types";
/** /**
* Formats a duration in seconds to a human-readable string. * Formats a duration in seconds to a human-readable string.
@@ -42,17 +42,36 @@ const formatDuration = (seconds: number): string => {
return `${String(seconds)}s`; return `${String(seconds)}s`;
}; };
/**
* Computes the effective quest duration in seconds, after applying any active
* companion quest-time reduction (e.g. Wren, Zuri).
* @param quest - The quest to check.
* @param questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The effective duration in seconds.
*/
const effectiveQuestDuration = (
quest: Quest,
questTimeReduction: number,
): number => {
return quest.durationSeconds * (1 - questTimeReduction);
};
/** /**
* Computes the time remaining for an active quest. * Computes the time remaining for an active quest.
* @param quest - The quest to check. * @param quest - The quest to check.
* @param questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The remaining seconds. * @returns The remaining seconds.
*/ */
const questTimeRemaining = (quest: Quest): number => { const questTimeRemaining = (
quest: Quest,
questTimeReduction: number,
): number => {
if (quest.status !== "active" || quest.startedAt === undefined) { if (quest.status !== "active" || quest.startedAt === undefined) {
return 0; return 0;
} }
const elapsed = (Date.now() - quest.startedAt) / 1000; const elapsed = (Date.now() - quest.startedAt) / 1000;
return Math.max(0, quest.durationSeconds - elapsed); const remaining = effectiveQuestDuration(quest, questTimeReduction) - elapsed;
return Math.max(0, remaining);
}; };
interface QuestCardProperties { interface QuestCardProperties {
@@ -60,6 +79,7 @@ interface QuestCardProperties {
readonly partyCombatPower: number; readonly partyCombatPower: number;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly zoneHint: string | undefined; readonly zoneHint: string | undefined;
readonly questTimeReduction: number;
} }
/** /**
@@ -69,6 +89,7 @@ interface QuestCardProperties {
* @param props.partyCombatPower - The current party's combat power. * @param props.partyCombatPower - The current party's combat power.
* @param props.unlockHint - Optional hint for how to unlock this quest. * @param props.unlockHint - Optional hint for how to unlock this quest.
* @param props.zoneHint - Optional hint for which zone to unlock. * @param props.zoneHint - Optional hint for which zone to unlock.
* @param props.questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The JSX element. * @returns The JSX element.
*/ */
const QuestCard = ({ const QuestCard = ({
@@ -76,6 +97,7 @@ const QuestCard = ({
partyCombatPower, partyCombatPower,
unlockHint, unlockHint,
zoneHint, zoneHint,
questTimeReduction,
}: QuestCardProperties): JSX.Element => { }: QuestCardProperties): JSX.Element => {
const { startQuest, formatNumber } = useGame(); const { startQuest, formatNumber } = useGame();
const cpRequired = quest.combatPowerRequired ?? 0; const cpRequired = quest.combatPowerRequired ?? 0;
@@ -176,14 +198,18 @@ const QuestCard = ({
type="button" type="button"
> >
{"Send Party ("} {"Send Party ("}
{formatDuration(quest.durationSeconds)} {formatDuration(
Math.round(effectiveQuestDuration(quest, questTimeReduction)),
)}
{")"} {")"}
</button> </button>
} }
{quest.status === "active" {quest.status === "active"
&& <span className="quest-badge active"> && <span className="quest-badge active">
{"⏳ "} {"⏳ "}
{formatDuration(Math.ceil(questTimeRemaining(quest)))} {formatDuration(
Math.ceil(questTimeRemaining(quest, questTimeReduction)),
)}
{" remaining"} {" remaining"}
</span> </span>
} }
@@ -214,7 +240,16 @@ const QuestPanel = (): JSX.Element => {
); );
} }
const { autoQuest, bosses, quests, zones } = state; const { autoQuest, bosses, companions, quests, zones } = state;
const companionBonus = getActiveCompanionBonus(
companions?.activeCompanionId,
companions?.unlockedCompanionIds ?? [],
);
const questTimeReduction
= companionBonus?.type === "questTime"
? companionBonus.value
: 0;
const activeZone = zones.find((zone) => { const activeZone = zones.find((zone) => {
return zone.id === activeZoneId; return zone.id === activeZoneId;
@@ -358,6 +393,7 @@ const QuestPanel = (): JSX.Element => {
key={quest.id} key={quest.id}
partyCombatPower={partyCombatPower} partyCombatPower={partyCombatPower}
quest={quest} quest={quest}
questTimeReduction={questTimeReduction}
unlockHint={questUnlockHints.get(quest.id)} unlockHint={questUnlockHints.get(quest.id)}
zoneHint={questZoneHints.get(quest.id)} zoneHint={questZoneHints.get(quest.id)}
/> />
+64 -1
View File
@@ -7,7 +7,13 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */
import { STORY_CHAPTERS } from "@elysium/types"; import { STORY_CHAPTERS } from "@elysium/types";
import { type JSX, useState } from "react"; import {
type JSX,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
useEffect,
useState,
} from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -27,6 +33,16 @@ const substituteCharacterName = (
return text.replaceAll("{characterName}", fallback); return text.replaceAll("{characterName}", fallback);
}; };
/**
* Stops the enlarged image click from bubbling to the overlay's close handler.
* @param event - The click event on the enlarged image.
*/
const handleFullImageClick = (
event: ReactMouseEvent<HTMLImageElement>,
): void => {
event.stopPropagation();
};
/** /**
* Renders the story panel with chapter navigation and content. * Renders the story panel with chapter navigation and content.
* @returns The JSX element. * @returns The JSX element.
@@ -34,6 +50,22 @@ const substituteCharacterName = (
const StoryPanel = (): JSX.Element => { const StoryPanel = (): JSX.Element => {
const { state, completeChapter } = useGame(); const { state, completeChapter } = useGame();
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0); const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
const [ isImageExpanded, setIsImageExpanded ] = useState(false);
useEffect(() => {
if (!isImageExpanded) {
return undefined;
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsImageExpanded(false);
}
}
globalThis.addEventListener("keydown", handleKeyDown);
return (): void => {
globalThis.removeEventListener("keydown", handleKeyDown);
};
}, [ isImageExpanded ]);
if (state === null) { if (state === null) {
return ( return (
@@ -57,6 +89,19 @@ const StoryPanel = (): JSX.Element => {
}) ?? null; }) ?? null;
const isUnread = isUnlocked && completion === null; const isUnread = isUnlocked && completion === null;
function handleImageExpand(): void {
setIsImageExpanded(true);
}
function handleImageCollapse(): void {
setIsImageExpanded(false);
}
function handleImageKeyDown(
event: ReactKeyboardEvent<HTMLImageElement>,
): void {
if (event.key === "Enter" || event.key === " ") {
setIsImageExpanded(true);
}
}
return ( return (
<div className="story-panel"> <div className="story-panel">
<div className="story-chapter-tabs"> <div className="story-chapter-tabs">
@@ -106,7 +151,11 @@ const StoryPanel = (): JSX.Element => {
<img <img
alt={activeChapter.title} alt={activeChapter.title}
className="story-chapter-banner" className="story-chapter-banner"
onClick={handleImageExpand}
onKeyDown={handleImageKeyDown}
role="button"
src={cdnImage("story-chapters", activeChapter.id)} src={cdnImage("story-chapters", activeChapter.id)}
tabIndex={0}
/> />
<h2 className="story-chapter-title"> <h2 className="story-chapter-title">
{"Chapter "} {"Chapter "}
@@ -178,6 +227,20 @@ const StoryPanel = (): JSX.Element => {
} }
</div> </div>
} }
{isImageExpanded && activeChapter !== undefined
? <div
className="modal-overlay"
onClick={handleImageCollapse}
>
<img
alt={activeChapter.title}
className="story-chapter-banner-full"
onClick={handleFullImageClick}
src={cdnImage("story-chapters", activeChapter.id)}
/>
</div>
: null}
</div> </div>
); );
}; };
+58 -6
View File
@@ -74,6 +74,25 @@ import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000; const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000; const autoPrestigeThresholdBase = 1_000_000;
/**
* Returns whether a boss's own unlock quest (if any) has been completed.
* @param questId - The quest ID gating the boss, or null/undefined if none.
* @param quests - The player's current quest states.
* @returns True when there's no quest gate, or the gate quest is completed.
*/
const isBossQuestSatisfied = (
questId: string | null | undefined,
quests: GameState["quests"],
): boolean => {
return (
questId === null
|| questId === undefined
|| quests.some((q) => {
return q.id === questId && q.status === "completed";
})
);
};
/** /**
* Pure function applies a boss challenge result to the game state. * Pure function applies a boss challenge result to the game state.
* Used by both the manual challengeBoss flow and the auto-boss tick logic. * Used by both the manual challengeBoss flow and the auto-boss tick logic.
@@ -146,12 +165,14 @@ const applyBossResult = (
if ( if (
b.id === nextZoneBossId b.id === nextZoneBossId
&& b.prestigeRequirement <= previous.prestige.count && b.prestigeRequirement <= previous.prestige.count
&& isBossQuestSatisfied(b.unlockQuestId, previous.quests)
) { ) {
return { ...b, status: "available" as const }; return { ...b, status: "available" as const };
} }
if ( if (
zoneFirstBossIds.has(b.id) zoneFirstBossIds.has(b.id)
&& b.prestigeRequirement <= previous.prestige.count && b.prestigeRequirement <= previous.prestige.count
&& isBossQuestSatisfied(b.unlockQuestId, previous.quests)
) { ) {
return { ...b, status: "available" as const }; return { ...b, status: "available" as const };
} }
@@ -181,6 +202,7 @@ const applyBossResult = (
return { return {
...equip, ...equip,
equipped: slotEmpty || equip.equipped, equipped: slotEmpty || equip.equipped,
everOwned: true,
owned: true, owned: true,
}; };
} }
@@ -404,6 +426,13 @@ interface GameContextValue {
*/ */
triggerPrestigeToast: ()=> void; triggerPrestigeToast: ()=> void;
/**
* Schedule a lightweight background save ~10s after a prestige completes,
* folding back into the normal auto-save cadence (called from prestigePanel
* on manual prestige, and internally on auto-prestige).
*/
notifyPrestiged: ()=> void;
/** /**
* Dismiss the prestige milestone toast. * Dismiss the prestige milestone toast.
*/ */
@@ -716,13 +745,15 @@ export const GameProvider = ({
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>( const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
); );
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix"); const [ numberFormat, setNumberFormat ]
= useState<NumberFormat>("scientific");
const [ enableSounds, setEnableSounds ] = useState(false); const [ enableSounds, setEnableSounds ] = useState(false);
const [ enableNotifications, setEnableNotifications ] = useState(false); const [ enableNotifications, setEnableNotifications ] = useState(false);
const enableSoundsReference = useRef(false); const enableSoundsReference = useRef(false);
const enableNotificationsReference = useRef(false); const enableNotificationsReference = useRef(false);
const stateReference = useRef<GameState | null>(null); const stateReference = useRef<GameState | null>(null);
const lastSaveReference = useRef<number>(Date.now()); const lastSaveReference = useRef<number>(Date.now());
const postPrestigeSaveDeadlineReference = useRef<number | null>(null);
const isSyncingReference = useRef(false); const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null); const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]); const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
@@ -741,6 +772,9 @@ export const GameProvider = ({
/* No-op placeholder */ /* No-op placeholder */
}); });
const notifyPrestiged = useCallback(() => {
postPrestigeSaveDeadlineReference.current = Date.now() + 10_000;
}, []);
const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
@@ -1356,9 +1390,19 @@ export const GameProvider = ({
newlyFailedQuestsReference.current = []; newlyFailedQuestsReference.current = [];
} }
// Auto-save every 30 seconds (skip if a force sync or auto-prestige is in-flight to avoid signature collisions) /*
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) { * Auto-save every 30 seconds, or ~10s after a prestige (whichever comes
* first) so recent progress lands without waiting on a heavy force-sync
* (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
*/
const postPrestigeDeadline = postPrestigeSaveDeadlineReference.current;
const shouldAutoSave
= Date.now() - lastSaveReference.current >= autoSaveIntervalMs;
const shouldPostPrestigeSave
= postPrestigeDeadline !== null && Date.now() >= postPrestigeDeadline;
if (shouldAutoSave || shouldPostPrestigeSave) {
lastSaveReference.current = Date.now(); lastSaveReference.current = Date.now();
postPrestigeSaveDeadlineReference.current = null;
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability // eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) { if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
void saveGame({ void saveGame({
@@ -1406,11 +1450,11 @@ export const GameProvider = ({
(autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold, (autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold,
), ),
) * 15, ) * 15,
200, 400,
); );
const autoMaxRunestonesMet const autoMaxRunestonesMet
= autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true = autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true
|| autoBaseRunestones >= 200; || autoBaseRunestones >= 400;
if ( if (
!isAutoPrestigingReference.current !isAutoPrestigingReference.current
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") && autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
@@ -1429,6 +1473,7 @@ export const GameProvider = ({
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!"); sendNotification("⭐ Prestige!", "You have ascended!");
} }
notifyPrestiged();
await reloadSilentReference.current(); await reloadSilentReference.current();
}). }).
catch(() => { catch(() => {
@@ -1823,7 +1868,12 @@ export const GameProvider = ({
...previous, ...previous,
equipment: previous.equipment.map((equip) => { equipment: previous.equipment.map((equip) => {
if (equip.id === equipmentId) { if (equip.id === equipmentId) {
return { ...equip, equipped: !slotAlreadyEquipped, owned: true }; return {
...equip,
equipped: !slotAlreadyEquipped,
everOwned: true,
owned: true,
};
} }
return equip; return equip;
}), }),
@@ -2531,6 +2581,7 @@ export const GameProvider = ({
lastSavedAt, lastSavedAt,
loginBonus, loginBonus,
loginStreak, loginStreak,
notifyPrestiged,
numberFormat, numberFormat,
offlineEssence, offlineEssence,
offlineGold, offlineGold,
@@ -2608,6 +2659,7 @@ export const GameProvider = ({
lastSavedAt, lastSavedAt,
loginBonus, loginBonus,
loginStreak, loginStreak,
notifyPrestiged,
numberFormat, numberFormat,
offlineEssence, offlineEssence,
offlineGold, offlineGold,
+67 -67
View File
@@ -20,7 +20,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
durationSeconds: 300, durationSeconds: 60,
id: "verdant_meadow", id: "verdant_meadow",
name: "The Verdant Meadow", name: "The Verdant Meadow",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -28,7 +28,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
durationSeconds: 600, durationSeconds: 75,
id: "whispering_forest", id: "whispering_forest",
name: "The Whispering Forest", name: "The Whispering Forest",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -36,7 +36,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
durationSeconds: 900, durationSeconds: 90,
id: "ancient_grove", id: "ancient_grove",
name: "The Ancient Grove", name: "The Ancient Grove",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -44,7 +44,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
durationSeconds: 1200, durationSeconds: 105,
id: "forbidden_glen", id: "forbidden_glen",
name: "The Forbidden Glen", name: "The Forbidden Glen",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -54,7 +54,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
durationSeconds: 600, durationSeconds: 75,
id: "collapsed_outpost", id: "collapsed_outpost",
name: "The Collapsed Outpost", name: "The Collapsed Outpost",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -62,7 +62,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
durationSeconds: 1200, durationSeconds: 90,
id: "cursed_lake", id: "cursed_lake",
name: "The Cursed Lake", name: "The Cursed Lake",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -70,7 +70,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
durationSeconds: 1800, durationSeconds: 120,
id: "runic_archive", id: "runic_archive",
name: "The Runic Archive", name: "The Runic Archive",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -78,7 +78,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
durationSeconds: 2400, durationSeconds: 150,
id: "dragon_throne", id: "dragon_throne",
name: "The Dragon's Throne", name: "The Dragon's Throne",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -88,7 +88,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
durationSeconds: 900, durationSeconds: 105,
id: "glacial_cave", id: "glacial_cave",
name: "The Glacial Cave", name: "The Glacial Cave",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -96,7 +96,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
durationSeconds: 1800, durationSeconds: 135,
id: "frozen_tundra", id: "frozen_tundra",
name: "The Frozen Tundra", name: "The Frozen Tundra",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -104,7 +104,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
durationSeconds: 2700, durationSeconds: 180,
id: "void_rift", id: "void_rift",
name: "The Void Rift", name: "The Void Rift",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -112,7 +112,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
durationSeconds: 3600, durationSeconds: 240,
id: "summit_shrine", id: "summit_shrine",
name: "The Summit Shrine", name: "The Summit Shrine",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -122,7 +122,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
durationSeconds: 1500, durationSeconds: 105,
id: "fog_hollow", id: "fog_hollow",
name: "The Fog Hollow", name: "The Fog Hollow",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -130,7 +130,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
durationSeconds: 3000, durationSeconds: 150,
id: "dark_grotto", id: "dark_grotto",
name: "The Dark Grotto", name: "The Dark Grotto",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -138,7 +138,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
durationSeconds: 5400, durationSeconds: 195,
id: "cursed_barrow", id: "cursed_barrow",
name: "The Cursed Barrow", name: "The Cursed Barrow",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -146,7 +146,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
durationSeconds: 5400, durationSeconds: 255,
id: "marsh_depths", id: "marsh_depths",
name: "The Marsh Depths", name: "The Marsh Depths",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -156,7 +156,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
durationSeconds: 2100, durationSeconds: 120,
id: "magma_tunnel", id: "magma_tunnel",
name: "The Magma Tunnel", name: "The Magma Tunnel",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -164,7 +164,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
durationSeconds: 3600, durationSeconds: 165,
id: "forge_chamber", id: "forge_chamber",
name: "The Forge Chamber", name: "The Forge Chamber",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -172,7 +172,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
durationSeconds: 7200, durationSeconds: 225,
id: "fire_temple", id: "fire_temple",
name: "The Fire Temple", name: "The Fire Temple",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -180,7 +180,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
durationSeconds: 9000, durationSeconds: 300,
id: "core_descent", id: "core_descent",
name: "The Core Descent", name: "The Core Descent",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -190,7 +190,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
durationSeconds: 3000, durationSeconds: 150,
id: "star_field", id: "star_field",
name: "The Star Field", name: "The Star Field",
zoneId: "astral_void", zoneId: "astral_void",
@@ -198,7 +198,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
durationSeconds: 5400, durationSeconds: 210,
id: "probability_sea", id: "probability_sea",
name: "The Probability Sea", name: "The Probability Sea",
zoneId: "astral_void", zoneId: "astral_void",
@@ -206,7 +206,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
durationSeconds: 9000, durationSeconds: 270,
id: "void_current", id: "void_current",
name: "The Void Current", name: "The Void Current",
zoneId: "astral_void", zoneId: "astral_void",
@@ -214,7 +214,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
durationSeconds: 12_600, durationSeconds: 375,
id: "null_zenith", id: "null_zenith",
name: "The Null Zenith", name: "The Null Zenith",
zoneId: "astral_void", zoneId: "astral_void",
@@ -224,7 +224,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
durationSeconds: 3600, durationSeconds: 300,
id: "light_spire", id: "light_spire",
name: "The Light Spire", name: "The Light Spire",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -232,7 +232,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
durationSeconds: 7200, durationSeconds: 390,
id: "choir_hall", id: "choir_hall",
name: "The Choir Hall", name: "The Choir Hall",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -240,7 +240,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
durationSeconds: 10_800, durationSeconds: 540,
id: "divine_court", id: "divine_court",
name: "The Divine Court", name: "The Divine Court",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -248,7 +248,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
durationSeconds: 14_400, durationSeconds: 720,
id: "celestial_vault", id: "celestial_vault",
name: "The Celestial Vault", name: "The Celestial Vault",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -258,7 +258,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
durationSeconds: 3600, durationSeconds: 420,
id: "trench_entrance", id: "trench_entrance",
name: "The Trench Entrance", name: "The Trench Entrance",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -266,7 +266,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
durationSeconds: 9000, durationSeconds: 570,
id: "deep_current", id: "deep_current",
name: "The Deep Current", name: "The Deep Current",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -274,7 +274,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
durationSeconds: 12_600, durationSeconds: 750,
id: "sunless_chamber", id: "sunless_chamber",
name: "The Sunless Chamber", name: "The Sunless Chamber",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -282,7 +282,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
durationSeconds: 16_200, durationSeconds: 990,
id: "the_waiting_place", id: "the_waiting_place",
name: "The Waiting Place", name: "The Waiting Place",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -292,7 +292,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
durationSeconds: 5400, durationSeconds: 540,
id: "demon_market", id: "demon_market",
name: "The Demon Market", name: "The Demon Market",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -300,7 +300,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
durationSeconds: 9000, durationSeconds: 720,
id: "torment_hall", id: "torment_hall",
name: "The Torment Hall", name: "The Torment Hall",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -308,7 +308,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
durationSeconds: 14_400, durationSeconds: 960,
id: "soul_forge", id: "soul_forge",
name: "The Soul Forge", name: "The Soul Forge",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -316,7 +316,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
durationSeconds: 19_800, durationSeconds: 1320,
id: "lords_chamber", id: "lords_chamber",
name: "The Lords' Chamber", name: "The Lords' Chamber",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -326,7 +326,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
durationSeconds: 5400, durationSeconds: 750,
id: "facet_approach", id: "facet_approach",
name: "The Facet Approach", name: "The Facet Approach",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -334,7 +334,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
durationSeconds: 10_800, durationSeconds: 1005,
id: "calculation_chamber", id: "calculation_chamber",
name: "The Calculation Chamber", name: "The Calculation Chamber",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -342,7 +342,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
durationSeconds: 16_200, durationSeconds: 1320,
id: "mirror_hall", id: "mirror_hall",
name: "The Mirror Hall", name: "The Mirror Hall",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -350,7 +350,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
durationSeconds: 21_600, durationSeconds: 1800,
id: "core_access", id: "core_access",
name: "The Core Access", name: "The Core Access",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -360,7 +360,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
durationSeconds: 5400, durationSeconds: 990,
id: "threshold", id: "threshold",
name: "The Threshold", name: "The Threshold",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -368,7 +368,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
durationSeconds: 12_600, durationSeconds: 1320,
id: "inner_silence", id: "inner_silence",
name: "The Inner Silence", name: "The Inner Silence",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -376,7 +376,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
durationSeconds: 18_000, durationSeconds: 1800,
id: "resonance_chamber", id: "resonance_chamber",
name: "The Resonance Chamber", name: "The Resonance Chamber",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -384,7 +384,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
durationSeconds: 25_200, durationSeconds: 2400,
id: "sanctum_heart", id: "sanctum_heart",
name: "The Sanctum Heart", name: "The Sanctum Heart",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -394,7 +394,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
durationSeconds: 7200, durationSeconds: 1320,
id: "throne_approach", id: "throne_approach",
name: "The Throne Approach", name: "The Throne Approach",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -402,7 +402,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
durationSeconds: 12_600, durationSeconds: 1800,
id: "dominion_hall", id: "dominion_hall",
name: "The Dominion Hall", name: "The Dominion Hall",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -410,7 +410,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
durationSeconds: 19_800, durationSeconds: 2400,
id: "eternity_vault", id: "eternity_vault",
name: "The Eternity Vault", name: "The Eternity Vault",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -418,7 +418,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
durationSeconds: 25_200, durationSeconds: 3600,
id: "the_seat", id: "the_seat",
name: "The Seat", name: "The Seat",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -428,7 +428,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
durationSeconds: 7200, durationSeconds: 1800,
id: "creation_storm", id: "creation_storm",
name: "The Creation Storm", name: "The Creation Storm",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -436,7 +436,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
durationSeconds: 14_400, durationSeconds: 2700,
id: "unmaking_sea", id: "unmaking_sea",
name: "The Unmaking Sea", name: "The Unmaking Sea",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -444,7 +444,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
durationSeconds: 21_600, durationSeconds: 3600,
id: "probability_void", id: "probability_void",
name: "The Probability Void", name: "The Probability Void",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -452,7 +452,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
durationSeconds: 28_800, durationSeconds: 4500,
id: "chaos_core", id: "chaos_core",
name: "The Chaos Core", name: "The Chaos Core",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -462,7 +462,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
durationSeconds: 7200, durationSeconds: 2400,
id: "first_horizon", id: "first_horizon",
name: "The First Horizon", name: "The First Horizon",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -470,7 +470,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
durationSeconds: 16_200, durationSeconds: 3600,
id: "middle_nowhere", id: "middle_nowhere",
name: "The Middle of Nowhere", name: "The Middle of Nowhere",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -478,7 +478,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
durationSeconds: 25_200, durationSeconds: 4500,
id: "edge_approach", id: "edge_approach",
name: "The Edge Approach", name: "The Edge Approach",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -486,7 +486,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
durationSeconds: 32_400, durationSeconds: 5700,
id: "the_furthest", id: "the_furthest",
name: "The Furthest", name: "The Furthest",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -496,7 +496,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
durationSeconds: 9000, durationSeconds: 5400,
id: "workshop_entrance", id: "workshop_entrance",
name: "The Workshop Entrance", name: "The Workshop Entrance",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -504,7 +504,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
durationSeconds: 16_200, durationSeconds: 7200,
id: "creation_floor", id: "creation_floor",
name: "The Creation Floor", name: "The Creation Floor",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -512,7 +512,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
durationSeconds: 25_200, durationSeconds: 9000,
id: "master_forge", id: "master_forge",
name: "The Master Forge", name: "The Master Forge",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -520,7 +520,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
durationSeconds: 32_400, durationSeconds: 12_600,
id: "forge_core", id: "forge_core",
name: "The Forge Core", name: "The Forge Core",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -530,7 +530,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
durationSeconds: 9000, durationSeconds: 7200,
id: "outer_current", id: "outer_current",
name: "The Outer Current", name: "The Outer Current",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -538,7 +538,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
durationSeconds: 18_000, durationSeconds: 10_800,
id: "debris_field", id: "debris_field",
name: "The Debris Field", name: "The Debris Field",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -546,7 +546,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
durationSeconds: 28_800, durationSeconds: 14_400,
id: "force_confluence", id: "force_confluence",
name: "The Force Confluence", name: "The Force Confluence",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -554,7 +554,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
durationSeconds: 36_000, durationSeconds: 18_000,
id: "eye_approach", id: "eye_approach",
name: "The Eye Approach", name: "The Eye Approach",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -572,7 +572,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
durationSeconds: 19_800, durationSeconds: 16_200,
id: "ancient_archive", id: "ancient_archive",
name: "The Ancient Archive", name: "The Ancient Archive",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -580,7 +580,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
durationSeconds: 28_800, durationSeconds: 22_500,
id: "memory_chamber", id: "memory_chamber",
name: "The Memory Chamber", name: "The Memory Chamber",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -588,7 +588,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
durationSeconds: 39_600, durationSeconds: 29_700,
id: "the_oldest_place", id: "the_oldest_place",
name: "The Oldest Place", name: "The Oldest Place",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
+6 -6
View File
@@ -94,20 +94,20 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
{ {
category: "income", category: "income",
description: description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.", "The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
id: "income_10", id: "income_10",
multiplier: 500, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 30_000, runestonesCost: 15_000,
}, },
{ {
category: "income", category: "income",
description: description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.", "Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
id: "income_11", id: "income_11",
multiplier: 1000, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 80_000, runestonesCost: 35_000,
}, },
// ── Click Power ─────────────────────────────────────────────────────────── // ── Click Power ───────────────────────────────────────────────────────────
{ {
+15 -15
View File
@@ -13,7 +13,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Income multipliers ────────────────────────────────────────────────────── // ── Income multipliers ──────────────────────────────────────────────────────
{ {
category: "income", category: "income",
cost: 5, cost: 2,
description: description:
"The echoes of past runs linger, amplifying your guild's income by 25%.", "The echoes of past runs linger, amplifying your guild's income by 25%.",
id: "echo_income_1", id: "echo_income_1",
@@ -22,7 +22,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 10, cost: 4,
description: description:
"Your transcendent experience resonates through your guild, boosting income by 50%.", "Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2", id: "echo_income_2",
@@ -31,7 +31,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 20, cost: 8,
description: description:
"The harmony of multiple timelines surges through your guild, doubling its income.", "The harmony of multiple timelines surges through your guild, doubling its income.",
id: "echo_income_3", id: "echo_income_3",
@@ -40,7 +40,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 40, cost: 16,
description: description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.", "Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4", id: "echo_income_4",
@@ -49,7 +49,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 80, cost: 32,
description: description:
"The infinite chorus of every run you've ever played amplifies your guild fivefold.", "The infinite chorus of every run you've ever played amplifies your guild fivefold.",
id: "echo_income_5", id: "echo_income_5",
@@ -60,7 +60,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Combat multipliers ────────────────────────────────────────────────────── // ── Combat multipliers ──────────────────────────────────────────────────────
{ {
category: "combat", category: "combat",
cost: 5, cost: 2,
description: description:
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.", "Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
id: "echo_combat_1", id: "echo_combat_1",
@@ -69,7 +69,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "combat", category: "combat",
cost: 15, cost: 6,
description: description:
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.", "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
id: "echo_combat_2", id: "echo_combat_2",
@@ -78,7 +78,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "combat", category: "combat",
cost: 35, cost: 12,
description: description:
"Your warriors carry the strength of every fallen timeline, doubling party DPS.", "Your warriors carry the strength of every fallen timeline, doubling party DPS.",
id: "echo_combat_3", id: "echo_combat_3",
@@ -89,7 +89,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Prestige threshold reductions ────────────────────────────────────────── // ── Prestige threshold reductions ──────────────────────────────────────────
{ {
category: "prestige_threshold", category: "prestige_threshold",
cost: 8, cost: 3,
description: description:
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.", "Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
id: "echo_prestige_threshold_1", id: "echo_prestige_threshold_1",
@@ -98,7 +98,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "prestige_threshold", category: "prestige_threshold",
cost: 20, cost: 6,
description: description:
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.", "You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
id: "echo_prestige_threshold_2", id: "echo_prestige_threshold_2",
@@ -109,7 +109,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Prestige runestone multipliers ───────────────────────────────────────── // ── Prestige runestone multipliers ─────────────────────────────────────────
{ {
category: "prestige_runestones", category: "prestige_runestones",
cost: 8, cost: 3,
description: description:
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.", "Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
id: "echo_prestige_runestones_1", id: "echo_prestige_runestones_1",
@@ -118,7 +118,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "prestige_runestones", category: "prestige_runestones",
cost: 20, cost: 6,
description: description:
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.", "You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
id: "echo_prestige_runestones_2", id: "echo_prestige_runestones_2",
@@ -129,7 +129,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ─────────────────────────────────────────────────── // ── Echo meta multipliers ───────────────────────────────────────────────────
{ {
category: "echo_meta", category: "echo_meta",
cost: 10, cost: 15,
description: description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.", "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1", id: "echo_meta_1",
@@ -138,7 +138,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 25, cost: 45,
description: description:
"Each loop of existence makes the next more powerful — future echo yields +50%.", "Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2", id: "echo_meta_2",
@@ -147,7 +147,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 50, cost: 100,
description: description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.", "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3", id: "echo_meta_3",
@@ -0,0 +1,57 @@
/**
* @file Shared achievement progress calculation for Elysium.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { AchievementCondition, GameState } from "@elysium/types";
/**
* Computes the player's current progress toward an achievement condition.
* Stat-based conditions combine the frozen lifetime total from the last
* prestige/transcendence/apotheosis reset with this run's live contribution,
* so progress reflects the true all-time total rather than resetting to zero
* every reset. `prestigeCount`, `equipmentOwned`, `uniqueQuestsCompleted`,
* and `uniqueBossesDefeated` already read persistent state directly (via
* `everOwned`/`everCompleted`/`everDefeated` flags) and need no such
* combination.
* @param condition - The achievement's unlock condition.
* @param state - The current game state to evaluate progress against.
* @returns The current numeric progress toward the condition's target amount.
*/
const getAchievementProgress = (
condition: AchievementCondition,
state: GameState,
): number => {
switch (condition.type) {
case "totalGoldEarned":
return state.player.lifetimeGoldEarned + state.player.totalGoldEarned;
case "totalClicks":
return state.player.lifetimeClicks + state.player.totalClicks;
case "uniqueBossesDefeated":
return state.bosses.filter((boss) => {
return boss.everDefeated === true || boss.status === "defeated";
}).length;
case "uniqueQuestsCompleted":
return state.quests.filter((quest) => {
return quest.everCompleted === true || quest.status === "completed";
}).length;
case "adventurerTotal":
return (
state.player.lifetimeAdventurersRecruited
+ state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0)
);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.everOwned === true || item.owned;
}).length;
default:
/* V8 ignore next -- @preserve */ return 0;
}
};
export { getAchievementProgress };
+75 -44
View File
@@ -22,6 +22,7 @@ import {
import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { EXPLORATION_AREAS } from "../data/explorations.js"; import { EXPLORATION_AREAS } from "../data/explorations.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { getAchievementProgress } from "./achievementProgress.js";
/** /**
* Checks all achievements against the current game state and returns an updated * Checks all achievements against the current game state and returns an updated
@@ -37,45 +38,7 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
} }
const { condition } = achievement; const { condition } = achievement;
let met = false; const met = getAchievementProgress(condition, state) >= condition.amount;
switch (condition.type) {
case "totalGoldEarned":
met = state.player.lifetimeGoldEarned >= condition.amount;
break;
case "totalClicks":
met = state.player.totalClicks >= condition.amount;
break;
case "bossesDefeated":
met
= state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length >= condition.amount;
break;
case "questsCompleted":
met
= state.quests.filter((quest) => {
return quest.status === "completed";
}).length >= condition.amount;
break;
case "adventurerTotal":
met
= state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0) >= condition.amount;
break;
case "prestigeCount":
met = state.prestige.count >= condition.amount;
break;
case "equipmentOwned":
met
= state.equipment.filter((item) => {
return item.owned;
}).length >= condition.amount;
break;
default:
/* V8 ignore next -- @preserve */ break;
}
return met return met
? { ...achievement, unlockedAt: now } ? { ...achievement, unlockedAt: now }
@@ -452,11 +415,32 @@ export const computePartyCombatPower = (state: GameState): number => {
const basePrestigeThreshold = 1_000_000; const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 20; const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200; const maxBaseRunestones = 400;
const milestoneIntervalClient = 5;
const milestoneRunestonesPerIntervalClient = 25;
/** /**
* Computes the projected runestone reward if the player were to prestige right now. * Computes the milestone runestone bonus the player would receive if they
* Mirrors the server-side calculateRunestones formula exactly. * prestiged right now. Mirrors the server-side calculateMilestoneBonus formula:
* every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param state - The current game state.
* @returns The milestone runestone bonus, or 0 if the next prestige isn't a milestone.
*/
export const computeProjectedMilestoneBonus = (state: GameState): number => {
const nextPrestigeCount = state.prestige.count + 1;
if (nextPrestigeCount % milestoneIntervalClient !== 0) {
return 0;
}
const milestoneNumber = nextPrestigeCount / milestoneIntervalClient;
return (
milestoneNumber * milestoneNumber * milestoneRunestonesPerIntervalClient
);
};
/**
* Computes the projected runestone reward if the player were to prestige right now,
* including any milestone bonus, so callers always see the true total they'd receive.
* Mirrors the server-side calculateRunestones + calculateMilestoneBonus formulas exactly.
* @param state - The current game state. * @param state - The current game state.
* @returns The number of runestones the player would earn from a prestige now. * @returns The number of runestones the player would earn from a prestige now.
*/ */
@@ -480,7 +464,10 @@ export const computeProjectedRunestones = (state: GameState): number => {
const runestoneMult = gain1Mult * gain2Mult; const runestoneMult = gain1Mult * gain2Mult;
const echoMult: number const echoMult: number
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult); return (
Math.floor(base * runestoneMult * echoMult)
+ computeProjectedMilestoneBonus(state)
);
}; };
/** /**
@@ -649,6 +636,7 @@ export const applyTick = (
return { return {
...item, ...item,
equipped: slotEmpty || item.equipped, equipped: slotEmpty || item.equipped,
everOwned: true,
owned: true, owned: true,
}; };
}); });
@@ -731,7 +719,11 @@ export const applyTick = (
return zoneBoss.zoneId === boss.zoneId; return zoneBoss.zoneId === boss.zoneId;
}); });
const [ firstBoss ] = zoneBosses; const [ firstBoss ] = zoneBosses;
if (firstBoss?.id === boss.id && boss.status === "locked") { const questOk
= boss.unlockQuestId === null
|| boss.unlockQuestId === undefined
|| completedIds.has(boss.unlockQuestId);
if (firstBoss?.id === boss.id && boss.status === "locked" && questOk) {
return { ...boss, status: "available" as const }; return { ...boss, status: "available" as const };
} }
} }
@@ -739,6 +731,45 @@ export const applyTick = (
}); });
} }
/*
* Activate any locked boss whose own unlock quest just became satisfied
* this tick, independent of a zone-unlock or boss-defeat event.
*/
updatedBosses = updatedBosses.map((boss) => {
if (boss.status !== "locked") {
return boss;
}
if (boss.unlockQuestId === null || boss.unlockQuestId === undefined) {
return boss;
}
if (!completedIds.has(boss.unlockQuestId)) {
return boss;
}
if (boss.prestigeRequirement > state.prestige.count) {
return boss;
}
const bossZone = updatedZones.find((zone) => {
return zone.id === boss.zoneId;
});
if (bossZone?.status !== "unlocked") {
return boss;
}
const zoneBosses = updatedBosses.filter((zoneBoss) => {
return zoneBoss.zoneId === boss.zoneId;
});
const bossIndex = zoneBosses.findIndex((zoneBoss) => {
return zoneBoss.id === boss.id;
});
const previousZoneBoss = zoneBosses[bossIndex - 1];
if (
previousZoneBoss !== undefined
&& previousZoneBoss.status !== "defeated"
) {
return boss;
}
return { ...boss, status: "available" as const };
});
// Count quests newly completed this tick and update daily challenge progress // Count quests newly completed this tick and update daily challenge progress
const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => { const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => {
const wasNotCompleted = state.quests[index]?.status !== "completed"; const wasNotCompleted = state.quests[index]?.status !== "completed";
+11 -3
View File
@@ -4455,6 +4455,7 @@ body::before {
color: var(--colour-text); color: var(--colour-text);
line-height: 1.6; line-height: 1.6;
font-style: italic; font-style: italic;
font-size: 0.9rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--colour-surface); background: var(--colour-surface);
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -4583,13 +4584,20 @@ body::before {
.story-chapter-banner { .story-chapter-banner {
border-radius: var(--radius); border-radius: var(--radius);
height: 220px; height: 260px;
margin-bottom: 1rem; margin-bottom: 1rem;
object-fit: cover; object-fit: contain;
object-position: top; cursor: pointer;
width: 100%; width: 100%;
} }
.story-chapter-banner-full {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius);
}
.codex-entry-image { .codex-entry-image {
border-radius: var(--radius); border-radius: var(--radius);
height: 80px; height: 80px;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+3 -3
View File
@@ -8,11 +8,11 @@
type AchievementConditionType = type AchievementConditionType =
| "totalGoldEarned" | "totalGoldEarned"
| "totalClicks" | "totalClicks"
| "bossesDefeated"
| "questsCompleted"
| "adventurerTotal" | "adventurerTotal"
| "prestigeCount" | "prestigeCount"
| "equipmentOwned"; | "equipmentOwned"
| "uniqueQuestsCompleted"
| "uniqueBossesDefeated";
interface AchievementCondition { interface AchievementCondition {
type: AchievementConditionType; type: AchievementConditionType;
+14
View File
@@ -55,6 +55,12 @@ interface Boss {
*/ */
zoneId: string; zoneId: string;
/**
* ID of the quest that must be completed before this boss becomes
* available. `null`/`undefined` means no quest gate applies.
*/
unlockQuestId?: string | null;
/** /**
* One-time runestone bounty awarded on first-ever defeat. * One-time runestone bounty awarded on first-ever defeat.
*/ */
@@ -66,6 +72,14 @@ interface Boss {
* bounty is never re-awarded in subsequent runs. * bounty is never re-awarded in subsequent runs.
*/ */
bountyRunestonesClaimed?: boolean; bountyRunestonesClaimed?: boolean;
/**
* Whether this boss has ever been defeated, across all prestige/
* transcendence/apotheosis resets. Used by achievements that require
* defeating every distinct boss, since `status` alone only reflects
* the current run.
*/
everDefeated?: boolean;
} }
export type { Boss, BossStatus }; export type { Boss, BossStatus };
+3 -3
View File
@@ -87,7 +87,7 @@ const COMPANIONS: Array<Companion> = [
id: "wren", id: "wren",
name: "Wren", name: "Wren",
title: "Hedge Witch", title: "Hedge Witch",
unlock: { threshold: 500, type: "lifetimeQuests" }, unlock: { threshold: 250, type: "lifetimeQuests" },
}, },
{ {
bonus: { type: "bossDamage", value: 0.2 }, bonus: { type: "bossDamage", value: 0.2 },
@@ -117,7 +117,7 @@ const COMPANIONS: Array<Companion> = [
id: "kael", id: "kael",
name: "Kael", name: "Kael",
title: "Battle Mage", title: "Battle Mage",
unlock: { threshold: 720, type: "lifetimeBosses" }, unlock: { threshold: 500, type: "lifetimeBosses" },
}, },
{ {
bonus: { type: "questTime", value: 0.3 }, bonus: { type: "questTime", value: 0.3 },
@@ -127,7 +127,7 @@ const COMPANIONS: Array<Companion> = [
id: "zuri", id: "zuri",
name: "Zuri", name: "Zuri",
title: "Chrono Weaver", title: "Chrono Weaver",
unlock: { threshold: 950, type: "lifetimeQuests" }, unlock: { threshold: 500, type: "lifetimeQuests" },
}, },
{ {
bonus: { type: "passiveGold", value: 0.75 }, bonus: { type: "passiveGold", value: 0.75 },
@@ -40,6 +40,13 @@ interface Equipment {
*/ */
owned: boolean; owned: boolean;
/**
* Whether the player has ever acquired this item, across all runs never reset.
* Optional for backwards compatibility with saves persisted before this field
* existed; readers should fall back to `owned` when it is absent.
*/
everOwned?: boolean;
/** /**
* Whether this item is currently equipped (only one per type can be equipped). * Whether this item is currently equipped (only one per type can be equipped).
*/ */
@@ -60,7 +60,7 @@ const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
enableNotifications: false, enableNotifications: false,
enablePrestigeAnnouncements: true, enablePrestigeAnnouncements: true,
enableSounds: false, enableSounds: false,
numberFormat: "suffix", numberFormat: "scientific",
showAchievementsUnlocked: true, showAchievementsUnlocked: true,
showAdventurersRecruited: true, showAdventurersRecruited: true,
showApotheosis: true, showApotheosis: true,
+8
View File
@@ -61,6 +61,14 @@ interface Quest {
* Unix timestamp of the most recent failed attempt (if any). * Unix timestamp of the most recent failed attempt (if any).
*/ */
lastFailedAt?: number; lastFailedAt?: number;
/**
* Whether this quest has ever been completed, across all prestige/
* transcendence/apotheosis resets. Used by achievements that require
* completing every distinct quest, since `status` alone only reflects
* the current run.
*/
everCompleted?: boolean;
} }
export type { Quest, QuestReward, QuestRewardType, QuestStatus }; export type { Quest, QuestReward, QuestRewardType, QuestStatus };
+8 -8
View File
@@ -35,8 +35,8 @@ describe("computeUnlockedCompanionIds", () => {
expect(result).toContain("finn"); expect(result).toContain("finn");
}); });
it("unlocks wren at 500 lifetime quests", () => { it("unlocks wren at 250 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 500 }); const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 250 });
expect(result).toContain("finn"); expect(result).toContain("finn");
expect(result).toContain("wren"); expect(result).toContain("wren");
}); });
@@ -57,15 +57,15 @@ describe("computeUnlockedCompanionIds", () => {
expect(result).not.toContain("sera"); expect(result).not.toContain("sera");
}); });
it("unlocks kael at 720 lifetime bosses", () => { it("unlocks kael at 500 lifetime bosses", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 720 }); const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 500 });
expect(result).toContain("kael"); expect(result).toContain("kael");
expect(result).toContain("lyra"); expect(result).toContain("lyra");
expect(result).toContain("aldric"); expect(result).toContain("aldric");
}); });
it("unlocks zuri at 950 lifetime quests", () => { it("unlocks zuri at 500 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 950 }); const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 500 });
expect(result).toContain("zuri"); expect(result).toContain("zuri");
}); });
@@ -96,8 +96,8 @@ describe("computeUnlockedCompanionIds", () => {
it("unlocks multiple companions simultaneously", () => { it("unlocks multiple companions simultaneously", () => {
const result = computeUnlockedCompanionIds({ const result = computeUnlockedCompanionIds({
lifetimeBossesDefeated: 720, lifetimeBossesDefeated: 500,
lifetimeQuestsCompleted: 950, lifetimeQuestsCompleted: 500,
lifetimeGoldEarned: 1e18, lifetimeGoldEarned: 1e18,
prestigeCount: 10, prestigeCount: 10,
transcendenceCount: 5, transcendenceCount: 5,
+2 -2
View File
@@ -24,7 +24,7 @@ describe("DEFAULT_PROFILE_SETTINGS", () => {
expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false); expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false);
}); });
it("defaults numberFormat to suffix", () => { it("defaults numberFormat to scientific", () => {
expect(DEFAULT_PROFILE_SETTINGS.numberFormat).toBe("suffix"); expect(DEFAULT_PROFILE_SETTINGS.numberFormat).toBe("scientific");
}); });
}); });
+6
View File
@@ -2,6 +2,12 @@ packages:
- "apps/*" - "apps/*"
- "packages/*" - "packages/*"
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true
onlyBuiltDependencies: onlyBuiltDependencies:
- "@prisma/engines" - "@prisma/engines"
- "prisma" - "prisma"