generated from nhcarrigan/template
Compare commits
13 Commits
e7f0a9d537
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
e341db56af
|
|||
| 2bc47b79aa | |||
| 3afe64e48a | |||
| e7164257c5 | |||
| 1195b657a0 | |||
| b0227c1709 | |||
|
de5570b5fc
|
|||
|
133c81fefe
|
|||
|
1408e067b7
|
|||
| 666a5b2d6d | |||
|
9926e7f639
|
|||
| 6bf1ac5e7d | |||
| b48beef474 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/api",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
@@ -14,19 +14,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"@hono/node-server": "1.13.7",
|
||||
"@hono/node-server": "1.19.12",
|
||||
"@nhcarrigan/logger": "1.1.1",
|
||||
"@prisma/client": "6.5.0",
|
||||
"hono": "4.7.4",
|
||||
"hono": "4.12.11",
|
||||
"prisma": "6.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@types/node": "25.3.5",
|
||||
"@types/node": "25.5.2",
|
||||
"@vitest/coverage-v8": "3.0.8",
|
||||
"eslint": "9.22.0",
|
||||
"tsx": "4.19.3",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.8.2",
|
||||
"vitest": "3.0.8"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ model Player {
|
||||
lifetimeAchievementsUnlocked Float @default(0)
|
||||
lastLoginDate String?
|
||||
loginStreak Int @default(1)
|
||||
inGuild Boolean @default(false)
|
||||
}
|
||||
|
||||
model GameState {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
|
||||
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
||||
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
|
||||
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
||||
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
||||
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
||||
@@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
|
||||
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
||||
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
||||
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
},
|
||||
{
|
||||
condition: { amount: 18, type: "bossesDefeated" },
|
||||
description: "Defeat all 18 bosses across the first six zones.",
|
||||
description: "Defeat the 18 bosses of the mortal realms.",
|
||||
icon: "🌟",
|
||||
id: "devourer_slayer",
|
||||
name: "World Saver",
|
||||
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 65, type: "equipmentOwned" },
|
||||
description: "Own all 65 pieces of equipment.",
|
||||
condition: { amount: 78, type: "equipmentOwned" },
|
||||
description: "Own all 78 pieces of equipment.",
|
||||
icon: "🛡️",
|
||||
id: "fully_equipped",
|
||||
name: "Fully Equipped",
|
||||
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
icon: "☄️",
|
||||
id: "click_deity",
|
||||
name: "Click Deity",
|
||||
reward: { crystals: 5000 },
|
||||
reward: { crystals: 15_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Endgame gold milestones
|
||||
@@ -269,6 +269,33 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
reward: { crystals: 50_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1e30, type: "totalGoldEarned" },
|
||||
description: "Earn 1 nonillion gold in total.",
|
||||
icon: "🌌",
|
||||
id: "cosmic_wealthy",
|
||||
name: "Cosmic Wealthy",
|
||||
reward: { crystals: 100_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1e60, type: "totalGoldEarned" },
|
||||
description: "Earn a vigintillion gold in total.",
|
||||
icon: "♾️",
|
||||
id: "infinite_hoarder",
|
||||
name: "Infinite Hoarder",
|
||||
reward: { crystals: 250_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1e90, type: "totalGoldEarned" },
|
||||
description: "Earn a trigintillion gold in total.",
|
||||
icon: "🔮",
|
||||
id: "omniversal_tycoon",
|
||||
name: "Omniversal Tycoon",
|
||||
reward: { crystals: 1_000_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher quest milestones
|
||||
{
|
||||
condition: { amount: 30, type: "questsCompleted" },
|
||||
@@ -289,8 +316,26 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 95, type: "questsCompleted" },
|
||||
description: "Complete all 95 quests across the known multiverse.",
|
||||
condition: { amount: 75, type: "questsCompleted" },
|
||||
description: "Complete 75 quests.",
|
||||
icon: "🌠",
|
||||
id: "quest_hero",
|
||||
name: "Quest Hero",
|
||||
reward: { crystals: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100, type: "questsCompleted" },
|
||||
description: "Complete 100 quests.",
|
||||
icon: "💫",
|
||||
id: "quest_legend",
|
||||
name: "Quest Legend",
|
||||
reward: { crystals: 15_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 122, type: "questsCompleted" },
|
||||
description: "Complete all 122 quests across the known multiverse.",
|
||||
icon: "🌌",
|
||||
id: "quest_eternal",
|
||||
name: "Quest Eternal",
|
||||
@@ -316,6 +361,15 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
reward: { crystals: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 50, type: "bossesDefeated" },
|
||||
description: "Defeat 50 bosses.",
|
||||
icon: "⚡",
|
||||
id: "boss_legend",
|
||||
name: "Legendary Vanquisher",
|
||||
reward: { crystals: 15_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 72, type: "bossesDefeated" },
|
||||
description: "Defeat all 72 bosses across every plane of existence.",
|
||||
@@ -351,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
icon: "💫",
|
||||
id: "prestige_master",
|
||||
name: "Master of Cycles",
|
||||
reward: { crystals: 5000 },
|
||||
reward: { crystals: 15_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
@@ -360,7 +414,43 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
icon: "🌠",
|
||||
id: "prestige_legend",
|
||||
name: "Legend of Eternity",
|
||||
reward: { crystals: 25_000 },
|
||||
reward: { crystals: 75_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 50, type: "prestigeCount" },
|
||||
description: "Prestige 50 times.",
|
||||
icon: "✨",
|
||||
id: "prestige_transcendent",
|
||||
name: "Transcendent",
|
||||
reward: { runestones: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100, type: "prestigeCount" },
|
||||
description: "Prestige 100 times.",
|
||||
icon: "💎",
|
||||
id: "prestige_eternal",
|
||||
name: "Eternal Looper",
|
||||
reward: { runestones: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 150, type: "prestigeCount" },
|
||||
description: "Prestige 150 times.",
|
||||
icon: "🌟",
|
||||
id: "prestige_immortal",
|
||||
name: "Immortal Cycler",
|
||||
reward: { runestones: 2000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 200, type: "prestigeCount" },
|
||||
description: "Prestige 200 times.",
|
||||
icon: "👑",
|
||||
id: "prestige_absolute",
|
||||
name: "Absolute Champion",
|
||||
reward: { runestones: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,12 +21,12 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
baseCost: 100,
|
||||
baseCost: 65,
|
||||
class: "warrior",
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
essencePerSecond: 0,
|
||||
goldPerSecond: 0.5,
|
||||
goldPerSecond: 0.7,
|
||||
id: "militia",
|
||||
level: 2,
|
||||
name: "Militia",
|
||||
@@ -129,7 +129,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 2_600_000_000,
|
||||
baseCost: 2_850_000_000,
|
||||
class: "mage",
|
||||
combatPower: 13_000,
|
||||
count: 0,
|
||||
@@ -141,7 +141,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 11_000_000_000,
|
||||
baseCost: 13_500_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 28_000,
|
||||
count: 0,
|
||||
@@ -153,7 +153,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 47_000_000_000,
|
||||
baseCost: 64_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 60_000,
|
||||
count: 0,
|
||||
@@ -165,7 +165,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 200_000_000_000,
|
||||
baseCost: 300_000_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 130_000,
|
||||
count: 0,
|
||||
@@ -177,7 +177,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_400_000_000_000,
|
||||
baseCost: 1_800_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 400_000,
|
||||
count: 0,
|
||||
|
||||
+130
-130
@@ -12,7 +12,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Verdant Vale ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 1,
|
||||
crystalReward: 0,
|
||||
crystalReward: 5,
|
||||
currentHp: 1000,
|
||||
damagePerSecond: 5,
|
||||
description:
|
||||
@@ -122,7 +122,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 20,
|
||||
crystalReward: 700,
|
||||
crystalReward: 1500,
|
||||
currentHp: 6_000_000,
|
||||
damagePerSecond: 1200,
|
||||
description:
|
||||
@@ -140,7 +140,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
},
|
||||
{
|
||||
bountyRunestones: 25,
|
||||
crystalReward: 1500,
|
||||
crystalReward: 3000,
|
||||
currentHp: 12_000_000,
|
||||
damagePerSecond: 2400,
|
||||
description:
|
||||
@@ -158,7 +158,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
},
|
||||
{
|
||||
bountyRunestones: 30,
|
||||
crystalReward: 3000,
|
||||
crystalReward: 6000,
|
||||
currentHp: 20_000_000,
|
||||
damagePerSecond: 4000,
|
||||
description:
|
||||
@@ -226,7 +226,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
name: "The Void Titan",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "dark_templar_1" ],
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
// ── Volcanic Depths ───────────────────────────────────────────────────────
|
||||
@@ -353,14 +353,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "seraph_guardian",
|
||||
maxHp: 500_000_000,
|
||||
name: "The Seraph Guardian",
|
||||
prestigeRequirement: 6,
|
||||
prestigeRequirement: 1,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "click_4" ],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 40,
|
||||
crystalReward: 40_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 2_000_000_000,
|
||||
damagePerSecond: 120_000,
|
||||
description:
|
||||
@@ -371,14 +371,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "fallen_archangel",
|
||||
maxHp: 2_000_000_000,
|
||||
name: "The Fallen Archangel",
|
||||
prestigeRequirement: 7,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 50,
|
||||
crystalReward: 100_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 8_000_000_000,
|
||||
damagePerSecond: 350_000,
|
||||
description:
|
||||
@@ -389,14 +389,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "divine_judge",
|
||||
maxHp: 8_000_000_000,
|
||||
name: "The Divine Judge",
|
||||
prestigeRequirement: 8,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "divine_covenant" ],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 60,
|
||||
crystalReward: 300_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 30_000_000_000,
|
||||
damagePerSecond: 1_000_000,
|
||||
description:
|
||||
@@ -407,14 +407,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "celestial_titan",
|
||||
maxHp: 30_000_000_000,
|
||||
name: "The Celestial Titan",
|
||||
prestigeRequirement: 9,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 75,
|
||||
crystalReward: 800_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 100_000_000_000,
|
||||
damagePerSecond: 3_000_000,
|
||||
description:
|
||||
@@ -425,7 +425,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_first_light",
|
||||
maxHp: 100_000_000_000,
|
||||
name: "The First Light",
|
||||
prestigeRequirement: 10,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -433,7 +433,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Abyssal Trench ────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 40,
|
||||
crystalReward: 1_500_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 250_000_000_000,
|
||||
damagePerSecond: 5_000_000,
|
||||
description:
|
||||
@@ -444,14 +444,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "depth_leviathan",
|
||||
maxHp: 250_000_000_000,
|
||||
name: "The Depth Leviathan",
|
||||
prestigeRequirement: 9,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 55,
|
||||
crystalReward: 4_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 1_000_000_000_000,
|
||||
damagePerSecond: 15_000_000,
|
||||
description:
|
||||
@@ -462,14 +462,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "kraken_elder",
|
||||
maxHp: 1_000_000_000_000,
|
||||
name: "The Elder Kraken",
|
||||
prestigeRequirement: 10,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "abyssal_pact" ],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 70,
|
||||
crystalReward: 12_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 4_000_000_000_000,
|
||||
damagePerSecond: 50_000_000,
|
||||
description:
|
||||
@@ -480,14 +480,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "abyssal_colossus",
|
||||
maxHp: 4_000_000_000_000,
|
||||
name: "The Abyssal Colossus",
|
||||
prestigeRequirement: 11,
|
||||
prestigeRequirement: 2,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 85,
|
||||
crystalReward: 40_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 15_000_000_000_000,
|
||||
damagePerSecond: 150_000_000,
|
||||
description:
|
||||
@@ -498,14 +498,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_deep_one",
|
||||
maxHp: 15_000_000_000_000,
|
||||
name: "The Deep One",
|
||||
prestigeRequirement: 12,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "global_4" ],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 100,
|
||||
crystalReward: 150_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 50_000_000_000_000,
|
||||
damagePerSecond: 500_000_000,
|
||||
description:
|
||||
@@ -516,7 +516,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "elder_abomination",
|
||||
maxHp: 50_000_000_000_000,
|
||||
name: "The Elder Abomination",
|
||||
prestigeRequirement: 13,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -524,7 +524,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Infernal Court ────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 55,
|
||||
crystalReward: 350_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 120_000_000_000_000,
|
||||
damagePerSecond: 800_000_000,
|
||||
description:
|
||||
@@ -535,14 +535,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "demon_prince",
|
||||
maxHp: 120_000_000_000_000,
|
||||
name: "The Demon Prince",
|
||||
prestigeRequirement: 12,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 70,
|
||||
crystalReward: 1_000_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 500_000_000_000_000,
|
||||
damagePerSecond: 2_500_000_000,
|
||||
description:
|
||||
@@ -553,14 +553,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "hellfire_titan",
|
||||
maxHp: 500_000_000_000_000,
|
||||
name: "The Hellfire Titan",
|
||||
prestigeRequirement: 13,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "celestial_mandate" ],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 90,
|
||||
crystalReward: 3_000_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 2_000_000_000_000_000,
|
||||
damagePerSecond: 8_000_000_000,
|
||||
description:
|
||||
@@ -571,14 +571,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "lord_of_sin",
|
||||
maxHp: 2_000_000_000_000_000,
|
||||
name: "The Lord of Sin",
|
||||
prestigeRequirement: 14,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 110,
|
||||
crystalReward: 10_000_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 6_000_000_000_000_000,
|
||||
damagePerSecond: 25_000_000_000,
|
||||
description:
|
||||
@@ -589,14 +589,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "infernal_sovereign",
|
||||
maxHp: 6_000_000_000_000_000,
|
||||
name: "The Infernal Sovereign",
|
||||
prestigeRequirement: 15,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "click_5" ],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 135,
|
||||
crystalReward: 30_000_000_000,
|
||||
crystalReward: 0,
|
||||
currentHp: 8_000_000_000_000_000,
|
||||
damagePerSecond: 80_000_000_000,
|
||||
description:
|
||||
@@ -607,7 +607,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_fallen",
|
||||
maxHp: 8_000_000_000_000_000,
|
||||
name: "The Fallen",
|
||||
prestigeRequirement: 16,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "infernal_court",
|
||||
@@ -615,7 +615,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Crystalline Spire ─────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 70,
|
||||
crystalReward: 8e10,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e16,
|
||||
damagePerSecond: 120_000_000_000,
|
||||
description:
|
||||
@@ -626,14 +626,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "prism_golem",
|
||||
maxHp: 2e16,
|
||||
name: "The Prism Golem",
|
||||
prestigeRequirement: 15,
|
||||
prestigeRequirement: 3,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "crystal_sage_1" ],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 90,
|
||||
crystalReward: 3e11,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e16,
|
||||
damagePerSecond: 4e11,
|
||||
description:
|
||||
@@ -644,14 +644,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "crystal_drake",
|
||||
maxHp: 8e16,
|
||||
name: "The Crystal Drake",
|
||||
prestigeRequirement: 16,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "void_ascendancy" ],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 115,
|
||||
crystalReward: 1e12,
|
||||
crystalReward: 0,
|
||||
currentHp: 3e17,
|
||||
damagePerSecond: 1.2e12,
|
||||
description:
|
||||
@@ -662,14 +662,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_faceted",
|
||||
maxHp: 3e17,
|
||||
name: "The Faceted",
|
||||
prestigeRequirement: 17,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "void_sentinel_1" ],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 140,
|
||||
crystalReward: 4e12,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e18,
|
||||
damagePerSecond: 4e12,
|
||||
description:
|
||||
@@ -680,14 +680,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "diamond_colossus",
|
||||
maxHp: 1e18,
|
||||
name: "The Diamond Colossus",
|
||||
prestigeRequirement: 18,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "eternal_champion_1" ],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 175,
|
||||
crystalReward: 1.5e13,
|
||||
crystalReward: 0,
|
||||
currentHp: 4e18,
|
||||
damagePerSecond: 1.5e13,
|
||||
description:
|
||||
@@ -698,15 +698,15 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "crystal_sovereign",
|
||||
maxHp: 4e18,
|
||||
name: "The Crystal Sovereign",
|
||||
prestigeRequirement: 19,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "cosmos_knight_1" ],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
// ── Void Sanctum ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 90,
|
||||
crystalReward: 4e13,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e19,
|
||||
damagePerSecond: 4e13,
|
||||
description:
|
||||
@@ -717,14 +717,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "void_herald",
|
||||
maxHp: 1e19,
|
||||
name: "The Void Herald",
|
||||
prestigeRequirement: 18,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "seraph_knight_1" ],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 115,
|
||||
crystalReward: 1.5e14,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e19,
|
||||
damagePerSecond: 1.5e14,
|
||||
description:
|
||||
@@ -735,14 +735,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "eternal_shade",
|
||||
maxHp: 5e19,
|
||||
name: "The Eternal Shade",
|
||||
prestigeRequirement: 19,
|
||||
prestigeRequirement: 4,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "divine_harmony" ],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 145,
|
||||
crystalReward: 5e14,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e20,
|
||||
damagePerSecond: 5e14,
|
||||
description:
|
||||
@@ -753,14 +753,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_unmaker",
|
||||
maxHp: 2e20,
|
||||
name: "The Unmaker",
|
||||
prestigeRequirement: 20,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "abyss_diver_1" ],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 180,
|
||||
crystalReward: 2e15,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e20,
|
||||
damagePerSecond: 2e15,
|
||||
description:
|
||||
@@ -771,14 +771,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "void_progenitor",
|
||||
maxHp: 8e20,
|
||||
name: "The Void Progenitor",
|
||||
prestigeRequirement: 21,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 225,
|
||||
crystalReward: 8e15,
|
||||
crystalReward: 0,
|
||||
currentHp: 3e21,
|
||||
damagePerSecond: 8e15,
|
||||
description:
|
||||
@@ -789,15 +789,15 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "void_emperor",
|
||||
maxHp: 3e21,
|
||||
name: "The Void Emperor",
|
||||
prestigeRequirement: 22,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "infernal_warden_1" ],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
// ── Eternal Throne ────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 115,
|
||||
crystalReward: 2e16,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e22,
|
||||
damagePerSecond: 2e16,
|
||||
description:
|
||||
@@ -808,14 +808,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "throne_warden",
|
||||
maxHp: 1e22,
|
||||
name: "The Throne Warden",
|
||||
prestigeRequirement: 21,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "infinity_ranger_1" ],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 150,
|
||||
crystalReward: 8e16,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e22,
|
||||
damagePerSecond: 8e16,
|
||||
description:
|
||||
@@ -826,14 +826,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "eternal_knight",
|
||||
maxHp: 5e22,
|
||||
name: "The Eternal Knight",
|
||||
prestigeRequirement: 22,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "infernal_fury" ],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 190,
|
||||
crystalReward: 3e17,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e23,
|
||||
damagePerSecond: 3e17,
|
||||
description:
|
||||
@@ -844,14 +844,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_undying",
|
||||
maxHp: 2e23,
|
||||
name: "The Undying",
|
||||
prestigeRequirement: 23,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "reality_warden_1" ],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 235,
|
||||
crystalReward: 1.2e18,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e23,
|
||||
damagePerSecond: 1.2e18,
|
||||
description:
|
||||
@@ -862,14 +862,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "apex_sovereign",
|
||||
maxHp: 8e23,
|
||||
name: "The Apex Sovereign",
|
||||
prestigeRequirement: 24,
|
||||
prestigeRequirement: 5,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 295,
|
||||
crystalReward: 5e18,
|
||||
crystalReward: 0,
|
||||
currentHp: 3e24,
|
||||
damagePerSecond: 5e18,
|
||||
description:
|
||||
@@ -880,7 +880,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_apex",
|
||||
maxHp: 3e24,
|
||||
name: "The Apex",
|
||||
prestigeRequirement: 25,
|
||||
prestigeRequirement: 6,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "eternal_throne",
|
||||
@@ -888,7 +888,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Primordial Chaos ──────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 150,
|
||||
crystalReward: 2e20,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e26,
|
||||
damagePerSecond: 2e20,
|
||||
description:
|
||||
@@ -899,14 +899,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "chaos_wyrm",
|
||||
maxHp: 1e26,
|
||||
name: "The Chaos Wyrm",
|
||||
prestigeRequirement: 26,
|
||||
prestigeRequirement: 6,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 200,
|
||||
crystalReward: 8e21,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e27,
|
||||
damagePerSecond: 8e21,
|
||||
description:
|
||||
@@ -917,14 +917,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "creation_engine",
|
||||
maxHp: 5e27,
|
||||
name: "The Creation Engine",
|
||||
prestigeRequirement: 27,
|
||||
prestigeRequirement: 6,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "aether_weaver_1" ],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 265,
|
||||
crystalReward: 4e23,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e29,
|
||||
damagePerSecond: 4e23,
|
||||
description:
|
||||
@@ -935,14 +935,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "entropy_avatar",
|
||||
maxHp: 2e29,
|
||||
name: "The Entropy Avatar",
|
||||
prestigeRequirement: 29,
|
||||
prestigeRequirement: 7,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 350,
|
||||
crystalReward: 2e25,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e30,
|
||||
damagePerSecond: 2e25,
|
||||
description:
|
||||
@@ -953,7 +953,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "primordial_titan",
|
||||
maxHp: 8e30,
|
||||
name: "The Primordial Titan",
|
||||
prestigeRequirement: 31,
|
||||
prestigeRequirement: 7,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -961,7 +961,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Infinite Expanse ──────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 200,
|
||||
crystalReward: 8e27,
|
||||
crystalReward: 0,
|
||||
currentHp: 3e33,
|
||||
damagePerSecond: 8e27,
|
||||
description:
|
||||
@@ -972,15 +972,15 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "expanse_drifter",
|
||||
maxHp: 3e33,
|
||||
name: "The Expanse Drifter",
|
||||
prestigeRequirement: 33,
|
||||
prestigeRequirement: 8,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "titan_warrior_1" ],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 265,
|
||||
crystalReward: 3e31,
|
||||
currentHp: 1e37,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e35,
|
||||
damagePerSecond: 3e31,
|
||||
description:
|
||||
"A creature as wide as the observable universe — which, in the Expanse, is not a helpful measurement. It is simply everywhere the horizon is, which in this place is everywhere.",
|
||||
@@ -988,17 +988,17 @@ export const defaultBosses: Array<Boss> = [
|
||||
essenceReward: 1e34,
|
||||
goldReward: 1e38,
|
||||
id: "horizon_beast",
|
||||
maxHp: 1e37,
|
||||
maxHp: 2e35,
|
||||
name: "The Horizon Beast",
|
||||
prestigeRequirement: 35,
|
||||
prestigeRequirement: 8,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "oblivion_paladin_1" ],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 350,
|
||||
crystalReward: 1e35,
|
||||
currentHp: 5e40,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e37,
|
||||
damagePerSecond: 1e35,
|
||||
description:
|
||||
"A self-replicating intelligence that has filled the Expanse with copies of itself. Every copy has the same purpose: to be the last thing in the Expanse. Your guild will need to convince all of them otherwise.",
|
||||
@@ -1006,17 +1006,17 @@ export const defaultBosses: Array<Boss> = [
|
||||
essenceReward: 5e37,
|
||||
goldReward: 5e41,
|
||||
id: "infinity_construct",
|
||||
maxHp: 5e40,
|
||||
maxHp: 5e37,
|
||||
name: "The Infinity Construct",
|
||||
prestigeRequirement: 37,
|
||||
prestigeRequirement: 8,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 465,
|
||||
crystalReward: 5e38,
|
||||
currentHp: 2e44,
|
||||
crystalReward: 0,
|
||||
currentHp: 3e39,
|
||||
damagePerSecond: 5e38,
|
||||
description:
|
||||
"The thing that claims the Infinite Expanse as its territory — which, given the name of the place, is an ambitious claim. It enforces this claim with power that has had infinite space to accumulate.",
|
||||
@@ -1024,9 +1024,9 @@ export const defaultBosses: Array<Boss> = [
|
||||
essenceReward: 2e41,
|
||||
goldReward: 2e45,
|
||||
id: "expanse_sovereign",
|
||||
maxHp: 2e44,
|
||||
maxHp: 3e39,
|
||||
name: "The Expanse Sovereign",
|
||||
prestigeRequirement: 39,
|
||||
prestigeRequirement: 9,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -1034,7 +1034,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Reality Forge ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 265,
|
||||
crystalReward: 2e42,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e47,
|
||||
damagePerSecond: 2e42,
|
||||
description:
|
||||
@@ -1045,14 +1045,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "forge_guardian",
|
||||
maxHp: 8e47,
|
||||
name: "The Forge Guardian",
|
||||
prestigeRequirement: 41,
|
||||
prestigeRequirement: 9,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "nexus_sage_1" ],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 350,
|
||||
crystalReward: 1e47,
|
||||
crystalReward: 0,
|
||||
currentHp: 4e52,
|
||||
damagePerSecond: 1e47,
|
||||
description:
|
||||
@@ -1063,14 +1063,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "reality_shaper",
|
||||
maxHp: 4e52,
|
||||
name: "The Reality Shaper",
|
||||
prestigeRequirement: 44,
|
||||
prestigeRequirement: 10,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 465,
|
||||
crystalReward: 6e51,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e57,
|
||||
damagePerSecond: 6e51,
|
||||
description:
|
||||
@@ -1081,14 +1081,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "creation_prime",
|
||||
maxHp: 2e57,
|
||||
name: "The Creation Prime",
|
||||
prestigeRequirement: 47,
|
||||
prestigeRequirement: 11,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 615,
|
||||
crystalReward: 2e56,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e61,
|
||||
damagePerSecond: 2e56,
|
||||
description:
|
||||
@@ -1099,7 +1099,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "reality_architect",
|
||||
maxHp: 8e61,
|
||||
name: "The Reality Architect",
|
||||
prestigeRequirement: 49,
|
||||
prestigeRequirement: 11,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "reality_forge",
|
||||
@@ -1107,7 +1107,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 350,
|
||||
crystalReward: 1e60,
|
||||
crystalReward: 0,
|
||||
currentHp: 4e65,
|
||||
damagePerSecond: 1e60,
|
||||
description:
|
||||
@@ -1118,14 +1118,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "storm_colossus",
|
||||
maxHp: 4e65,
|
||||
name: "The Storm Colossus",
|
||||
prestigeRequirement: 51,
|
||||
prestigeRequirement: 12,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 465,
|
||||
crystalReward: 6e65,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e71,
|
||||
damagePerSecond: 6e65,
|
||||
description:
|
||||
@@ -1136,14 +1136,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "force_prime",
|
||||
maxHp: 2e71,
|
||||
name: "The Force Prime",
|
||||
prestigeRequirement: 54,
|
||||
prestigeRequirement: 12,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 615,
|
||||
crystalReward: 3e71,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e77,
|
||||
damagePerSecond: 3e71,
|
||||
description:
|
||||
@@ -1154,14 +1154,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "maelstrom_god",
|
||||
maxHp: 1e77,
|
||||
name: "The Maelstrom God",
|
||||
prestigeRequirement: 57,
|
||||
prestigeRequirement: 13,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "transcendent_rogue_1" ],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 815,
|
||||
crystalReward: 1e77,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e82,
|
||||
damagePerSecond: 1e77,
|
||||
description:
|
||||
@@ -1172,7 +1172,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "cosmic_annihilator",
|
||||
maxHp: 5e82,
|
||||
name: "The Cosmic Annihilator",
|
||||
prestigeRequirement: 59,
|
||||
prestigeRequirement: 13,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -1180,7 +1180,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── Primeval Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 465,
|
||||
crystalReward: 5e82,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e88,
|
||||
damagePerSecond: 5e82,
|
||||
description:
|
||||
@@ -1191,14 +1191,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "ancient_sentinel",
|
||||
maxHp: 2e88,
|
||||
name: "The Ancient Sentinel",
|
||||
prestigeRequirement: 61,
|
||||
prestigeRequirement: 14,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "astral_sovereign_1" ],
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 615,
|
||||
crystalReward: 3e89,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e95,
|
||||
damagePerSecond: 3e89,
|
||||
description:
|
||||
@@ -1209,14 +1209,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "time_elder",
|
||||
maxHp: 1e95,
|
||||
name: "The Time Elder",
|
||||
prestigeRequirement: 65,
|
||||
prestigeRequirement: 15,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 815,
|
||||
crystalReward: 2e96,
|
||||
crystalReward: 0,
|
||||
currentHp: 8e101,
|
||||
damagePerSecond: 2e96,
|
||||
description:
|
||||
@@ -1227,14 +1227,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "origin_beast",
|
||||
maxHp: 8e101,
|
||||
name: "The Origin Beast",
|
||||
prestigeRequirement: 69,
|
||||
prestigeRequirement: 16,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 1080,
|
||||
crystalReward: 1e103,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e108,
|
||||
damagePerSecond: 1e103,
|
||||
description:
|
||||
@@ -1245,7 +1245,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "primeval_god",
|
||||
maxHp: 5e108,
|
||||
name: "The Primeval God",
|
||||
prestigeRequirement: 74,
|
||||
prestigeRequirement: 17,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -1253,7 +1253,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
// ── The Absolute ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bountyRunestones: 615,
|
||||
crystalReward: 5e110,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e116,
|
||||
damagePerSecond: 5e110,
|
||||
description:
|
||||
@@ -1264,14 +1264,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "absolute_herald",
|
||||
maxHp: 2e116,
|
||||
name: "The Absolute Herald",
|
||||
prestigeRequirement: 76,
|
||||
prestigeRequirement: 17,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "primordial_mage_1" ],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 815,
|
||||
crystalReward: 3e119,
|
||||
crystalReward: 0,
|
||||
currentHp: 1e125,
|
||||
damagePerSecond: 3e119,
|
||||
description:
|
||||
@@ -1282,14 +1282,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "void_convergence",
|
||||
maxHp: 1e125,
|
||||
name: "The Void Convergence",
|
||||
prestigeRequirement: 79,
|
||||
prestigeRequirement: 18,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 1080,
|
||||
crystalReward: 1e129,
|
||||
crystalReward: 0,
|
||||
currentHp: 5e134,
|
||||
damagePerSecond: 1e129,
|
||||
description:
|
||||
@@ -1300,14 +1300,14 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "eternal_end",
|
||||
maxHp: 5e134,
|
||||
name: "The Eternal End",
|
||||
prestigeRequirement: 83,
|
||||
prestigeRequirement: 19,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
upgradeRewards: [ "omniversal_champion_1" ],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bountyRunestones: 1430,
|
||||
crystalReward: 5e139,
|
||||
crystalReward: 0,
|
||||
currentHp: 2e145,
|
||||
damagePerSecond: 5e139,
|
||||
description:
|
||||
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
id: "the_absolute_one",
|
||||
maxHp: 2e145,
|
||||
name: "The Absolute One",
|
||||
prestigeRequirement: 88,
|
||||
prestigeRequirement: 20,
|
||||
status: "locked",
|
||||
upgradeRewards: [],
|
||||
zoneId: "the_absolute",
|
||||
|
||||
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
|
||||
target: 5000,
|
||||
type: "clicks",
|
||||
},
|
||||
// Crafting — requires materials but no zone/boss progression
|
||||
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
|
||||
{
|
||||
label: "Craft 2 recipes",
|
||||
rewardCrystals: 175,
|
||||
target: 2,
|
||||
type: "crafting",
|
||||
},
|
||||
{
|
||||
label: "Craft 3 recipes",
|
||||
rewardCrystals: 350,
|
||||
target: 3,
|
||||
type: "crafting",
|
||||
},
|
||||
// Boss defeats — requires active combat
|
||||
{
|
||||
label: "Defeat 1 boss",
|
||||
|
||||
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
type: "trinket",
|
||||
},
|
||||
{
|
||||
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 },
|
||||
bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
|
||||
description:
|
||||
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
||||
equipped: false,
|
||||
@@ -305,9 +305,9 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
type: "trinket",
|
||||
},
|
||||
{
|
||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
||||
bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 },
|
||||
description:
|
||||
"The legendary stone that grants mastery over gold and combat alike.",
|
||||
"The legendary stone that transmutes effort into wealth — every action fills the coffers.",
|
||||
equipped: false,
|
||||
id: "philosophers_stone",
|
||||
name: "Philosopher's Stone",
|
||||
@@ -695,9 +695,171 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
setId: "eternal_throne",
|
||||
type: "trinket",
|
||||
},
|
||||
// ── Primordial Chaos ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { goldMultiplier: 9 },
|
||||
description:
|
||||
"The Primordial Titan's carapace — formed before the concept of armour existed. It simply is what armour aspires to be.",
|
||||
equipped: false,
|
||||
id: "chaos_mantle",
|
||||
name: "The Chaos Mantle",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "primordial_chaos",
|
||||
type: "armour",
|
||||
},
|
||||
{
|
||||
bonus: { clickMultiplier: 5, combatMultiplier: 2, goldMultiplier: 2.5 },
|
||||
description:
|
||||
"The crystallised core of the Titan itself — the first stable thing to emerge from chaos. It radiates in every direction simultaneously.",
|
||||
equipped: false,
|
||||
id: "titan_core",
|
||||
name: "The Titan Core",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "primordial_chaos",
|
||||
type: "trinket",
|
||||
},
|
||||
// ── Infinite Expanse ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 14 },
|
||||
description:
|
||||
"Forged from the Expanse Sovereign's own reach — a blade that has no beginning and no end, only edge.",
|
||||
equipped: false,
|
||||
id: "expanse_blade",
|
||||
name: "The Expanse Blade",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "infinite_expanse",
|
||||
type: "weapon",
|
||||
},
|
||||
{
|
||||
bonus: { goldMultiplier: 10 },
|
||||
description:
|
||||
"A second iteration of the void's armour — the first was not enough. This one has never been tested to its limit.",
|
||||
equipped: false,
|
||||
id: "void_armour_mk2",
|
||||
name: "Void Armour Mk. II",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "infinite_expanse",
|
||||
type: "armour",
|
||||
},
|
||||
// ── Reality Forge ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 16 },
|
||||
description:
|
||||
"The Reality Architect's primary instrument — a sword that does not cut through things but rewrites what they are.",
|
||||
equipped: false,
|
||||
id: "cosmos_blade",
|
||||
name: "The Cosmos Blade",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "reality_forge",
|
||||
type: "weapon",
|
||||
},
|
||||
{
|
||||
bonus: { goldMultiplier: 12 },
|
||||
description:
|
||||
"Plated from the substance of reality itself — wearing it makes you feel slightly more real than everything around you.",
|
||||
equipped: false,
|
||||
id: "reality_plate",
|
||||
name: "The Reality Plate",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "reality_forge",
|
||||
type: "armour",
|
||||
},
|
||||
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 18 },
|
||||
description:
|
||||
"Torn from the eye of the Cosmic Annihilator — a weapon that carries the force of an ending universe in every swing.",
|
||||
equipped: false,
|
||||
id: "maelstrom_edge",
|
||||
name: "The Maelstrom Edge",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "cosmic_maelstrom",
|
||||
type: "weapon",
|
||||
},
|
||||
{
|
||||
bonus: { goldMultiplier: 14 },
|
||||
description:
|
||||
"Armour that has weathered the destruction of countless realities. It has learned not to flinch.",
|
||||
equipped: false,
|
||||
id: "cosmic_plate",
|
||||
name: "The Cosmic Plate",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "cosmic_maelstrom",
|
||||
type: "armour",
|
||||
},
|
||||
// ── Primeval Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 22 },
|
||||
description:
|
||||
"The first weapon — older than the concept of war, older than the concept of a weapon. It remembers what it was made for.",
|
||||
equipped: false,
|
||||
id: "primeval_blade",
|
||||
name: "The Primeval Blade",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "primeval_sanctum",
|
||||
type: "weapon",
|
||||
},
|
||||
{
|
||||
bonus: { goldMultiplier: 17 },
|
||||
description:
|
||||
"The shield-form of the Primeval God — absolute protection from before the concept of harm existed.",
|
||||
equipped: false,
|
||||
id: "ancient_aegis",
|
||||
name: "The Ancient Aegis",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "primeval_sanctum",
|
||||
type: "armour",
|
||||
},
|
||||
// ── The Absolute ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 28 },
|
||||
description:
|
||||
"There is no name for what this was before it became a sword. There is no name for what it is now. It ends things.",
|
||||
equipped: false,
|
||||
id: "absolute_blade",
|
||||
name: "The Absolute Blade",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "the_absolute",
|
||||
type: "weapon",
|
||||
},
|
||||
{
|
||||
bonus: { goldMultiplier: 20 },
|
||||
description:
|
||||
"Eternity given the shape of armour — it has always existed, it will always exist, and it has always protected its wearer.",
|
||||
equipped: false,
|
||||
id: "eternity_plate",
|
||||
name: "The Eternity Plate",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "the_absolute",
|
||||
type: "armour",
|
||||
},
|
||||
{
|
||||
bonus: { clickMultiplier: 6, combatMultiplier: 3, goldMultiplier: 3 },
|
||||
description:
|
||||
"The heart of everything — a thing so fundamental that its removal from the Absolute One ended all things, briefly. Briefly.",
|
||||
equipped: false,
|
||||
id: "omniversal_core",
|
||||
name: "The Omniversal Core",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "the_absolute",
|
||||
type: "trinket",
|
||||
},
|
||||
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
||||
{
|
||||
bonus: { clickMultiplier: 3 },
|
||||
bonus: { clickMultiplier: 4.25 },
|
||||
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
||||
description:
|
||||
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
||||
@@ -721,7 +883,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
type: "armour",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 7 },
|
||||
bonus: { combatMultiplier: 10.5 },
|
||||
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
||||
description:
|
||||
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
||||
@@ -745,7 +907,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
type: "trinket",
|
||||
},
|
||||
{
|
||||
bonus: { goldMultiplier: 4.75 },
|
||||
bonus: { goldMultiplier: 7.5 },
|
||||
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
||||
description:
|
||||
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
||||
@@ -757,7 +919,7 @@ export const defaultEquipment: Array<Equipment> = [
|
||||
type: "armour",
|
||||
},
|
||||
{
|
||||
bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
|
||||
bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 },
|
||||
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
||||
description:
|
||||
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
||||
|
||||
+144
-144
File diff suppressed because it is too large
Load Diff
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
||||
id: "income_10",
|
||||
multiplier: 200,
|
||||
name: "Eternal Rune I",
|
||||
runestonesCost: 30_000,
|
||||
runestonesCost: 15_000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
||||
id: "income_11",
|
||||
multiplier: 500,
|
||||
name: "Eternal Rune II",
|
||||
runestonesCost: 80_000,
|
||||
runestonesCost: 35_000,
|
||||
},
|
||||
// ── Click Power ───────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
+452
-173
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.08 },
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
||||
id: "elder_bark_shield",
|
||||
@@ -75,7 +75,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description:
|
||||
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
|
||||
id: "void_fragment_amulet",
|
||||
@@ -101,7 +101,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
description:
|
||||
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
||||
id: "cursed_focus",
|
||||
@@ -127,7 +127,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.12 },
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
||||
id: "elemental_ore_ingot",
|
||||
@@ -193,7 +193,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description:
|
||||
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
||||
id: "pressure_forged_core",
|
||||
@@ -231,7 +231,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.15 },
|
||||
bonus: { type: "essence_income", value: 1.2 },
|
||||
description:
|
||||
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
|
||||
id: "soul_bound_catalyst",
|
||||
@@ -271,7 +271,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.18 },
|
||||
bonus: { type: "combat_power", value: 1.28 },
|
||||
description:
|
||||
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
||||
id: "null_field_generator",
|
||||
@@ -309,7 +309,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description:
|
||||
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
||||
id: "eternity_bound_ring",
|
||||
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 13: primordial_chaos
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.2 },
|
||||
bonus: { type: "click_power", value: 1.22 },
|
||||
description:
|
||||
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
||||
id: "chaos_lens",
|
||||
@@ -375,7 +375,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.22 },
|
||||
bonus: { type: "combat_power", value: 1.35 },
|
||||
description:
|
||||
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
||||
id: "reality_ingot",
|
||||
@@ -387,7 +387,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.22 },
|
||||
bonus: { type: "click_power", value: 1.25 },
|
||||
description:
|
||||
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
||||
id: "universe_seed",
|
||||
@@ -427,7 +427,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
bonus: { type: "combat_power", value: 1.4 },
|
||||
description:
|
||||
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
||||
id: "ancient_memory_array",
|
||||
@@ -439,7 +439,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.25 },
|
||||
bonus: { type: "click_power", value: 1.28 },
|
||||
description:
|
||||
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
||||
id: "first_artefact",
|
||||
@@ -493,7 +493,20 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.4 },
|
||||
bonus: { type: "click_power", value: 1.38 },
|
||||
description:
|
||||
"A primeval relic submerged at the absolute boundary of existence alongside omega crystals and boundary shards — the first and last thing, unified. Every action your guild takes through it is simultaneously the most ancient and most final thing that has ever happened. It does not miss.",
|
||||
id: "primal_omega_lens",
|
||||
name: "Primal Omega Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "primeval_relic", quantity: 2 },
|
||||
{ materialId: "boundary_shard", quantity: 4 },
|
||||
{ materialId: "omega_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.65 },
|
||||
description:
|
||||
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
|
||||
id: "eternal_omega",
|
||||
@@ -508,6 +521,18 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
},
|
||||
|
||||
// Zone 18: the_absolute
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.3 },
|
||||
description:
|
||||
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
|
||||
id: "absolute_focus",
|
||||
name: "Absolute Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "absolute_fragment", quantity: 8 },
|
||||
{ materialId: "omega_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
description:
|
||||
@@ -521,7 +546,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
bonus: { type: "combat_power", value: 1.55 },
|
||||
description:
|
||||
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
||||
id: "omega_convergence",
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
/**
|
||||
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
|
||||
*/
|
||||
export const currentSchemaVersion = 1;
|
||||
export const currentSchemaVersion = 2;
|
||||
|
||||
@@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Income multipliers ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "income",
|
||||
cost: 5,
|
||||
cost: 2,
|
||||
description:
|
||||
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
||||
id: "echo_income_1",
|
||||
@@ -20,7 +20,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 10,
|
||||
cost: 4,
|
||||
description:
|
||||
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
||||
id: "echo_income_2",
|
||||
@@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 20,
|
||||
cost: 8,
|
||||
description:
|
||||
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
||||
id: "echo_income_3",
|
||||
@@ -38,7 +38,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 40,
|
||||
cost: 16,
|
||||
description:
|
||||
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
||||
id: "echo_income_4",
|
||||
@@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 80,
|
||||
cost: 32,
|
||||
description:
|
||||
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
||||
id: "echo_income_5",
|
||||
@@ -58,7 +58,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Combat multipliers ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
cost: 5,
|
||||
cost: 2,
|
||||
description:
|
||||
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
||||
id: "echo_combat_1",
|
||||
@@ -67,7 +67,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 15,
|
||||
cost: 6,
|
||||
description:
|
||||
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
||||
id: "echo_combat_2",
|
||||
@@ -76,7 +76,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 35,
|
||||
cost: 12,
|
||||
description:
|
||||
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
||||
id: "echo_combat_3",
|
||||
@@ -87,7 +87,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Prestige threshold reductions ──────────────────────────────────────────
|
||||
{
|
||||
category: "prestige_threshold",
|
||||
cost: 8,
|
||||
cost: 3,
|
||||
description:
|
||||
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
||||
id: "echo_prestige_threshold_1",
|
||||
@@ -96,7 +96,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "prestige_threshold",
|
||||
cost: 20,
|
||||
cost: 6,
|
||||
description:
|
||||
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
||||
id: "echo_prestige_threshold_2",
|
||||
@@ -107,7 +107,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
||||
{
|
||||
category: "prestige_runestones",
|
||||
cost: 8,
|
||||
cost: 3,
|
||||
description:
|
||||
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
||||
id: "echo_prestige_runestones_1",
|
||||
@@ -116,7 +116,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "prestige_runestones",
|
||||
cost: 20,
|
||||
cost: 6,
|
||||
description:
|
||||
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
||||
id: "echo_prestige_runestones_2",
|
||||
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 50,
|
||||
cost: 15,
|
||||
description:
|
||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||
id: "echo_meta_1",
|
||||
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 150,
|
||||
cost: 45,
|
||||
description:
|
||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||
id: "echo_meta_2",
|
||||
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 400,
|
||||
cost: 100,
|
||||
description:
|
||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||
id: "echo_meta_3",
|
||||
|
||||
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costCrystals: 100,
|
||||
costCrystals: 50,
|
||||
costEssence: 0,
|
||||
costGold: 0,
|
||||
description:
|
||||
@@ -104,7 +104,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
description:
|
||||
"Forge partnerships with mage guilds across the realm. All income +50%.",
|
||||
id: "essence_guild",
|
||||
multiplier: 1.5,
|
||||
multiplier: 2,
|
||||
name: "Essence Guild",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
@@ -459,7 +459,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costCrystals: 10_000_000,
|
||||
costCrystals: 50_000_000,
|
||||
costEssence: 0,
|
||||
costGold: 0,
|
||||
description: "Transcend mortal limits through void energy. All income x3.",
|
||||
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
|
||||
{
|
||||
costCrystals: 3000,
|
||||
costEssence: 0,
|
||||
costGold: 0,
|
||||
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
|
||||
id: "crystal_pulse",
|
||||
multiplier: 1.5,
|
||||
name: "Crystal Pulse",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costCrystals: 20_000,
|
||||
costEssence: 0,
|
||||
costGold: 0,
|
||||
description:
|
||||
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
|
||||
id: "crystal_surge",
|
||||
multiplier: 2,
|
||||
name: "Crystal Surge",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costCrystals: 150_000,
|
||||
costEssence: 0,
|
||||
costGold: 0,
|
||||
description: "Your guild's operations are saturated with crystalline power. All income x3.",
|
||||
id: "crystal_tempest",
|
||||
multiplier: 3,
|
||||
name: "Crystal Tempest",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costCrystals: 0,
|
||||
costEssence: 5_000_000,
|
||||
|
||||
@@ -20,7 +20,9 @@ import { gameRouter } from "./routes/game.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { timersRouter } from "./routes/timers.js";
|
||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||
import { connectGateway } from "./services/gateway.js";
|
||||
import { logger } from "./services/logger.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -48,6 +50,7 @@ app.route("/transcendence", transcendenceRouter);
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
app.route("/profile", profileRouter);
|
||||
app.route("/timers", timersRouter);
|
||||
|
||||
app.get("/health", (context) => {
|
||||
return context.json({ status: "ok" });
|
||||
@@ -68,6 +71,7 @@ const port = Number(process.env.PORT ?? 3001);
|
||||
try {
|
||||
serve({ fetch: app.fetch, port: port }, () => {
|
||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||
connectGateway();
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
|
||||
@@ -35,12 +35,16 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
||||
const payload = verifyToken(token);
|
||||
context.set("discordId", payload.discordId);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"auth_middleware",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
const isExpiredToken
|
||||
= error instanceof Error && error.message === "Token has expired";
|
||||
if (!isExpiredToken) {
|
||||
void logger.error(
|
||||
"auth_middleware",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
}
|
||||
return context.json({ error: "Invalid or expired token" }, 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../services/discord.js";
|
||||
import { signToken } from "../services/jwt.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { grantElysianRole } from "../services/webhook.js";
|
||||
import type { Player } from "@elysium/types";
|
||||
|
||||
const authRouter = new Hono();
|
||||
@@ -92,6 +93,12 @@ authRouter.get("/callback", async(context) => {
|
||||
},
|
||||
});
|
||||
|
||||
const inGuild = await grantElysianRole(player.discordId);
|
||||
await prisma.player.update({
|
||||
data: { inGuild },
|
||||
where: { discordId: player.discordId },
|
||||
});
|
||||
|
||||
const jwtToken = signToken(player.discordId);
|
||||
void logger.log("info", `New player registered: ${player.discordId}`);
|
||||
void logger.metric("user_registered", 1, { discordId: player.discordId });
|
||||
@@ -104,10 +111,12 @@ authRouter.get("/callback", async(context) => {
|
||||
);
|
||||
}
|
||||
|
||||
const inGuild = await grantElysianRole(discordUser.id);
|
||||
const updated = await prisma.player.update({
|
||||
data: {
|
||||
avatar: discordUser.avatar,
|
||||
discriminator: discordUser.discriminator,
|
||||
inGuild: inGuild,
|
||||
username: discordUser.username,
|
||||
},
|
||||
where: { discordId: discordUser.id },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
computeSetBonuses,
|
||||
getActiveCompanionBonus,
|
||||
@@ -18,12 +19,31 @@ import {
|
||||
import { Hono } from "hono";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
/**
|
||||
* Computes the HMAC-SHA256 of data using the given secret.
|
||||
* @param data - The data string to sign.
|
||||
* @param secret - The HMAC secret key.
|
||||
* @returns The hex-encoded HMAC digest.
|
||||
*/
|
||||
const computeHmac = (data: string, secret: string): string => {
|
||||
return createHmac("sha256", secret).update(data).
|
||||
digest("hex");
|
||||
};
|
||||
|
||||
/**
|
||||
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||
* Must be kept in sync with prestigeCombatBase in apps/web/src/engine/tick.ts.
|
||||
*/
|
||||
const prestigeCombatBase = 4;
|
||||
|
||||
const bossRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
bossRouter.use("*", authMiddleware);
|
||||
@@ -38,8 +58,7 @@ const calculatePartyStats = (
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
|
||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count);
|
||||
|
||||
// Apply equipped weapon's combat bonus
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
@@ -199,9 +218,11 @@ bossRouter.post("/challenge", async(context) => {
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
||||
const crystalAward = boss.crystalReward * crystalMult;
|
||||
state.resources.crystals = state.resources.crystals + crystalAward;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
@@ -276,6 +297,19 @@ bossRouter.post("/challenge", async(context) => {
|
||||
continue;
|
||||
}
|
||||
zone.status = "unlocked";
|
||||
|
||||
// Unlock exploration areas for the newly unlocked zone
|
||||
for (const area of state.exploration?.areas ?? []) {
|
||||
const areaDefinition = defaultExplorations.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
@@ -317,7 +351,7 @@ bossRouter.post("/challenge", async(context) => {
|
||||
|
||||
rewards = {
|
||||
bountyRunestones: bountyRunestones,
|
||||
crystals: boss.crystalReward,
|
||||
crystals: crystalAward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
essence: boss.essenceReward,
|
||||
gold: boss.goldReward,
|
||||
@@ -357,6 +391,11 @@ bossRouter.post("/challenge", async(context) => {
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const updatedSignature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
|
||||
const { bossId } = body;
|
||||
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||
|
||||
@@ -379,6 +418,9 @@ bossRouter.post("/challenge", async(context) => {
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
if (updatedSignature !== undefined) {
|
||||
response.signature = updatedSignature;
|
||||
}
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Hono } from "hono";
|
||||
import { defaultRecipes } from "../data/recipes.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
@@ -138,6 +139,16 @@ craftRouter.post("/", async(context) => {
|
||||
state.exploration.craftedCombatMultiplier
|
||||
= updatedMultipliers.craftedCombatMultiplier;
|
||||
|
||||
if (state.dailyChallenges !== undefined) {
|
||||
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
|
||||
state.dailyChallenges,
|
||||
"crafting",
|
||||
1,
|
||||
);
|
||||
state.dailyChallenges = updatedChallenges;
|
||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||
}
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: Date.now() },
|
||||
|
||||
+411
-22
@@ -20,6 +20,7 @@ import { defaultEquipment } from "../data/equipment.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultQuests } from "../data/quests.js";
|
||||
import { defaultRecipes } from "../data/recipes.js";
|
||||
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||
import { defaultUpgrades } from "../data/upgrades.js";
|
||||
import { defaultZones } from "../data/zones.js";
|
||||
@@ -625,38 +626,406 @@ const patchBossUpgradeRewards = (state: GameState): number => {
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing adventurers to match the current defaults,
|
||||
* preserving only player-state fields (count and unlocked status).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of adventurer entries whose stats were updated.
|
||||
*/
|
||||
const patchAdventurerStats = (state: GameState): number => {
|
||||
const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => {
|
||||
return [ adventurer.id, adventurer ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedAdventurer of state.adventurers) {
|
||||
const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id);
|
||||
if (defaultAdventurer === undefined) {
|
||||
continue;
|
||||
}
|
||||
const hasChanged
|
||||
= savedAdventurer.baseCost !== defaultAdventurer.baseCost
|
||||
|| savedAdventurer.class !== defaultAdventurer.class
|
||||
|| savedAdventurer.combatPower !== defaultAdventurer.combatPower
|
||||
|| savedAdventurer.essencePerSecond !== defaultAdventurer.essencePerSecond
|
||||
|| savedAdventurer.goldPerSecond !== defaultAdventurer.goldPerSecond
|
||||
|| savedAdventurer.level !== defaultAdventurer.level
|
||||
|| savedAdventurer.name !== defaultAdventurer.name;
|
||||
savedAdventurer.baseCost = defaultAdventurer.baseCost;
|
||||
savedAdventurer.class = defaultAdventurer.class;
|
||||
savedAdventurer.combatPower = defaultAdventurer.combatPower;
|
||||
savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond;
|
||||
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
|
||||
savedAdventurer.level = defaultAdventurer.level;
|
||||
savedAdventurer.name = defaultAdventurer.name;
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing quests to match the current defaults,
|
||||
* preserving only player-state fields (status, startedAt, lastFailedAt, rewards).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of quest entries whose stats were updated.
|
||||
*/
|
||||
const patchQuestStats = (state: GameState): number => {
|
||||
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
|
||||
return [ quest.id, quest ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedQuest of state.quests) {
|
||||
const defaultQuest = defaultQuestMap.get(savedQuest.id);
|
||||
if (defaultQuest === undefined) {
|
||||
continue;
|
||||
}
|
||||
const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds);
|
||||
const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds);
|
||||
const hasChanged
|
||||
= savedQuest.name !== defaultQuest.name
|
||||
|| savedQuest.description !== defaultQuest.description
|
||||
|| savedQuest.durationSeconds !== defaultQuest.durationSeconds
|
||||
|| savedPrereqs !== defaultPrereqs
|
||||
|| savedQuest.zoneId !== defaultQuest.zoneId
|
||||
|| savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired;
|
||||
savedQuest.name = defaultQuest.name;
|
||||
savedQuest.description = defaultQuest.description;
|
||||
savedQuest.durationSeconds = defaultQuest.durationSeconds;
|
||||
savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds;
|
||||
savedQuest.zoneId = defaultQuest.zoneId;
|
||||
if (defaultQuest.combatPowerRequired !== undefined) {
|
||||
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
|
||||
}
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing bosses to match the current defaults,
|
||||
* preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of boss entries whose stats were updated.
|
||||
*/
|
||||
/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */
|
||||
const patchBossStats = (state: GameState): number => {
|
||||
const defaultBossMap = new Map(defaultBosses.map((boss) => {
|
||||
return [ boss.id, boss ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedBoss of state.bosses) {
|
||||
const defaultBoss = defaultBossMap.get(savedBoss.id);
|
||||
if (defaultBoss === undefined) {
|
||||
continue;
|
||||
}
|
||||
const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
|
||||
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
|
||||
const hasChanged
|
||||
= savedBoss.name !== defaultBoss.name
|
||||
|| savedBoss.description !== defaultBoss.description
|
||||
|| savedBoss.maxHp !== defaultBoss.maxHp
|
||||
|| savedBoss.damagePerSecond !== defaultBoss.damagePerSecond
|
||||
|| savedBoss.goldReward !== defaultBoss.goldReward
|
||||
|| savedBoss.essenceReward !== defaultBoss.essenceReward
|
||||
|| savedBoss.crystalReward !== defaultBoss.crystalReward
|
||||
|| savedRewards !== defaultRewards
|
||||
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|
||||
|| savedBoss.zoneId !== defaultBoss.zoneId
|
||||
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones;
|
||||
savedBoss.name = defaultBoss.name;
|
||||
savedBoss.description = defaultBoss.description;
|
||||
savedBoss.maxHp = defaultBoss.maxHp;
|
||||
savedBoss.damagePerSecond = defaultBoss.damagePerSecond;
|
||||
savedBoss.goldReward = defaultBoss.goldReward;
|
||||
savedBoss.essenceReward = defaultBoss.essenceReward;
|
||||
savedBoss.crystalReward = defaultBoss.crystalReward;
|
||||
savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ];
|
||||
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
|
||||
savedBoss.zoneId = defaultBoss.zoneId;
|
||||
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing zones to match the current defaults,
|
||||
* preserving only player-state fields (status).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of zone entries whose stats were updated.
|
||||
*/
|
||||
const patchZoneStats = (state: GameState): number => {
|
||||
const defaultZoneMap = new Map(defaultZones.map((zone) => {
|
||||
return [ zone.id, zone ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedZone of state.zones) {
|
||||
const defaultZone = defaultZoneMap.get(savedZone.id);
|
||||
if (defaultZone === undefined) {
|
||||
continue;
|
||||
}
|
||||
const hasChanged
|
||||
= savedZone.name !== defaultZone.name
|
||||
|| savedZone.description !== defaultZone.description
|
||||
|| savedZone.emoji !== defaultZone.emoji
|
||||
|| savedZone.unlockBossId !== defaultZone.unlockBossId
|
||||
|| savedZone.unlockQuestId !== defaultZone.unlockQuestId;
|
||||
savedZone.name = defaultZone.name;
|
||||
savedZone.description = defaultZone.description;
|
||||
savedZone.emoji = defaultZone.emoji;
|
||||
savedZone.unlockBossId = defaultZone.unlockBossId;
|
||||
savedZone.unlockQuestId = defaultZone.unlockQuestId;
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing upgrades to match the current defaults,
|
||||
* preserving only player-state fields (purchased, unlocked).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of upgrade entries whose stats were updated.
|
||||
*/
|
||||
/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */
|
||||
const patchUpgradeStats = (state: GameState): number => {
|
||||
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
|
||||
return [ upgrade.id, upgrade ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedUpgrade of state.upgrades) {
|
||||
const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id);
|
||||
if (defaultUpgrade === undefined) {
|
||||
continue;
|
||||
}
|
||||
const hasChanged
|
||||
= savedUpgrade.name !== defaultUpgrade.name
|
||||
|| savedUpgrade.description !== defaultUpgrade.description
|
||||
|| savedUpgrade.target !== defaultUpgrade.target
|
||||
|| savedUpgrade.adventurerId !== defaultUpgrade.adventurerId
|
||||
|| savedUpgrade.multiplier !== defaultUpgrade.multiplier
|
||||
|| savedUpgrade.costGold !== defaultUpgrade.costGold
|
||||
|| savedUpgrade.costEssence !== defaultUpgrade.costEssence
|
||||
|| savedUpgrade.costCrystals !== defaultUpgrade.costCrystals;
|
||||
savedUpgrade.name = defaultUpgrade.name;
|
||||
savedUpgrade.description = defaultUpgrade.description;
|
||||
savedUpgrade.target = defaultUpgrade.target;
|
||||
if (defaultUpgrade.adventurerId !== undefined) {
|
||||
savedUpgrade.adventurerId = defaultUpgrade.adventurerId;
|
||||
}
|
||||
savedUpgrade.multiplier = defaultUpgrade.multiplier;
|
||||
savedUpgrade.costGold = defaultUpgrade.costGold;
|
||||
savedUpgrade.costEssence = defaultUpgrade.costEssence;
|
||||
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing equipment items to match the current defaults,
|
||||
* preserving only player-state fields (owned, equipped).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of equipment entries whose stats were updated.
|
||||
*/
|
||||
/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */
|
||||
const patchEquipmentStats = (state: GameState): number => {
|
||||
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
|
||||
return [ item.id, item ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedItem of state.equipment) {
|
||||
const defaultItem = defaultEquipmentMap.get(savedItem.id);
|
||||
if (defaultItem === undefined) {
|
||||
continue;
|
||||
}
|
||||
const savedBonus = JSON.stringify(savedItem.bonus);
|
||||
const defaultBonus = JSON.stringify(defaultItem.bonus);
|
||||
const savedCost = JSON.stringify(savedItem.cost);
|
||||
const defaultCost = JSON.stringify(defaultItem.cost);
|
||||
const hasChanged
|
||||
= savedItem.name !== defaultItem.name
|
||||
|| savedItem.description !== defaultItem.description
|
||||
|| savedItem.type !== defaultItem.type
|
||||
|| savedItem.rarity !== defaultItem.rarity
|
||||
|| savedBonus !== defaultBonus
|
||||
|| savedCost !== defaultCost
|
||||
|| savedItem.setId !== defaultItem.setId;
|
||||
savedItem.name = defaultItem.name;
|
||||
savedItem.description = defaultItem.description;
|
||||
savedItem.type = defaultItem.type;
|
||||
savedItem.rarity = defaultItem.rarity;
|
||||
savedItem.bonus = structuredClone(defaultItem.bonus);
|
||||
if (defaultItem.cost !== undefined) {
|
||||
savedItem.cost = { ...defaultItem.cost };
|
||||
}
|
||||
if (defaultItem.setId !== undefined) {
|
||||
savedItem.setId = defaultItem.setId;
|
||||
}
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing achievements to match the current defaults,
|
||||
* preserving only player-state fields (unlockedAt).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of achievement entries whose stats were updated.
|
||||
*/
|
||||
const patchAchievementStats = (state: GameState): number => {
|
||||
const defaultAchievementMap = new Map(defaultAchievements.map((a) => {
|
||||
return [ a.id, a ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedAchievement of state.achievements) {
|
||||
const defaultAchievement = defaultAchievementMap.get(savedAchievement.id);
|
||||
if (defaultAchievement === undefined) {
|
||||
continue;
|
||||
}
|
||||
const savedCondition = JSON.stringify(savedAchievement.condition);
|
||||
const defaultCondition = JSON.stringify(defaultAchievement.condition);
|
||||
const savedReward = JSON.stringify(savedAchievement.reward);
|
||||
const defaultReward = JSON.stringify(defaultAchievement.reward);
|
||||
const hasChanged
|
||||
= savedAchievement.name !== defaultAchievement.name
|
||||
|| savedAchievement.description !== defaultAchievement.description
|
||||
|| savedAchievement.icon !== defaultAchievement.icon
|
||||
|| savedCondition !== defaultCondition
|
||||
|| savedReward !== defaultReward;
|
||||
savedAchievement.name = defaultAchievement.name;
|
||||
savedAchievement.description = defaultAchievement.description;
|
||||
savedAchievement.icon = defaultAchievement.icon;
|
||||
savedAchievement.condition = structuredClone(defaultAchievement.condition);
|
||||
if (defaultAchievement.reward !== undefined) {
|
||||
savedAchievement.reward = { ...defaultAchievement.reward };
|
||||
}
|
||||
if (hasChanged) {
|
||||
patched = patched + 1;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */
|
||||
/**
|
||||
* Recomputes all four crafting multipliers from the player's craftedRecipeIds,
|
||||
* replacing any stale cached values with the correct product of all crafted bonuses.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined.
|
||||
*/
|
||||
const recomputeCraftingMultipliers = (state: GameState): number => {
|
||||
if (state.exploration === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const { craftedRecipeIds } = state.exploration;
|
||||
state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
return craftedRecipeIds.length;
|
||||
};
|
||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
|
||||
/**
|
||||
* Syncs a player's save with the current game data, injecting any content
|
||||
* entries that are missing because they were added after the save was created.
|
||||
* entries that are missing because they were added after the save was created,
|
||||
* and patching stat fields on existing entries to match the current defaults.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns Counts of how many entries were added per content type.
|
||||
* @returns Counts of how many entries were added or patched per content type.
|
||||
*/
|
||||
const syncNewContent = (
|
||||
state: GameState,
|
||||
): {
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
bossRewardsPatched: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
achievementsAdded: number;
|
||||
achievementsPatched: number;
|
||||
adventurersAdded: number;
|
||||
adventurerStatsPatched: number;
|
||||
bossesAdded: number;
|
||||
bossesPatched: number;
|
||||
bossRewardsPatched: number;
|
||||
craftingRecipesReapplied: number;
|
||||
equipmentAdded: number;
|
||||
equipmentPatched: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
questsPatched: number;
|
||||
upgradesAdded: number;
|
||||
upgradesPatched: number;
|
||||
zonesAdded: number;
|
||||
zonesPatched: number;
|
||||
} => {
|
||||
const adventurerStatsPatched = patchAdventurerStats(state);
|
||||
const questsPatched = patchQuestStats(state);
|
||||
const bossesPatched = patchBossStats(state);
|
||||
const zonesPatched = patchZoneStats(state);
|
||||
const upgradesPatched = patchUpgradeStats(state);
|
||||
const equipmentPatched = patchEquipmentStats(state);
|
||||
const achievementsPatched = patchAchievementStats(state);
|
||||
const craftingRecipesReapplied = recomputeCraftingMultipliers(state);
|
||||
const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
|
||||
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
|
||||
const bossRewardsPatched = patchBossUpgradeRewards(state);
|
||||
const bossesAdded = injectMissingEntries(state.bosses, defaultBosses);
|
||||
const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment);
|
||||
const explorationAreasAdded = injectMissingExplorationAreas(state);
|
||||
const questRewardsPatched = patchQuestRewards(state);
|
||||
const questsAdded = injectMissingEntries(state.quests, defaultQuests);
|
||||
const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades);
|
||||
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
|
||||
return {
|
||||
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
|
||||
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
|
||||
bossRewardsPatched: patchBossUpgradeRewards(state),
|
||||
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
|
||||
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
|
||||
explorationAreasAdded: injectMissingExplorationAreas(state),
|
||||
questRewardsPatched: patchQuestRewards(state),
|
||||
questsAdded: injectMissingEntries(state.quests, defaultQuests),
|
||||
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
|
||||
zonesAdded: injectMissingEntries(state.zones, defaultZones),
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
adventurerStatsPatched,
|
||||
adventurersAdded,
|
||||
bossRewardsPatched,
|
||||
bossesAdded,
|
||||
bossesPatched,
|
||||
craftingRecipesReapplied,
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
};
|
||||
};
|
||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||
@@ -741,13 +1110,23 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
|
||||
const {
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
adventurersAdded,
|
||||
adventurerStatsPatched,
|
||||
bossesAdded,
|
||||
bossesPatched,
|
||||
bossRewardsPatched,
|
||||
craftingRecipesReapplied,
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
} = syncNewContent(state);
|
||||
|
||||
const updatedAt = Date.now();
|
||||
@@ -765,15 +1144,25 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
|
||||
return context.json({
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
adventurerStatsPatched,
|
||||
adventurersAdded,
|
||||
bossRewardsPatched,
|
||||
bossesAdded,
|
||||
bossesPatched,
|
||||
craftingRecipesReapplied,
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
signature,
|
||||
state,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
|
||||
@@ -191,17 +191,17 @@ exploreRouter.post("/start", async(context) => {
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||
area.status = "in_progress";
|
||||
area.startedAt = now;
|
||||
area.endsAt = endsAt;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||
const response: ExploreStartResponse = {
|
||||
areaId,
|
||||
endsAt,
|
||||
|
||||
@@ -546,6 +546,17 @@ const validateAndSanitize = (
|
||||
? previous.prestige
|
||||
: incoming.prestige;
|
||||
|
||||
/*
|
||||
* If the DB prestige count is higher than the client's, the client is sending a
|
||||
* stale pre-prestige save. Discard its upgrades (which have purchased: true) in
|
||||
* favour of the DB's post-prestige upgrades (purchased: false) so that upgrade
|
||||
* multipliers cannot persist across prestige via a race-condition auto-save.
|
||||
*/
|
||||
const upgrades
|
||||
= incoming.prestige.count < previous.prestige.count
|
||||
? previous.upgrades
|
||||
: incoming.upgrades;
|
||||
|
||||
/*
|
||||
* Echoes are only granted server-side via transcendence and can only decrease between
|
||||
* saves (spent on echo upgrades). Cap at the previous value to block inflation.
|
||||
@@ -611,11 +622,17 @@ const validateAndSanitize = (
|
||||
= Math.min(material.quantity, previousQuantity);
|
||||
return { ...material, quantity: cappedQuantity };
|
||||
});
|
||||
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter(
|
||||
(recipeId) => {
|
||||
return previousExploration.craftedRecipeIds.includes(recipeId);
|
||||
},
|
||||
);
|
||||
|
||||
/*
|
||||
* Merge crafted recipe IDs from both states so the list can only ever grow.
|
||||
* A stale auto-save arriving after a craft must not silently un-craft items.
|
||||
*/
|
||||
const craftedRecipeIds = [
|
||||
...new Set([
|
||||
...previousExploration.craftedRecipeIds,
|
||||
...incoming.exploration.craftedRecipeIds,
|
||||
]),
|
||||
];
|
||||
explorationSpread = {
|
||||
exploration: {
|
||||
...incoming.exploration,
|
||||
@@ -671,6 +688,7 @@ const validateAndSanitize = (
|
||||
prestige,
|
||||
quests,
|
||||
resources,
|
||||
upgrades,
|
||||
...transcendenceSpread,
|
||||
...apotheosisSpread,
|
||||
...explorationSpread,
|
||||
@@ -760,6 +778,7 @@ gameRouter.get("/load", async(context) => {
|
||||
: computeHmac(JSON.stringify(freshState), secret);
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
inGuild: playerRecord.inGuild,
|
||||
loginBonus: null,
|
||||
loginStreak: playerRecord.loginStreak,
|
||||
offlineEssence: 0,
|
||||
@@ -898,8 +917,10 @@ gameRouter.get("/load", async(context) => {
|
||||
const signature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
const inGuild = playerRecord?.inGuild ?? false;
|
||||
return context.json({
|
||||
currentSchemaVersion,
|
||||
inGuild,
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
offlineEssence,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
calculatePrestigeThreshold,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../services/prestige.js";
|
||||
@@ -40,10 +41,15 @@ prestigeRouter.post("/", async(context) => {
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
const thresholdMultiplier
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
const required = calculatePrestigeThreshold(
|
||||
state.prestige.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return context.json(
|
||||
{
|
||||
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||
error: `Not eligible for prestige — collect ${required.toLocaleString()} total gold first`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
@@ -102,12 +108,23 @@ prestigeRouter.post("/", async(context) => {
|
||||
}).length;
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
const { updatedAt } = record;
|
||||
|
||||
/*
|
||||
* Use the record's current updatedAt as an optimistic lock — if another
|
||||
* concurrent prestige request already committed, this update will match
|
||||
* 0 rows and we can safely reject the duplicate without a double webhook.
|
||||
*/
|
||||
const updateResult = await prisma.gameState.updateMany({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: finalState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
where: { discordId, updatedAt },
|
||||
});
|
||||
|
||||
if (updateResult.count === 0) {
|
||||
return context.json({ error: "Prestige already in progress" }, 409);
|
||||
}
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: state.player.characterName,
|
||||
@@ -136,17 +153,30 @@ prestigeRouter.post("/", async(context) => {
|
||||
|
||||
const prestigeCount = prestigeData.count;
|
||||
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||
void postMilestoneWebhook(discordId, "prestige", {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||
|
||||
prestige: prestigeData.count,
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||
const playerRecord = await prisma.player.findUnique({
|
||||
select: { profileSettings: true },
|
||||
where: { discordId },
|
||||
});
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
|
||||
const playerSettings = playerRecord?.profileSettings as
|
||||
Record<string, unknown> | null | undefined;
|
||||
const announcementsEnabled
|
||||
= playerSettings?.enablePrestigeAnnouncements !== false;
|
||||
|
||||
if (announcementsEnabled) {
|
||||
void postMilestoneWebhook(discordId, "prestige", {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||
|
||||
prestige: prestigeData.count,
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json({
|
||||
milestoneRunestones: milestoneRunestones,
|
||||
|
||||
@@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
||||
: "suffix";
|
||||
return {
|
||||
enableNotifications: rawObject.enableNotifications === true,
|
||||
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
|
||||
enableSounds: rawObject.enableSounds === true,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||
@@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
|
||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file Public read-only timer API for external tooling (bots, automations, etc.).
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const timersRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const explorationNameMap = new Map(
|
||||
defaultExplorations.map((area) => {
|
||||
return [ area.id, area.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Extracts active quest timers from a game state.
|
||||
* @param state - The player's game state.
|
||||
* @param now - The current timestamp in milliseconds.
|
||||
* @returns An array of active quest timer objects.
|
||||
*/
|
||||
const getQuestTimers = (
|
||||
state: GameState,
|
||||
now: number,
|
||||
): Array<{
|
||||
endsAt: number;
|
||||
name: string;
|
||||
questId: string;
|
||||
timeLeft: number;
|
||||
}> => {
|
||||
return state.quests.
|
||||
filter((quest) => {
|
||||
return quest.status === "active" && quest.startedAt !== undefined;
|
||||
}).
|
||||
map((quest) => {
|
||||
const durationMs = quest.durationSeconds * 1000;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const endsAt = (quest.startedAt ?? 0) + durationMs;
|
||||
return {
|
||||
endsAt: endsAt,
|
||||
name: quest.name,
|
||||
questId: quest.id,
|
||||
timeLeft: Math.max(0, endsAt - now),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts active exploration timers from a game state.
|
||||
* @param state - The player's game state.
|
||||
* @param now - The current timestamp in milliseconds.
|
||||
* @returns An array of active exploration timer objects.
|
||||
*/
|
||||
const getExplorationTimers = (
|
||||
state: GameState,
|
||||
now: number,
|
||||
): Array<{
|
||||
areaId: string;
|
||||
endsAt: number;
|
||||
name: string;
|
||||
timeLeft: number;
|
||||
}> => {
|
||||
return (state.exploration?.areas ?? []).
|
||||
filter((area) => {
|
||||
return area.status === "in_progress" && area.endsAt !== undefined;
|
||||
}).
|
||||
map((area) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const endsAt = area.endsAt ?? 0;
|
||||
return {
|
||||
areaId: area.id,
|
||||
endsAt: endsAt,
|
||||
name: explorationNameMap.get(area.id) ?? area.id,
|
||||
timeLeft: Math.max(0, endsAt - now),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns active quest and exploration timers for a given player.
|
||||
* This endpoint is public and read-only — no authentication required.
|
||||
* Rate limiting is enforced at the infrastructure level.
|
||||
*/
|
||||
timersRouter.get("/:userId", async(context) => {
|
||||
try {
|
||||
const { userId } = context.req.param();
|
||||
|
||||
if (userId.length === 0 || !/^\d+$/u.test(userId)) {
|
||||
return context.json({ error: "Invalid user ID" }, 400);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({
|
||||
where: { discordId: userId },
|
||||
});
|
||||
|
||||
if (record === null) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const now = Date.now();
|
||||
|
||||
return context.json({
|
||||
explorations: getExplorationTimers(state, now),
|
||||
quests: getQuestTimers(state, now),
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"timers",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { timersRouter };
|
||||
@@ -71,8 +71,11 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const challengeTypes: Array<DailyChallengeType> = [
|
||||
"clicks",
|
||||
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
|
||||
"crafting",
|
||||
];
|
||||
|
||||
const progressionChallengeTypes: Array<DailyChallengeType> = [
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
"prestige",
|
||||
@@ -80,7 +83,10 @@ const challengeTypes: Array<DailyChallengeType> = [
|
||||
|
||||
/**
|
||||
* Generates 3 daily challenges for the given date string, deterministically.
|
||||
* Picks one challenge from 3 different randomly-selected types.
|
||||
* Always includes a "clicks" challenge and a "crafting" challenge (both
|
||||
* completable regardless of zone/boss progression), then picks 1 more from
|
||||
* the progression types. This ensures stuck players always have 2 completable
|
||||
* challenges available.
|
||||
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
||||
* @returns An array of 3 DailyChallenge objects.
|
||||
*/
|
||||
@@ -88,8 +94,17 @@ const generateDailyChallenges = (
|
||||
dateString: string,
|
||||
): Array<DailyChallenge> => {
|
||||
const seed = dateSeed(dateString);
|
||||
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
|
||||
slice(0, 3);
|
||||
const selectedTypes: Array<DailyChallengeType> = [
|
||||
"clicks",
|
||||
...shuffleWithSeed(
|
||||
[ ...nonProgressionChallengeTypes ],
|
||||
seed + 500,
|
||||
).slice(0, 1),
|
||||
...shuffleWithSeed(
|
||||
[ ...progressionChallengeTypes ],
|
||||
seed,
|
||||
).slice(0, 1),
|
||||
];
|
||||
|
||||
return selectedTypes.map((type, index) => {
|
||||
const templates = dailyChallengeTemplates.filter((template) => {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const discordClientId = "1479551654264049908";
|
||||
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
||||
|
||||
interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
@@ -31,24 +34,18 @@ interface DiscordUser {
|
||||
const exchangeCode = async(
|
||||
code: string,
|
||||
): Promise<DiscordTokenResponse> => {
|
||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||
|
||||
if (
|
||||
clientId === undefined || clientId === ""
|
||||
|| clientSecret === undefined || clientSecret === ""
|
||||
|| redirectUri === undefined || redirectUri === ""
|
||||
) {
|
||||
if (clientSecret === undefined || clientSecret === "") {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_id: discordClientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri: discordRedirectUri,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -146,19 +143,9 @@ const fetchDiscordUserById = async(
|
||||
* @throws {Error} If OAuth environment variables are missing.
|
||||
*/
|
||||
const buildOAuthUrl = (): string => {
|
||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||
|
||||
if (
|
||||
clientId === undefined || clientId === ""
|
||||
|| redirectUri === undefined || redirectUri === ""
|
||||
) {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: discordClientId,
|
||||
redirect_uri: discordRedirectUri,
|
||||
response_type: "code",
|
||||
scope: "identify",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file Discord Gateway WebSocket client for listening to guild member events.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const discordGuildId = "1354624415861833870";
|
||||
|
||||
/**
|
||||
* Discord Gateway opcodes used by this client.
|
||||
*/
|
||||
const gatewayOpcodes = {
|
||||
dispatch: 0,
|
||||
heartbeat: 1,
|
||||
heartbeatAck: 11,
|
||||
hello: 10,
|
||||
identify: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* GUILD_MEMBERS privileged intent bitmask.
|
||||
*/
|
||||
/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */
|
||||
const guildMembersIntent = 1 << 1;
|
||||
|
||||
/**
|
||||
* Updates the inGuild flag for a player when they join the configured guild.
|
||||
* No-ops silently if the Discord user has no player record.
|
||||
* @param discordId - The Discord user ID of the member who joined.
|
||||
* @param guildId - The ID of the guild they joined.
|
||||
* @returns A promise that resolves when the update attempt completes.
|
||||
*/
|
||||
const handleGuildMemberAdd = async(
|
||||
discordId: string,
|
||||
guildId: string,
|
||||
): Promise<void> => {
|
||||
if (guildId !== discordGuildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.player.updateMany({
|
||||
data: { inGuild: true },
|
||||
where: { discordId },
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"gateway_member_add",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the inGuild flag for a player when they leave the configured guild.
|
||||
* No-ops silently if the Discord user has no player record.
|
||||
* @param discordId - The Discord user ID of the member who left.
|
||||
* @param guildId - The ID of the guild they left.
|
||||
* @returns A promise that resolves when the update attempt completes.
|
||||
*/
|
||||
const handleGuildMemberRemove = async(
|
||||
discordId: string,
|
||||
guildId: string,
|
||||
): Promise<void> => {
|
||||
if (guildId !== discordGuildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.player.updateMany({
|
||||
data: { inGuild: false },
|
||||
where: { discordId },
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"gateway_member_remove",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase
|
||||
/* v8 ignore next 95 -- @preserve */
|
||||
/**
|
||||
* Connects to the Discord Gateway and listens for guild member events.
|
||||
* Reconnects automatically on close or error.
|
||||
* Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal.
|
||||
*/
|
||||
const connectGateway = (): void => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
if (botToken === undefined || botToken === "") {
|
||||
void logger.log("info", "Gateway: no bot token configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json");
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSequence: number | null = null;
|
||||
|
||||
const stopHeartbeat = (): void => {
|
||||
if (heartbeatInterval !== null) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */
|
||||
const payload = JSON.parse(event.data as string) as {
|
||||
op: number;
|
||||
d: unknown;
|
||||
s: number | null;
|
||||
t: string | null;
|
||||
};
|
||||
|
||||
if (payload.s !== null) {
|
||||
lastSequence = payload.s;
|
||||
}
|
||||
|
||||
if (payload.op === gatewayOpcodes.hello) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */
|
||||
const helloData = payload.d as { heartbeat_interval: number };
|
||||
const heartbeatMs = helloData.heartbeat_interval;
|
||||
heartbeatInterval = setInterval(() => {
|
||||
ws.send(JSON.stringify({
|
||||
d: lastSequence,
|
||||
op: gatewayOpcodes.heartbeat,
|
||||
}));
|
||||
}, heartbeatMs);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
d: {
|
||||
intents: guildMembersIntent,
|
||||
properties: { browser: "elysium", device: "elysium", os: "linux" },
|
||||
token: botToken,
|
||||
},
|
||||
op: gatewayOpcodes.identify,
|
||||
}));
|
||||
}
|
||||
|
||||
if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */
|
||||
const data = payload.d as { user?: { id: string }; guild_id?: string };
|
||||
const discordId = data.user?.id;
|
||||
const guildId = data.guild_id;
|
||||
|
||||
if (discordId === undefined || guildId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.t === "GUILD_MEMBER_ADD") {
|
||||
void handleGuildMemberAdd(discordId, guildId);
|
||||
} else if (payload.t === "GUILD_MEMBER_REMOVE") {
|
||||
void handleGuildMemberRemove(discordId, guildId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
stopHeartbeat();
|
||||
void logger.log("info", "Gateway: connection closed, reconnecting in 5s");
|
||||
setTimeout(connectGateway, 5000);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
const message
|
||||
= event instanceof ErrorEvent
|
||||
? event.message
|
||||
: "WebSocket error";
|
||||
void logger.error("gateway_error", new Error(message));
|
||||
stopHeartbeat();
|
||||
ws.close();
|
||||
});
|
||||
};
|
||||
|
||||
export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove };
|
||||
@@ -15,14 +15,21 @@ import type {
|
||||
} from "@elysium/types";
|
||||
|
||||
const basePrestigeGoldThreshold = 1_000_000;
|
||||
const thresholdScaleFactor = 5;
|
||||
const runestonesPerPrestigeLevel = 10;
|
||||
const runestonesPerPrestigeLevel = 20;
|
||||
const milestoneInterval = 5;
|
||||
const milestoneRunestonesPerInterval = 25;
|
||||
|
||||
/*
|
||||
* Hard cap on the base runestone yield (before multipliers) to prevent
|
||||
* extreme AFK accumulation from producing game-breaking runestone counts.
|
||||
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige.
|
||||
*/
|
||||
const maxBaseRunestones = 200;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
|
||||
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
|
||||
* meaningful even as the production multiplier scales.
|
||||
* @param prestigeCount - The current number of prestiges completed.
|
||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||
* @returns The gold amount required to prestige.
|
||||
@@ -33,7 +40,7 @@ const calculatePrestigeThreshold = (
|
||||
): number => {
|
||||
return (
|
||||
basePrestigeGoldThreshold
|
||||
* Math.pow(thresholdScaleFactor, prestigeCount)
|
||||
* Math.pow(prestigeCount + 1, 2.5)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
@@ -107,7 +114,9 @@ interface RunestoneParameters {
|
||||
|
||||
/**
|
||||
* Calculates how many runestones the player earns from a prestige.
|
||||
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
|
||||
* Formula: min(floor(cbrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL, MAX_BASE) * multipliers.
|
||||
* Uses cube root for stronger diminishing returns than sqrt, and caps the base before multipliers
|
||||
* to prevent extended AFK sessions from producing runestone windfalls.
|
||||
* @param parameters - The parameters for the runestone calculation.
|
||||
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
||||
* @param parameters.prestigeCount - The current prestige count.
|
||||
@@ -123,9 +132,11 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
|
||||
echoRunestoneMultiplier = 1,
|
||||
} = parameters;
|
||||
const threshold = calculatePrestigeThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevel;
|
||||
const base = Math.min(
|
||||
Math.floor(Math.cbrt(totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevel,
|
||||
maxBaseRunestones,
|
||||
);
|
||||
const runestoneMult = getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"runestones",
|
||||
@@ -135,19 +146,20 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
|
||||
|
||||
/**
|
||||
* Calculates the new prestige production multiplier.
|
||||
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
|
||||
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually
|
||||
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
|
||||
* @param prestigeCount - The new prestige count.
|
||||
* @returns The production multiplier for the new prestige level.
|
||||
*/
|
||||
const calculateProductionMultiplier = (
|
||||
prestigeCount: number,
|
||||
): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
return Math.pow(1.3, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the milestone runestone bonus for the given prestige count.
|
||||
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
|
||||
* Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
|
||||
* @param prestigeCount - The prestige count after the current prestige.
|
||||
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
||||
*/
|
||||
@@ -156,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
|
||||
return 0;
|
||||
}
|
||||
const milestoneNumber = prestigeCount / milestoneInterval;
|
||||
return milestoneNumber * milestoneRunestonesPerInterval;
|
||||
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,6 +189,7 @@ const buildPostPrestigeState = (
|
||||
} => {
|
||||
const {
|
||||
autoPrestigeEnabled,
|
||||
autoPrestigeMaxRunestonesOnly,
|
||||
count: currentPrestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
runestones: currentRunestones,
|
||||
@@ -203,6 +216,9 @@ const buildPostPrestigeState = (
|
||||
...autoPrestigeEnabled === undefined
|
||||
? {}
|
||||
: { autoPrestigeEnabled },
|
||||
...autoPrestigeMaxRunestonesOnly === undefined
|
||||
? {}
|
||||
: { autoPrestigeMaxRunestonesOnly },
|
||||
};
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
@@ -251,7 +267,8 @@ const buildPostPrestigeState = (
|
||||
* Preserve automation preferences across prestige — the player explicitly
|
||||
* opted into these settings and would not expect them to silently reset.
|
||||
*/
|
||||
autoBoss: currentState.autoBoss ?? false,
|
||||
autoAdventurer: currentState.autoAdventurer ?? false,
|
||||
autoBoss: currentState.autoBoss ?? false,
|
||||
|
||||
autoQuest: currentState.autoQuest ?? false,
|
||||
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||
|
||||
@@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one";
|
||||
/**
|
||||
* Base constant used in the echo yield formula.
|
||||
*/
|
||||
const echoFormulaConstant = 853;
|
||||
const echoFormulaConstant = 224;
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedIds: Array<string>,
|
||||
|
||||
@@ -15,6 +15,49 @@ const discordApi = "https://discord.com/api/v10";
|
||||
*/
|
||||
const suppressNotifications = 4096;
|
||||
|
||||
/**
|
||||
* The Discord role ID for the Elysian role granted to all Elysium players.
|
||||
*/
|
||||
const discordGuildId = "1354624415861833870";
|
||||
const elysianRoleId = "1486144823684628490";
|
||||
const apotheosisRoleId = "1479966598210129991";
|
||||
|
||||
/**
|
||||
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
|
||||
* Fails silently so role grant errors do not affect the auth flow.
|
||||
* @param discordId - The Discord user ID to grant the role to.
|
||||
* @returns True if the player is in the guild and the role was granted, false otherwise.
|
||||
*/
|
||||
const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
if (botToken === undefined || botToken === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bot ${botToken}`,
|
||||
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
return response.ok || response.status === 204;
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"webhook_elysian_role",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Grants the apotheosis Discord role to the given player if configured.
|
||||
* Fails silently so role grant errors do not affect the game action.
|
||||
@@ -23,23 +66,20 @@ const suppressNotifications = 4096;
|
||||
*/
|
||||
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
|
||||
|
||||
if (
|
||||
botToken === undefined || botToken === ""
|
||||
|| guildId === undefined || guildId === ""
|
||||
|| roleId === undefined || roleId === ""
|
||||
) {
|
||||
if (botToken === undefined || botToken === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
|
||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
|
||||
{
|
||||
headers: { Authorization: `Bot ${botToken}` },
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Authorization": `Bot ${botToken}`,
|
||||
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -109,4 +149,4 @@ const postMilestoneWebhook = async(
|
||||
}
|
||||
};
|
||||
|
||||
export { grantApotheosisRole, postMilestoneWebhook };
|
||||
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
|
||||
|
||||
@@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({
|
||||
verifyToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("authMiddleware", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const makeApp = async () => {
|
||||
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
const { logger } = await import("../../src/services/logger.js");
|
||||
const app = new Hono<{ Variables: { discordId: string } }>();
|
||||
app.use("*", authMiddleware);
|
||||
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
||||
return { app, verifyToken };
|
||||
return { app, logger, verifyToken };
|
||||
};
|
||||
|
||||
it("returns 401 when Authorization header is missing", async () => {
|
||||
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
|
||||
expect(body.discordId).toBe("user_123");
|
||||
});
|
||||
|
||||
it("returns 401 when verifyToken throws", async () => {
|
||||
const { app, verifyToken } = await makeApp();
|
||||
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
|
||||
const { app, logger, verifyToken } = await makeApp();
|
||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||
throw new Error("Invalid token");
|
||||
});
|
||||
@@ -54,10 +62,15 @@ describe("authMiddleware", () => {
|
||||
headers: { Authorization: "Bearer bad_token" },
|
||||
}));
|
||||
expect(res.status).toBe(401);
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"auth_middleware",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
||||
const { app, verifyToken } = await makeApp();
|
||||
it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
|
||||
const { app, logger, verifyToken } = await makeApp();
|
||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||
throw "raw string error";
|
||||
});
|
||||
@@ -65,5 +78,23 @@ describe("authMiddleware", () => {
|
||||
headers: { Authorization: "Bearer bad_token" },
|
||||
}));
|
||||
expect(res.status).toBe(401);
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"auth_middleware",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 401 without logging when token has expired", async () => {
|
||||
const { app, logger, verifyToken } = await makeApp();
|
||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||
throw new Error("Token has expired");
|
||||
});
|
||||
const res = await app.fetch(new Request("http://localhost/test", {
|
||||
headers: { Authorization: "Bearer expired_token" },
|
||||
}));
|
||||
expect(res.status).toBe(401);
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||
expect((logger.error as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,6 +294,83 @@ describe("boss route", () => {
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zone unlock gracefully when exploration state is undefined", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
exploration: undefined,
|
||||
});
|
||||
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("unlocks exploration areas when a zone is unlocked on boss defeat", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
exploration: {
|
||||
areas: [{ id: "test_area", status: "locked" as const }],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
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);
|
||||
// Exploration area should remain locked — no matching defaultExploration for "test_area"
|
||||
const area = savedState?.exploration?.areas.find((a) => a.id === "test_area");
|
||||
expect(area?.status).toBe("locked");
|
||||
});
|
||||
|
||||
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const state = makeState({
|
||||
bosses: [makeBoss()] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
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 { signature: string | undefined };
|
||||
expect(body.signature).toBeDefined();
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
});
|
||||
|
||||
it("omits signature in response when ANTI_CHEAT_SECRET is not set", async () => {
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
const state = makeState({
|
||||
bosses: [makeBoss()] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
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 { signature: string | undefined };
|
||||
expect(body.signature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
|
||||
@@ -144,6 +144,34 @@ describe("craft route", () => {
|
||||
expect(body.bonusType).toBe("gold_income");
|
||||
});
|
||||
|
||||
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
|
||||
const state = makeState({
|
||||
dailyChallenges: {
|
||||
date: "2024-01-15",
|
||||
challenges: [
|
||||
{
|
||||
completed: false,
|
||||
id: "2024-01-15_crafting",
|
||||
label: "Craft 1 recipe",
|
||||
progress: 0,
|
||||
rewardCrystals: 75,
|
||||
target: 1,
|
||||
type: "crafting",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||
data: { state: GameState };
|
||||
};
|
||||
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
|
||||
expect(updateArg.data.state.resources.crystals).toBe(75);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
|
||||
@@ -567,12 +567,56 @@ describe("debug route", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 200 with zero counts when state already has all content", async () => {
|
||||
it("returns 200 with zero added counts when state already has all content", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { adventurerStatsPatched: number; bossRewardsPatched: number; questRewardsPatched: number };
|
||||
expect(body.adventurerStatsPatched).toBe(0);
|
||||
expect(body.bossRewardsPatched).toBe(0);
|
||||
expect(body.questRewardsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches adventurer stats when saved adventurer has outdated stats", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { adventurerStatsPatched: number; state: GameState };
|
||||
expect(body.adventurerStatsPatched).toBe(1);
|
||||
const adventurer = body.state.adventurers.find((a) => a.id === "militia");
|
||||
expect(adventurer?.baseCost).not.toBe(1);
|
||||
expect(adventurer?.count).toBe(5);
|
||||
expect(adventurer?.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 65, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { adventurerStatsPatched: number };
|
||||
expect(body.adventurerStatsPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips adventurer stat patching for adventurers not in defaults", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { adventurerStatsPatched: number };
|
||||
expect(body.adventurerStatsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("injects missing entries when arrays are empty", async () => {
|
||||
@@ -718,6 +762,32 @@ describe("debug route", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("patches upgrade adventurerId when default has it set", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "peasant_1", purchased: false, unlocked: false, multiplier: 0.1, name: "Old", description: "Old", target: "click", costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
const upgrade = body.state.upgrades.find((u) => u.id === "peasant_1");
|
||||
expect(upgrade?.adventurerId).toBe("peasant");
|
||||
});
|
||||
|
||||
it("patches equipment cost when default has it set", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "shadow_dagger", owned: false, equipped: false, name: "Old", description: "Old", type: "weapon", rarity: "common", bonus: {} }] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
const item = body.state.equipment.find((e) => e.id === "shadow_dagger");
|
||||
expect(item?.cost).toBeDefined();
|
||||
});
|
||||
|
||||
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const state = makeState();
|
||||
@@ -741,6 +811,323 @@ describe("debug route", () => {
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("patches quest stats when saved quest has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "first_steps", status: "available", rewards: [], durationSeconds: 1, name: "Old Name", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { questsPatched: number; state: GameState };
|
||||
expect(body.questsPatched).toBe(1);
|
||||
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
||||
expect(quest?.name).not.toBe("Old Name");
|
||||
expect(quest?.durationSeconds).not.toBe(1);
|
||||
expect(quest?.status).toBe("available");
|
||||
});
|
||||
|
||||
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { questsPatched: number };
|
||||
expect(body.questsPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips quest stat patching for quests not in defaults", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { questsPatched: number };
|
||||
expect(body.questsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches boss stats when saved boss has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "troll_king", 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: "Old Name", description: "Old" }] 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?.maxHp).not.toBe(1);
|
||||
expect(boss?.name).not.toBe("Old Name");
|
||||
expect(boss?.status).toBe("available");
|
||||
expect(boss?.currentHp).toBe(100);
|
||||
});
|
||||
|
||||
it("patches boss stats when only bountyRunestones has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 0, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 99, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] as GameState["bosses"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { bossesPatched: number };
|
||||
expect(body.bossesPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("patches boss when only equipmentRewards differ (covers savedRewards branch)", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: [], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 1, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] as GameState["bosses"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { bossesPatched: number };
|
||||
expect(body.bossesPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("patches boss when only bountyRunestones differs with all other fields matching", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 99, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] as GameState["bosses"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { bossesPatched: number };
|
||||
expect(body.bossesPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips boss stat patching for bosses not in defaults", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { bossesPatched: number };
|
||||
expect(body.bossesPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches zone stats when saved zone has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
zones: [{ id: "verdant_vale", status: "unlocked", name: "Old Name", description: "Old", emoji: "❓", unlockBossId: "wrong_boss", unlockQuestId: "wrong_quest" }] as GameState["zones"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { zonesPatched: number; state: GameState };
|
||||
expect(body.zonesPatched).toBe(1);
|
||||
const zone = body.state.zones.find((z) => z.id === "verdant_vale");
|
||||
expect(zone?.name).not.toBe("Old Name");
|
||||
expect(zone?.status).toBe("unlocked");
|
||||
});
|
||||
|
||||
it("patches zone stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
zones: [{ id: "verdant_vale", status: "unlocked", name: "The Verdant Vale", description: "Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.", emoji: "🌿", unlockBossId: null, unlockQuestId: "wrong_quest" }] as GameState["zones"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { zonesPatched: number };
|
||||
expect(body.zonesPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips zone stat patching for zones not in defaults", async () => {
|
||||
const state = makeState({
|
||||
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { zonesPatched: number };
|
||||
expect(body.zonesPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches upgrade stats when saved upgrade has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "click_2", purchased: false, unlocked: true, multiplier: 0.1, name: "Old Name", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { upgradesPatched: number; state: GameState };
|
||||
expect(body.upgradesPatched).toBe(1);
|
||||
const upgrade = body.state.upgrades.find((u) => u.id === "click_2");
|
||||
expect(upgrade?.multiplier).not.toBe(0.1);
|
||||
expect(upgrade?.name).not.toBe("Old Name");
|
||||
expect(upgrade?.purchased).toBe(false);
|
||||
expect(upgrade?.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("patches upgrade stats when only costCrystals has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "click_2", purchased: false, unlocked: false, multiplier: 2, name: "Battle Hardened", description: "Years of combat sharpen your instincts. Doubles click power again.", target: "click", adventurerId: undefined, costGold: 1000, costEssence: 0, costCrystals: 99 }] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { upgradesPatched: number };
|
||||
expect(body.upgradesPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips upgrade stat patching for upgrades not in defaults", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { upgradesPatched: number };
|
||||
expect(body.upgradesPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches equipment stats when saved item has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Rusty Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { equipmentPatched: number; state: GameState };
|
||||
expect(body.equipmentPatched).toBe(1);
|
||||
const item = body.state.equipment.find((e) => e.id === "iron_sword");
|
||||
expect(item?.name).not.toBe("Rusty Sword");
|
||||
expect(item?.owned).toBe(true);
|
||||
expect(item?.equipped).toBe(false);
|
||||
});
|
||||
|
||||
it("patches equipment stats when only cost has changed (exercises name/desc/type/rarity/bonus OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "shadow_dagger", owned: true, equipped: false, name: "Shadow Dagger", description: "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.", type: "weapon", rarity: "epic", bonus: { combatMultiplier: 1.65 }, cost: { crystals: 99, essence: 500, gold: 0 }, setId: "shadow_infiltrator" }] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { equipmentPatched: number };
|
||||
expect(body.equipmentPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("patches equipment stats when only setId has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Iron Sword", description: "A sturdy weapon issued to veterans of the guild.", type: "weapon", rarity: "rare", bonus: { combatMultiplier: 1.25 }, cost: undefined, setId: "old_set" }] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { equipmentPatched: number };
|
||||
expect(body.equipmentPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips equipment stat patching for items not in defaults", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { equipmentPatched: number };
|
||||
expect(body.equipmentPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches achievement stats when saved achievement has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
achievements: [{ id: "first_click", unlockedAt: null, name: "Old Name", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 999 }, reward: undefined }] as GameState["achievements"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { achievementsPatched: number; state: GameState };
|
||||
expect(body.achievementsPatched).toBe(1);
|
||||
const achievement = body.state.achievements.find((a) => a.id === "first_click");
|
||||
expect(achievement?.name).not.toBe("Old Name");
|
||||
expect(achievement?.condition.amount).not.toBe(999);
|
||||
expect(achievement?.unlockedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("patches achievement stats when only reward has changed (exercises all earlier OR conditions)", async () => {
|
||||
const state = makeState({
|
||||
achievements: [{ id: "first_click", unlockedAt: null, name: "First Strike", description: "Click the Guild Hall for the first time.", icon: "👆", condition: { amount: 1, type: "totalClicks" }, reward: { crystals: 999 } }] as GameState["achievements"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { achievementsPatched: number };
|
||||
expect(body.achievementsPatched).toBe(1);
|
||||
});
|
||||
|
||||
it("skips achievement stat patching for achievements not in defaults", async () => {
|
||||
const state = makeState({
|
||||
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { achievementsPatched: number };
|
||||
expect(body.achievementsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("recomputes crafting multipliers from craftedRecipeIds", async () => {
|
||||
const state = makeState({
|
||||
exploration: { ...makeExploration(), craftedRecipeIds: ["heartwood_tincture"], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
});
|
||||
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 { craftingRecipesReapplied: number; state: GameState };
|
||||
expect(body.craftingRecipesReapplied).toBe(1);
|
||||
expect(body.state.exploration?.craftedGoldMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 0 for crafting recompute when exploration is undefined", async () => {
|
||||
const state = makeState({
|
||||
exploration: undefined as unknown as GameState["exploration"],
|
||||
});
|
||||
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 { craftingRecipesReapplied: number };
|
||||
expect(body.craftingRecipesReapplied).toBe(0);
|
||||
});
|
||||
|
||||
it("sets multipliers to 1 when craftedRecipeIds is empty", async () => {
|
||||
const state = makeState({
|
||||
exploration: { ...makeExploration(), craftedRecipeIds: [], craftedGoldMultiplier: 5, craftedEssenceMultiplier: 5, craftedClickMultiplier: 5, craftedCombatMultiplier: 5 },
|
||||
});
|
||||
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 { state: GameState };
|
||||
expect(body.state.exploration?.craftedGoldMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedEssenceMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedClickMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedCombatMultiplier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /hard-reset", () => {
|
||||
|
||||
@@ -246,6 +246,22 @@ describe("explore route", () => {
|
||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("persists endsAt to the DB state on exploration start", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { areaId: string; endsAt: number };
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]?.[0];
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test accesses nested mock data */
|
||||
const savedState = (updateCall?.data as { state?: { exploration?: { areas?: Array<{ id: string; endsAt?: number }> } } }).state;
|
||||
const savedArea = savedState?.exploration?.areas?.find((a) => {
|
||||
return a.id === TEST_AREA_ID;
|
||||
});
|
||||
expect(savedArea?.endsAt).toBe(body.endsAt);
|
||||
});
|
||||
|
||||
it("backfills exploration state for old saves without exploration", async () => {
|
||||
const state = makeState({ exploration: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock("../../src/services/discord.js", () => ({
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
const CURRENT_SCHEMA_VERSION = 2;
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
@@ -477,6 +477,28 @@ describe("game route", () => {
|
||||
expect(body.savedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
|
||||
const prevUpgrades = [
|
||||
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
|
||||
] as GameState["upgrades"];
|
||||
const prevState = makeState({
|
||||
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
|
||||
upgrades: prevUpgrades,
|
||||
});
|
||||
const incomingState = makeState({
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
upgrades: [
|
||||
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
|
||||
] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("validates companion when active companion is legitimately unlocked", async () => {
|
||||
const prevState = makeState();
|
||||
const stateWithCompanion = makeState({
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
player: { findUnique: vi.fn(), update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,8 +47,8 @@ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
describe("prestige route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
player: { update: ReturnType<typeof vi.fn> };
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -81,10 +81,20 @@ describe("prestige route", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
|
||||
const state = makeState({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
|
||||
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns runestones on successful prestige", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
@@ -93,6 +103,14 @@ describe("prestige route", () => {
|
||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns 409 when a concurrent prestige already committed", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 0 } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws during prestige", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
@@ -112,14 +130,26 @@ describe("prestige route", () => {
|
||||
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
||||
} as GameState["dailyChallenges"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
||||
expect(body.newPrestigeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("skips webhook when enablePrestigeAnnouncements is false", async () => {
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeState = (overrides: Record<string, unknown> = {}) => ({
|
||||
quests: [],
|
||||
exploration: { areas: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("timers route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { timersRouter } = await import("../../src/routes/timers.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/timers", timersRouter);
|
||||
});
|
||||
|
||||
const get = (userId: string) =>
|
||||
app.fetch(new Request(`http://localhost/timers/${userId}`));
|
||||
|
||||
it("returns 400 for a non-numeric user ID", async () => {
|
||||
const res = await get("not-a-number");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Invalid user ID");
|
||||
});
|
||||
|
||||
it("returns 404 when player is not found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Player not found");
|
||||
});
|
||||
|
||||
it("returns empty arrays when no active quests or explorations", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({
|
||||
state: makeState(),
|
||||
});
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { quests: unknown[]; explorations: unknown[] };
|
||||
expect(body.quests).toEqual([]);
|
||||
expect(body.explorations).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns active quest timers with endsAt computed from startedAt + duration", async () => {
|
||||
const startedAt = Date.now() - 30_000;
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Forest Patrol",
|
||||
status: "active",
|
||||
startedAt: startedAt,
|
||||
durationSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
quests: Array<{ questId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||
};
|
||||
expect(body.quests).toHaveLength(1);
|
||||
expect(body.quests[0]?.questId).toBe("q1");
|
||||
expect(body.quests[0]?.name).toBe("Forest Patrol");
|
||||
expect(body.quests[0]?.endsAt).toBe(startedAt + 600_000);
|
||||
expect(body.quests[0]?.timeLeft).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filters out quests that are not in_progress", async () => {
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{ id: "q1", name: "Done Quest", status: "completed", startedAt: 0, durationSeconds: 60 },
|
||||
{ id: "q2", name: "Idle Quest", status: "available", durationSeconds: 60 },
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as { quests: unknown[] };
|
||||
expect(body.quests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns timeLeft of 0 for already-completed quests still marked in_progress", async () => {
|
||||
const startedAt = Date.now() - 700_000;
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Old Quest",
|
||||
status: "active",
|
||||
startedAt: startedAt,
|
||||
durationSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as {
|
||||
quests: Array<{ timeLeft: number }>;
|
||||
};
|
||||
expect(body.quests[0]?.timeLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("returns active exploration timers", async () => {
|
||||
const endsAt = Date.now() + 120_000;
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: "verdant_meadows", status: "in_progress", endsAt },
|
||||
{ id: "unknown_area_xyz", status: "in_progress", endsAt },
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as {
|
||||
explorations: Array<{ areaId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||
};
|
||||
expect(body.explorations).toHaveLength(2);
|
||||
expect(body.explorations[0]?.areaId).toBe("verdant_meadows");
|
||||
expect(body.explorations[0]?.endsAt).toBe(endsAt);
|
||||
expect(body.explorations[0]?.timeLeft).toBeGreaterThan(0);
|
||||
// Unknown area falls back to ID as name
|
||||
expect(body.explorations[1]?.name).toBe("unknown_area_xyz");
|
||||
});
|
||||
|
||||
it("filters out explorations not in_progress", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: "area1", status: "available" },
|
||||
{ id: "area2", status: "completed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as { explorations: unknown[] };
|
||||
expect(body.explorations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles missing exploration state gracefully", async () => {
|
||||
const state = { quests: [] };
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { explorations: unknown[] };
|
||||
expect(body.explorations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 500 on database error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(
|
||||
new Error("DB failure"),
|
||||
);
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 and logs non-Error throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe("transcendence route", () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
|
||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
||||
expect(body.echoesRemaining).toBe(98); // 100 - 2
|
||||
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
||||
});
|
||||
|
||||
|
||||
@@ -46,13 +46,37 @@ describe("generateDailyChallenges", () => {
|
||||
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
||||
});
|
||||
|
||||
it("generates different challenges for different dates", async () => {
|
||||
it("always includes a clicks challenge regardless of date", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const day1 = generateDailyChallenges("2024-01-15");
|
||||
const day2 = generateDailyChallenges("2024-01-16");
|
||||
// They should differ in at least one challenge ID (types vary by seed)
|
||||
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
|
||||
expect(day1.some((c) => c.type === "clicks")).toBe(true);
|
||||
expect(day2.some((c) => c.type === "clicks")).toBe(true);
|
||||
});
|
||||
|
||||
it("always includes a crafting challenge regardless of date", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const day1 = generateDailyChallenges("2024-01-15");
|
||||
const day2 = generateDailyChallenges("2024-01-16");
|
||||
expect(day1.some((c) => c.type === "crafting")).toBe(true);
|
||||
expect(day2.some((c) => c.type === "crafting")).toBe(true);
|
||||
});
|
||||
|
||||
it("progression challenge slot varies across different dates", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
// 2024-01-01 picks bossesDefeated, 2024-01-02 picks prestige (verified by seed)
|
||||
const day1 = generateDailyChallenges("2024-01-01");
|
||||
const day2 = generateDailyChallenges("2024-01-02");
|
||||
const day1ProgressionType = day1.find((c) => {
|
||||
return c.type !== "clicks" && c.type !== "crafting";
|
||||
})?.type;
|
||||
const day2ProgressionType = day2.find((c) => {
|
||||
return c.type !== "clicks" && c.type !== "crafting";
|
||||
})?.type;
|
||||
expect(day1ProgressionType).not.toBe(day2ProgressionType);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,51 +18,31 @@ describe("discord service", () => {
|
||||
});
|
||||
|
||||
describe("buildOAuthUrl", () => {
|
||||
it("throws when DISCORD_CLIENT_ID is missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_ID"];
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||
delete process.env["DISCORD_REDIRECT_URI"];
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("returns a URL with correct query params", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
const url = buildOAuthUrl();
|
||||
expect(url).toContain("client_id=client123");
|
||||
expect(url).toContain("client_id=1479551654264049908");
|
||||
expect(url).toContain("response_type=code");
|
||||
expect(url).toContain("scope=identify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeCode", () => {
|
||||
it("throws when env vars are missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_ID"];
|
||||
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_SECRET"];
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("throws when response is not ok", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||
});
|
||||
|
||||
it("returns parsed body on success", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
@@ -96,9 +76,7 @@ describe("discord service", () => {
|
||||
|
||||
describe("exchangeCode non-Error throw", () => {
|
||||
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||
mockFetch.mockRejectedValueOnce("raw string error");
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { updateMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "../../src/db/client.js";
|
||||
|
||||
const discordGuildId = "1354624415861833870";
|
||||
|
||||
describe("gateway service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleGuildMemberAdd", () => {
|
||||
it("sets inGuild to true for the matching guild", async () => {
|
||||
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberAdd("user123", discordGuildId);
|
||||
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||
data: { inGuild: true },
|
||||
where: { discordId: "user123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops when guild id does not match the configured guild", async () => {
|
||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberAdd("user123", "other_guild");
|
||||
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error when prisma throws an Error", async () => {
|
||||
const dbError = new Error("DB failure");
|
||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||
const { logger } = await import("../../src/services/logger.js");
|
||||
await handleGuildMemberAdd("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
|
||||
});
|
||||
|
||||
it("logs error when prisma throws a non-Error", async () => {
|
||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||
const { logger } = await import("../../src/services/logger.js");
|
||||
await handleGuildMemberAdd("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"gateway_member_add",
|
||||
new Error("raw error"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleGuildMemberRemove", () => {
|
||||
it("sets inGuild to false for the matching guild", async () => {
|
||||
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberRemove("user123", discordGuildId);
|
||||
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||
data: { inGuild: false },
|
||||
where: { discordId: "user123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops when guild id does not match the configured guild", async () => {
|
||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberRemove("user123", "other_guild");
|
||||
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error when prisma throws an Error", async () => {
|
||||
const dbError = new Error("DB failure");
|
||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||
const { logger } = await import("../../src/services/logger.js");
|
||||
await handleGuildMemberRemove("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
|
||||
});
|
||||
|
||||
it("logs error when prisma throws a non-Error", async () => {
|
||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||
const { logger } = await import("../../src/services/logger.js");
|
||||
await handleGuildMemberRemove("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"gateway_member_remove",
|
||||
new Error("raw error"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,15 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
|
||||
describe("calculatePrestigeThreshold", () => {
|
||||
it("returns base threshold at count 0", () => {
|
||||
// base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
|
||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("returns 5× at count 1", () => {
|
||||
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
|
||||
it("returns base × 2^2.5 at count 1", () => {
|
||||
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
|
||||
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
|
||||
});
|
||||
|
||||
it("returns 25× at count 2", () => {
|
||||
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
|
||||
it("returns base × 3^2.5 at count 2", () => {
|
||||
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
|
||||
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
|
||||
});
|
||||
|
||||
it("applies threshold multiplier correctly", () => {
|
||||
@@ -99,21 +102,27 @@ describe("isEligibleForPrestige", () => {
|
||||
|
||||
describe("calculateRunestones", () => {
|
||||
it("calculates basic runestones formula", () => {
|
||||
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
|
||||
// floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it("applies echo runestone multiplier", () => {
|
||||
// floor(sqrt(4) × 10) = 20; × 2 = 40
|
||||
// floor(cbrt(4)) × 20 = 20; × 2 = 40
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||||
expect(result).toBe(40);
|
||||
});
|
||||
|
||||
it("applies purchased runestone upgrade multiplier", () => {
|
||||
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||||
// With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||||
expect(result).toBeGreaterThan(20);
|
||||
expect(result).toBe(25);
|
||||
});
|
||||
|
||||
it("caps base runestones before multipliers", () => {
|
||||
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
|
||||
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
|
||||
expect(calculateProductionMultiplier(0)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1.15 at count 1", () => {
|
||||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
|
||||
it("returns 1.3 at count 1", () => {
|
||||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
|
||||
});
|
||||
|
||||
it("scales exponentially", () => {
|
||||
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
|
||||
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,12 +151,12 @@ describe("calculateMilestoneBonus", () => {
|
||||
expect(calculateMilestoneBonus(5)).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 50 at prestige 10", () => {
|
||||
expect(calculateMilestoneBonus(10)).toBe(50);
|
||||
it("returns 100 at prestige 10", () => {
|
||||
expect(calculateMilestoneBonus(10)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns 75 at prestige 15", () => {
|
||||
expect(calculateMilestoneBonus(15)).toBe(75);
|
||||
it("returns 225 at prestige 15", () => {
|
||||
expect(calculateMilestoneBonus(15)).toBe(225);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +255,20 @@ describe("buildPostPrestigeState", () => {
|
||||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||
});
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
|
||||
const state = makeMinimalState();
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves apotheosis data across prestige", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
|
||||
@@ -97,20 +97,21 @@ describe("isEligibleForTranscendence", () => {
|
||||
|
||||
describe("calculateEchoes", () => {
|
||||
it("handles prestige count of 0 by treating it as 1", () => {
|
||||
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
|
||||
expect(calculateEchoes(0, 1)).toBe(853);
|
||||
// safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224
|
||||
expect(calculateEchoes(0, 1)).toBe(224);
|
||||
});
|
||||
|
||||
it("calculates echoes at count 1", () => {
|
||||
expect(calculateEchoes(1, 1)).toBe(853);
|
||||
// floor(224 / sqrt(1)) = 224
|
||||
expect(calculateEchoes(1, 1)).toBe(224);
|
||||
});
|
||||
|
||||
it("decreases echoes with higher prestige count", () => {
|
||||
const echoesAt1 = calculateEchoes(1, 1);
|
||||
const echoesAt4 = calculateEchoes(4, 1);
|
||||
expect(echoesAt4).toBeLessThan(echoesAt1);
|
||||
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
|
||||
expect(echoesAt4).toBe(426);
|
||||
// floor(224 / sqrt(4)) = floor(224 / 2) = 112
|
||||
expect(echoesAt4).toBe(112);
|
||||
});
|
||||
|
||||
it("applies echoMetaMultiplier", () => {
|
||||
@@ -118,6 +119,11 @@ describe("calculateEchoes", () => {
|
||||
const withMult = calculateEchoes(1, 2);
|
||||
expect(withMult).toBe(base * 2);
|
||||
});
|
||||
|
||||
it("returns 50 echoes at the target prestige 20", () => {
|
||||
// floor(224 / sqrt(20)) = floor(224 / 4.472) = floor(50.09) = 50
|
||||
expect(calculateEchoes(20, 1)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostTranscendenceState", () => {
|
||||
|
||||
@@ -20,42 +20,20 @@ describe("webhook service", () => {
|
||||
describe("grantApotheosisRole", () => {
|
||||
it("does nothing when bot token is missing", async () => {
|
||||
delete process.env["DISCORD_BOT_TOKEN"];
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when guild id is missing", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||
delete process.env["DISCORD_GUILD_ID"];
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when role id is missing", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls Discord API with correct URL and auth when env vars are set", async () => {
|
||||
it("calls Discord API with correct URL and auth when bot token is set", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user789");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
|
||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||
}),
|
||||
);
|
||||
@@ -63,8 +41,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||
@@ -72,14 +48,69 @@ describe("webhook service", () => {
|
||||
|
||||
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||
mockFetch.mockRejectedValueOnce("raw string error");
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("grantElysianRole", () => {
|
||||
it("does nothing when bot token is missing", async () => {
|
||||
delete process.env["DISCORD_BOT_TOKEN"];
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when Discord API responds with ok", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user789");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||
}),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when Discord API responds with 204", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when Discord API responds with an error status", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and swallows non-Error fetch rejections", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
mockFetch.mockRejectedValueOnce("raw string error");
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postMilestoneWebhook", () => {
|
||||
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/web",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,21 +12,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "3.0.8",
|
||||
"eslint": "9.22.0",
|
||||
"jsdom": "26.0.0",
|
||||
"jsdom": "29.0.1",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.1",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "3.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,26 @@ import type {
|
||||
|
||||
const baseUrl = "/api";
|
||||
|
||||
/**
|
||||
* Represents a 4xx API error so callers can distinguish expected server
|
||||
* rejections from unexpected failures. ValidationErrors are downgraded to
|
||||
* console.warn and are not forwarded to the error-email pipeline.
|
||||
*/
|
||||
class ValidationError extends Error {
|
||||
public readonly statusCode: number;
|
||||
|
||||
/**
|
||||
* Creates a new ValidationError.
|
||||
* @param message - The error message from the server response.
|
||||
* @param statusCode - The HTTP status code (4xx) returned by the server.
|
||||
*/
|
||||
public constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
const getToken = (): string | null => {
|
||||
return globalThis.localStorage.getItem("elysium_token");
|
||||
};
|
||||
@@ -72,6 +92,14 @@ const fetchJson = async <T>(
|
||||
= typeof errorBody.error === "string"
|
||||
? errorBody.error
|
||||
: "Unknown error";
|
||||
if (response.status === 401) {
|
||||
globalThis.localStorage.removeItem("elysium_token");
|
||||
globalThis.localStorage.removeItem("elysium_save_signature");
|
||||
globalThis.location.href = "/";
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new ValidationError(message, response.status);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -326,6 +354,7 @@ const updateProfile = async(
|
||||
};
|
||||
|
||||
export {
|
||||
ValidationError,
|
||||
achieveApotheosis,
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
|
||||
@@ -232,7 +232,7 @@ const howToPlay = [
|
||||
{
|
||||
body:
|
||||
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
||||
+ " The Absolute One (requires Prestige 90). Transcending performs a"
|
||||
+ " The Absolute One (requires Prestige 20). Transcending performs a"
|
||||
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
||||
+ " and equipment — but grants Echoes based on your prestige count"
|
||||
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
||||
@@ -277,6 +277,15 @@ const howToPlay = [
|
||||
+ " when you first enable them.",
|
||||
title: "🔔 Sounds & Notifications",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Have a question, found a bug, or want to suggest a feature? Join the"
|
||||
+ " NHCarrigan community Discord at https://chat.nhcarrigan.com or open"
|
||||
+ " a support ticket at https://support.nhcarrigan.com. You can also"
|
||||
+ " report issues directly on the project repository. We'd love to hear"
|
||||
+ " from you!",
|
||||
title: "💬 Community & Support",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
|
||||
@@ -156,7 +156,18 @@ const AchievementCard = ({
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
? <>
|
||||
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
{achievement.unlockedAt !== null
|
||||
&& <span className="achievement-unlocked-at">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
@@ -76,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface EffectiveAdventurerStats {
|
||||
readonly combatPower: number;
|
||||
readonly essencePerSecond: number;
|
||||
readonly goldPerSecond: number;
|
||||
}
|
||||
|
||||
interface AdventurerCardProperties {
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly effectiveStats: EffectiveAdventurerStats;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +100,7 @@ interface AdventurerCardProperties {
|
||||
* @param props.batchSize - The selected batch size.
|
||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.effectiveStats - The post-multiplier per-unit stats.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerCard = ({
|
||||
@@ -100,6 +109,7 @@ const AdventurerCard = ({
|
||||
batchSize,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
effectiveStats,
|
||||
}: AdventurerCardProperties): JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
@@ -134,17 +144,17 @@ const AdventurerCard = ({
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
{formatNumber(adventurer.goldPerSecond)}
|
||||
{formatNumber(effectiveStats.goldPerSecond)}
|
||||
{" gold/s each"}
|
||||
</p>
|
||||
{adventurer.essencePerSecond > 0
|
||||
&& <p>
|
||||
{formatNumber(adventurer.essencePerSecond)}
|
||||
{formatNumber(effectiveStats.essencePerSecond)}
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{formatNumber(adventurer.combatPower)}
|
||||
{formatNumber(effectiveStats.combatPower)}
|
||||
{" combat power each"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
effectiveStats={computeEffectiveAdventurerStats(
|
||||
state,
|
||||
adventurer.id,
|
||||
)}
|
||||
formatNumber={formatNumber}
|
||||
key={adventurer.id}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
|
||||
@@ -62,6 +62,7 @@ const BattleModal = ({
|
||||
enableNotifications,
|
||||
enableSounds,
|
||||
flushBossLoreToasts,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
} = useGame();
|
||||
|
||||
@@ -241,14 +242,14 @@ const BattleModal = ({
|
||||
{result.rewards.crystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(result.rewards.crystals)}
|
||||
{formatInteger(result.rewards.crystals)}
|
||||
{" crystals"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.bountyRunestones > 0
|
||||
&& <span className="battle-bounty">
|
||||
{"🔮 "}
|
||||
{formatNumber(result.rewards.bountyRunestones)}
|
||||
{formatInteger(result.rewards.bountyRunestones)}
|
||||
{" runestones (first kill!)"}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { computePartyCombatPower } from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Boss, GameState } from "@elysium/types";
|
||||
import type { Boss } from "@elysium/types";
|
||||
|
||||
interface BossCardProperties {
|
||||
readonly boss: Boss;
|
||||
@@ -22,6 +23,7 @@ interface BossCardProperties {
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ interface BossCardProperties {
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
@@ -42,6 +45,7 @@ const BossCard = ({
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
}: BossCardProperties): JSX.Element => {
|
||||
const scaled = boss.currentHp * 100;
|
||||
@@ -116,7 +120,7 @@ const BossCard = ({
|
||||
{boss.crystalReward > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(boss.crystalReward)}
|
||||
{formatInteger(boss.crystalReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
@@ -157,72 +161,6 @@ const BossCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes party DPS and HP from the current game state.
|
||||
* @param state - The full game state.
|
||||
* @returns The computed party DPS and HP values.
|
||||
*/
|
||||
const computePartyStats = (
|
||||
state: GameState,
|
||||
): {
|
||||
partyDps: number;
|
||||
partyHp: number;
|
||||
} => {
|
||||
const { upgrades, adventurers, equipment, prestige } = state;
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const { purchased, target, multiplier } = upgrade;
|
||||
if (purchased && target === "global") {
|
||||
globalMultiplier = globalMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeBonus = prestige.count * 0.1;
|
||||
const prestigeMultiplier = 1 + prestigeBonus;
|
||||
const equipmentCombatMultiplier = equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((multiplier, item) => {
|
||||
return multiplier * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
let partyDps = 0;
|
||||
let partyHp = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const { count, id: adventurerId, combatPower, level } = adventurer;
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const {
|
||||
purchased,
|
||||
target,
|
||||
multiplier,
|
||||
adventurerId: upgradeAdventurerId,
|
||||
} = upgrade;
|
||||
if (
|
||||
purchased
|
||||
&& target === "adventurer"
|
||||
&& upgradeAdventurerId === adventurerId
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const dps
|
||||
= combatPower
|
||||
* count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyDps = partyDps + dps;
|
||||
const hp = level * 50 * count;
|
||||
partyHp = partyHp + hp;
|
||||
}
|
||||
partyDps = partyDps * equipmentCombatMultiplier;
|
||||
return { partyDps, partyHp };
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the boss panel with zone selection and boss list.
|
||||
* @returns The JSX element.
|
||||
@@ -231,6 +169,7 @@ const BossPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
challengeBoss,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
toggleAutoBoss,
|
||||
autoBossLastResult,
|
||||
@@ -266,7 +205,14 @@ const BossPanel = (): JSX.Element => {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||
const {
|
||||
adventurers,
|
||||
autoBoss,
|
||||
bosses,
|
||||
prestige: playerPrestige,
|
||||
quests,
|
||||
zones,
|
||||
} = state;
|
||||
|
||||
const activeZone = zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
@@ -349,7 +295,12 @@ const BossPanel = (): JSX.Element => {
|
||||
}
|
||||
|
||||
const autoBossOn = autoBoss === true;
|
||||
const { partyDps, partyHp } = computePartyStats(state);
|
||||
const partyDps = computePartyCombatPower(state);
|
||||
let partyHp = 0;
|
||||
for (const { level, count } of adventurers) {
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
|
||||
partyHp = partyHp + level * 50 * count;
|
||||
}
|
||||
const { count: prestigeCount } = playerPrestige;
|
||||
|
||||
return (
|
||||
@@ -448,6 +399,7 @@ const BossPanel = (): JSX.Element => {
|
||||
return (
|
||||
<BossCard
|
||||
boss={boss}
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === bossId}
|
||||
key={bossId}
|
||||
|
||||
@@ -49,6 +49,40 @@ const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
||||
zone: "zones",
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a snake_case ID to a Title Case display name.
|
||||
* @param id - The snake_case identifier to format.
|
||||
* @returns The formatted display name.
|
||||
*/
|
||||
const formatId = (id: string): string => {
|
||||
return id.split("_").
|
||||
map((word) => {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}).
|
||||
join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable unlock hint for a locked codex entry.
|
||||
* @param entry - The locked codex entry.
|
||||
* @returns A string describing how to unlock the entry.
|
||||
*/
|
||||
const buildUnlockHint = (entry: CodexEntry): string => {
|
||||
const name = formatId(entry.sourceId);
|
||||
switch (entry.sourceType) {
|
||||
case "boss": return `Defeat ${name}`;
|
||||
case "quest": return `Complete: ${name}`;
|
||||
case "equipment": return `Obtain: ${name}`;
|
||||
case "adventurer": return `Recruit a ${name}`;
|
||||
case "upgrade": return `Purchase: ${name}`;
|
||||
case "prestige": return `Purchase runestone upgrade: ${name}`;
|
||||
case "zone": return `Explore: ${name}`;
|
||||
case "exploration": return `Discover: ${name}`;
|
||||
case "recipe": return `Craft: ${name}`;
|
||||
default: return "Keep playing to unlock";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex panel with lore entries grouped by zone.
|
||||
* @returns The JSX element.
|
||||
@@ -136,6 +170,9 @@ const CodexPanel = (): JSX.Element => {
|
||||
<span className="codex-lock">{"🔒"}</span>
|
||||
<span className="codex-entry-title">{"???"}</span>
|
||||
</div>
|
||||
<p className="codex-unlock-hint">
|
||||
{buildUnlockHint(entry)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||
/* eslint-disable complexity -- Companion card has many conditional render paths */
|
||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
@@ -28,41 +29,13 @@ const unlockLabels: Record<string, string> = {
|
||||
transcendence: "transcendence(s)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a companion unlock threshold for display.
|
||||
* @param type - The unlock condition type.
|
||||
* @param threshold - The threshold value.
|
||||
* @returns The formatted threshold string.
|
||||
*/
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e18) {
|
||||
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
||||
}
|
||||
if (threshold >= 1e15) {
|
||||
return `${(threshold / 1e15).toFixed(0)}Q`;
|
||||
}
|
||||
if (threshold >= 1e12) {
|
||||
return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
}
|
||||
if (threshold >= 1e9) {
|
||||
return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
}
|
||||
if (threshold >= 1e6) {
|
||||
return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
}
|
||||
if (threshold >= 1e3) {
|
||||
return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
}
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
interface CompanionCardProperties {
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly currentProgress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +45,8 @@ interface CompanionCardProperties {
|
||||
* @param props.isUnlocked - Whether this companion is unlocked.
|
||||
* @param props.isActive - Whether this companion is currently active.
|
||||
* @param props.onSelect - Callback when the companion is selected/deselected.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.currentProgress - The player's current progress toward the unlock threshold.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionCard = ({
|
||||
@@ -79,6 +54,8 @@ const CompanionCard = ({
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
formatNumber,
|
||||
currentProgress,
|
||||
}: CompanionCardProperties): JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime"
|
||||
? "-"
|
||||
@@ -137,12 +114,28 @@ const CompanionCard = ({
|
||||
: "Activate"}
|
||||
</button>
|
||||
: <div className="companion-unlock-requirement">
|
||||
{"🔒 Unlock: "}
|
||||
{formatThreshold(
|
||||
companion.unlock.type,
|
||||
companion.unlock.threshold,
|
||||
)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
<p>
|
||||
{"🔒 Unlock: "}
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(companion.unlock.threshold)
|
||||
: String(companion.unlock.threshold)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
</p>
|
||||
<div className="companion-progress">
|
||||
<progress
|
||||
max={companion.unlock.threshold}
|
||||
value={Math.min(currentProgress, companion.unlock.threshold)}
|
||||
/>
|
||||
<span className="companion-progress-label">
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(currentProgress)
|
||||
: String(currentProgress)}
|
||||
{" / "}
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(companion.unlock.threshold)
|
||||
: String(companion.unlock.threshold)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -154,7 +147,7 @@ const CompanionCard = ({
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionPanel = (): JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
const { formatNumber, setActiveCompanion, state } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
@@ -167,6 +160,15 @@ const CompanionPanel = (): JSX.Element => {
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
const progressByUnlockType: Record<string, number> = {
|
||||
apotheosis: state.apotheosis?.count ?? 0,
|
||||
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
||||
lifetimeGold: state.player.lifetimeGoldEarned,
|
||||
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
||||
prestige: state.prestige.count,
|
||||
transcendence: state.transcendence?.count ?? 0,
|
||||
};
|
||||
|
||||
function handleSelect(companionId: string): void {
|
||||
setActiveCompanion(activeId === companionId
|
||||
? null
|
||||
@@ -204,6 +206,10 @@ const CompanionPanel = (): JSX.Element => {
|
||||
return (
|
||||
<CompanionCard
|
||||
companion={companion}
|
||||
currentProgress={
|
||||
progressByUnlockType[companion.unlock.type] ?? 0
|
||||
}
|
||||
formatNumber={formatNumber}
|
||||
isActive={activeId === companion.id}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
key={companion.id}
|
||||
|
||||
@@ -13,16 +13,24 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||
|
||||
interface SyncNewContentResult {
|
||||
achievementsAdded: number | undefined;
|
||||
adventurersAdded: number | undefined;
|
||||
bossesAdded: number | undefined;
|
||||
bossRewardsPatched: number | undefined;
|
||||
equipmentAdded: number | undefined;
|
||||
explorationAreasAdded: number | undefined;
|
||||
questRewardsPatched: number | undefined;
|
||||
questsAdded: number | undefined;
|
||||
upgradesAdded: number | undefined;
|
||||
zonesAdded: number | undefined;
|
||||
achievementsAdded: number | undefined;
|
||||
achievementsPatched: number | undefined;
|
||||
adventurersAdded: number | undefined;
|
||||
adventurerStatsPatched: number | undefined;
|
||||
bossesAdded: number | undefined;
|
||||
bossesPatched: number | undefined;
|
||||
bossRewardsPatched: number | undefined;
|
||||
craftingRecipesReapplied: number | undefined;
|
||||
equipmentAdded: number | undefined;
|
||||
equipmentPatched: number | undefined;
|
||||
explorationAreasAdded: number | undefined;
|
||||
questRewardsPatched: number | undefined;
|
||||
questsAdded: number | undefined;
|
||||
questsPatched: number | undefined;
|
||||
upgradesAdded: number | undefined;
|
||||
upgradesPatched: number | undefined;
|
||||
zonesAdded: number | undefined;
|
||||
zonesPatched: number | undefined;
|
||||
}
|
||||
|
||||
const safeNumber = (value: number | undefined): number => {
|
||||
@@ -43,9 +51,17 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
||||
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
||||
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
||||
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
|
||||
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
|
||||
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
|
||||
[ safeNumber(result.achievementsAdded), "achievement(s)" ],
|
||||
[ safeNumber(result.questsPatched), "quest stat(s) patched" ],
|
||||
[ safeNumber(result.bossesPatched), "boss stat(s) patched" ],
|
||||
[ safeNumber(result.zonesPatched), "zone stat(s) patched" ],
|
||||
[ safeNumber(result.upgradesPatched), "upgrade stat(s) patched" ],
|
||||
[ safeNumber(result.equipmentPatched), "equipment stat(s) patched" ],
|
||||
[ safeNumber(result.achievementsPatched), "achievement stat(s) patched" ],
|
||||
[ safeNumber(result.craftingRecipesReapplied), "crafting recipe(s) reapplied" ],
|
||||
];
|
||||
const parts = entries.
|
||||
filter(([ count ]) => {
|
||||
|
||||
@@ -225,6 +225,10 @@ const EditProfileModal = ({
|
||||
void handleNotificationsEnable();
|
||||
}
|
||||
|
||||
function handlePrestigeAnnouncementsToggle(): void {
|
||||
toggleSetting("enablePrestigeAnnouncements");
|
||||
}
|
||||
|
||||
const isSaveDisabled = saving || characterName.trim() === "";
|
||||
|
||||
let saveLabel = "Save Profile";
|
||||
@@ -417,6 +421,23 @@ const EditProfileModal = ({
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.enablePrestigeAnnouncements
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handlePrestigeAnnouncementsToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"⭐ Prestige Bot Announcements"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.enablePrestigeAnnouncements
|
||||
? "✓ On"
|
||||
: "Off"
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js";
|
||||
import { EditProfileModal } from "./editProfileModal.js";
|
||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||
import { ExplorationPanel } from "./explorationPanel.js";
|
||||
import { JoinCommunityModal } from "./joinCommunityModal.js";
|
||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||
import { MilestoneToast } from "./milestoneToast.js";
|
||||
import { OfflineModal } from "./offlineModal.js";
|
||||
@@ -164,6 +165,7 @@ const GameLayout = (): JSX.Element => {
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
/>
|
||||
<OfflineModal />
|
||||
<JoinCommunityModal />
|
||||
{schemaOutdated && !dismissedOutdatedWarning
|
||||
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
||||
: null}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @file Modal prompting players to join the NHCarrigan Discord community.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { useCallback, useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
const sessionKey = "elysium_join_community_dismissed";
|
||||
|
||||
/**
|
||||
* Renders a modal prompting the player to join the NHCarrigan Discord server.
|
||||
* Shown once per session when the player is not already in the guild.
|
||||
* @returns The JSX element or null if the player is in the guild or dismissed.
|
||||
*/
|
||||
const JoinCommunityModal = (): JSX.Element | null => {
|
||||
const { inGuild } = useGame();
|
||||
const [ dismissed, setDismissed ] = useState(
|
||||
() => {
|
||||
return sessionStorage.getItem(sessionKey) === "true";
|
||||
},
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback((): void => {
|
||||
sessionStorage.setItem(sessionKey, "true");
|
||||
setDismissed(true);
|
||||
}, []);
|
||||
|
||||
if (inGuild || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>{"Join Our Community!"}</h2>
|
||||
<p>
|
||||
{"Did you know Elysium has an active Discord community? "}
|
||||
{"Join to chat with other players, get updates, and earn "}
|
||||
{"the exclusive Elysian role!"}
|
||||
</p>
|
||||
<p className="modal-note">
|
||||
{"You already earn the Elysian role just by playing — "}
|
||||
{"joining lets us show it off in the server!"}
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<a
|
||||
className="modal-close-button"
|
||||
href="https://discord.gg/KKe7BaEnQB"
|
||||
onClick={handleDismiss}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{"Join Discord"}
|
||||
</a>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={handleDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Maybe later"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { JoinCommunityModal };
|
||||
@@ -12,25 +12,27 @@ import { useState, type JSX } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
PRESTIGE_UPGRADES,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import {
|
||||
computeProjectedRunestones,
|
||||
} from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { sendNotification } from "../../utils/notification.js";
|
||||
import { playSound } from "../../utils/sound.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseThreshold = 1_000_000;
|
||||
const thresholdScale = 5;
|
||||
const runestonesPerLevel = 10;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2.5.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
||||
return baseThreshold * Math.pow(prestigeCount + 1, 2.5);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,33 +41,7 @@ const calculateThreshold = (prestigeCount: number): number => {
|
||||
* @returns The compounding multiplier applied to all income sources.
|
||||
*/
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the runestone preview for a prestige.
|
||||
* @param totalGoldEarned - Total gold earned this run.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
||||
* @returns The predicted runestone reward.
|
||||
*/
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === "runestones"
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
return Math.pow(1.3, prestigeCount);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
@@ -84,13 +60,15 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
const PrestigePanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reload,
|
||||
reloadSilent,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
enableNotifications,
|
||||
enableSounds,
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoPrestige,
|
||||
toggleAutoPrestigeMaxRunestones,
|
||||
triggerPrestigeToast,
|
||||
} = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
@@ -114,12 +92,13 @@ const PrestigePanel = (): JSX.Element => {
|
||||
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const runestonePreview = computeProjectedRunestones(state);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
const baseRunestones = Math.min(
|
||||
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
|
||||
200,
|
||||
);
|
||||
const isAtMaxRunestones = baseRunestones >= 200;
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
setIsPending(true);
|
||||
@@ -141,7 +120,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||
);
|
||||
}
|
||||
await reload();
|
||||
await reloadSilent();
|
||||
} catch (error_: unknown) {
|
||||
setPrestigeError(
|
||||
error_ instanceof Error
|
||||
@@ -182,6 +161,10 @@ const PrestigePanel = (): JSX.Element => {
|
||||
toggleAutoPrestige();
|
||||
}
|
||||
|
||||
function handleAutoPrestigeMaxRunestonesToggle(): void {
|
||||
toggleAutoPrestigeMaxRunestones();
|
||||
}
|
||||
|
||||
function handlePrestigeTabClick(): void {
|
||||
setActiveTab("prestige");
|
||||
}
|
||||
@@ -215,7 +198,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
type="button"
|
||||
>
|
||||
{"🔮 Runestone Shop ("}
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{formatInteger(prestigeData.runestones)}
|
||||
{" stones)"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -226,7 +209,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
{"Prestige resets your progress but grants "}
|
||||
<strong>{"Runestones"}</strong>
|
||||
{"— permanent currency used for powerful upgrades."}
|
||||
{" Each prestige multiplies your global production by ×1.15"}
|
||||
{" Each prestige multiplies your global production by ×1.3"}
|
||||
{" (compounding each run)."}
|
||||
</p>
|
||||
|
||||
@@ -259,15 +242,25 @@ const PrestigePanel = (): JSX.Element => {
|
||||
</p>
|
||||
<p>
|
||||
{"Runestones: "}
|
||||
<strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
<strong>{formatInteger(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="runestone-preview">
|
||||
{"Runestones on prestige: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(runestonePreview)}
|
||||
{formatInteger(runestonePreview)}
|
||||
</strong>
|
||||
{isAtMaxRunestones
|
||||
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
{isEligible && !isAtMaxRunestones
|
||||
? <p className="runestone-progress-hint">
|
||||
{"Earn more gold to increase your runestone yield "
|
||||
+ "(capped at ×14³ the prestige threshold)."}
|
||||
</p>
|
||||
: null}
|
||||
{isEligible
|
||||
@@ -296,7 +289,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
: `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError === null
|
||||
? null
|
||||
@@ -308,12 +301,12 @@ const PrestigePanel = (): JSX.Element => {
|
||||
{"Ascended to Prestige "}
|
||||
{result.count}
|
||||
{"! Earned "}
|
||||
{formatNumber(result.runestones)}
|
||||
{formatInteger(result.runestones)}
|
||||
{" Runestones."}
|
||||
{result.milestoneRunestones > 0
|
||||
&& <>
|
||||
{" 🎉 Milestone bonus: +"}
|
||||
{formatNumber(result.milestoneRunestones)}
|
||||
{formatInteger(result.milestoneRunestones)}
|
||||
{" Runestones!"}
|
||||
</>
|
||||
}
|
||||
@@ -334,7 +327,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{formatInteger(prestigeData.runestones)}
|
||||
{" Runestones"}
|
||||
</strong>
|
||||
</p>
|
||||
@@ -359,6 +352,8 @@ const PrestigePanel = (): JSX.Element => {
|
||||
= upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled
|
||||
= prestigeData.autoPrestigeEnabled ?? false;
|
||||
const autoPrestigeMaxRunestonesOnly
|
||||
= prestigeData.autoPrestigeMaxRunestonesOnly ?? false;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
@@ -386,7 +381,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
: `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoAdventurerToggle
|
||||
@@ -405,19 +400,37 @@ const PrestigePanel = (): JSX.Element => {
|
||||
</button>
|
||||
: null}
|
||||
{isAutoPrestigeToggle
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
? <>
|
||||
<button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeMaxRunestonesOnly
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeMaxRunestonesToggle}
|
||||
title="Only fire at max runestone yield"
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeMaxRunestonesOnly
|
||||
? "⚡ Max Runes Only"
|
||||
: "⏸ Max Runes OFF"}
|
||||
</button>
|
||||
: null}
|
||||
</>
|
||||
: null}
|
||||
{purchased
|
||||
? null
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { zoneFailureChance } from "../../engine/tick.js";
|
||||
import {
|
||||
computePartyCombatPower,
|
||||
zoneFailureChance,
|
||||
} from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
@@ -118,6 +121,7 @@ const QuestCard = ({
|
||||
{reward.type === "essence"
|
||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "crystals"
|
||||
&& (reward.amount ?? 0) > 0
|
||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||
@@ -208,7 +212,7 @@ const QuestPanel = (): JSX.Element => {
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, bosses, quests, zones } = state;
|
||||
const { autoQuest, bosses, quests, zones } = state;
|
||||
|
||||
const activeZone = zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
@@ -226,11 +230,7 @@ const QuestPanel = (): JSX.Element => {
|
||||
: quests.find((quest) => {
|
||||
return quest.id === activeZone.unlockQuestId;
|
||||
});
|
||||
let partyCombatPower = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const contribution = adventurer.combatPower * adventurer.count;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
const partyCombatPower = computePartyCombatPower(state);
|
||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||
return zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ const StatCard = ({
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatisticsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const { state, formatInteger, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
@@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => {
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
value={formatInteger(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
sub="permanent currency"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
value={formatInteger(prestige.runestones)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const TranscendencePanel = (): JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const { state, formatInteger, transcend, buyEchoUpgrade } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
echoes: number;
|
||||
@@ -152,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
type="button"
|
||||
>
|
||||
{"✨ Echo Shop ("}
|
||||
{formatNumber(currentEchoes)}
|
||||
{formatInteger(currentEchoes)}
|
||||
{" echoes)"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
}
|
||||
<p>
|
||||
{"Current Echoes: "}
|
||||
<strong>{formatNumber(currentEchoes)}</strong>
|
||||
<strong>{formatInteger(currentEchoes)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current prestige count: "}
|
||||
@@ -195,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
{"Echoes on transcendence: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(echoPreview)}
|
||||
{formatInteger(echoPreview)}
|
||||
</strong>
|
||||
{echoMetaMultiplier > 1
|
||||
&& <span className="echo-meta-bonus">
|
||||
@@ -238,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
: `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
@@ -248,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
: <p className="success">
|
||||
{"Transcended! Earned "}
|
||||
<strong>
|
||||
{formatNumber(result.echoes)}
|
||||
{formatInteger(result.echoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
{". This is Transcendence "}
|
||||
@@ -266,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(currentEchoes)}
|
||||
{formatInteger(currentEchoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
</p>
|
||||
@@ -314,7 +314,7 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
||||
: `✨ ${formatInteger(upgrade.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||
/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */
|
||||
/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
@@ -238,6 +240,22 @@ const UpgradePanel = (): JSX.Element => {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const upgrade of locked) {
|
||||
if (
|
||||
!upgradeUnlockHints.has(upgrade.id)
|
||||
&& upgrade.adventurerId !== undefined
|
||||
) {
|
||||
const adventurerForHint = adventurers.find((a) => {
|
||||
return a.id === upgrade.adventurerId;
|
||||
});
|
||||
if (adventurerForHint !== undefined) {
|
||||
upgradeUnlockHints.set(
|
||||
upgrade.id,
|
||||
`🗡️ Recruit: ${adventurerForHint.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||
import { useState, type FocusEvent, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
|
||||
import {
|
||||
RESOURCE_CAP,
|
||||
computeEssencePerSecond,
|
||||
computeGoldPerSecond,
|
||||
computePartyCombatPower,
|
||||
computeProjectedRunestones,
|
||||
} from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
|
||||
interface ResourceBarProperties {
|
||||
@@ -76,19 +82,20 @@ const ResourceBar = ({
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProperties): JSX.Element => {
|
||||
const { formatNumber, syncError, state } = useGame();
|
||||
const { formatInteger, formatNumber, syncError, state } = useGame();
|
||||
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||
|
||||
const { gold, essence, crystals } = resources;
|
||||
let partyCombatPower = 0;
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
let projectedRunestones = 0;
|
||||
if (state !== null) {
|
||||
for (const adventurer of state.adventurers) {
|
||||
const contribution = adventurer.combatPower * adventurer.count;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
partyCombatPower = computePartyCombatPower(state);
|
||||
goldPerSecond = computeGoldPerSecond(state);
|
||||
essencePerSecond = computeEssencePerSecond(state);
|
||||
projectedRunestones = computeProjectedRunestones(state);
|
||||
}
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
@@ -155,7 +162,7 @@ const ResourceBar = ({
|
||||
title="Click to see all resources"
|
||||
type="button"
|
||||
>
|
||||
<span className="resource-icon">{"🪙"}</span>
|
||||
<span className="resource-icon">{"💰"}</span>
|
||||
<span className="resource-value">{formatNumber(gold)}</span>
|
||||
<span className="resource-label">{"Gold"}</span>
|
||||
{goldFull
|
||||
@@ -182,6 +189,13 @@ const ResourceBar = ({
|
||||
</span>
|
||||
<span className="resource-label">{"Gold/s"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⚡"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(essencePerSecond)}
|
||||
</span>
|
||||
<span className="resource-label">{"Essence/s"}</span>
|
||||
</div>
|
||||
<div className={`resource${essenceFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
@@ -204,7 +218,7 @@ const ResourceBar = ({
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"💎"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(crystals)}
|
||||
{formatInteger(crystals)}
|
||||
</span>
|
||||
<span className="resource-label">{"Crystals"}</span>
|
||||
{crystalsFull
|
||||
@@ -219,10 +233,17 @@ const ResourceBar = ({
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"🔮"}</span>
|
||||
<span className="resource-value">
|
||||
{formatNumber(runestones)}
|
||||
{formatInteger(runestones)}
|
||||
</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⭐"}</span>
|
||||
<span className="resource-value">
|
||||
{`+${formatInteger(projectedRunestones)}`}
|
||||
</span>
|
||||
<span className="resource-label">{"On Prestige"}</span>
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"⚔️"}</span>
|
||||
<span className="resource-value">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type NumberFormat,
|
||||
type Quest,
|
||||
type TranscendenceResponse,
|
||||
computeUnlockedCompanionIds,
|
||||
isStoryChapterUnlocked,
|
||||
} from "@elysium/types";
|
||||
import {
|
||||
@@ -53,21 +54,25 @@ import {
|
||||
transcend as transcendApi,
|
||||
} from "../api/client.js";
|
||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||
import { RECIPES } from "../data/recipes.js";
|
||||
import {
|
||||
RESOURCE_CAP,
|
||||
applyTick,
|
||||
calculateClickPower,
|
||||
computePartyCombatPower,
|
||||
} from "../engine/tick.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||
import {
|
||||
formatInteger as formatIntegerUtil,
|
||||
formatNumber as formatNumberUtil,
|
||||
} from "../utils/format.js";
|
||||
import { logError } from "../utils/logError.js";
|
||||
import { sendNotification } from "../utils/notification.js";
|
||||
import { playSound } from "../utils/sound.js";
|
||||
|
||||
const autoSaveIntervalMs = 30_000;
|
||||
const autoPrestigeThresholdBase = 1_000_000;
|
||||
const autoPrestigeThresholdScale = 5;
|
||||
|
||||
/**
|
||||
* Pure function — applies a boss challenge result to the game state.
|
||||
@@ -115,6 +120,9 @@ const applyBossResult = (
|
||||
}).
|
||||
filter(Boolean),
|
||||
);
|
||||
const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => {
|
||||
return z.id;
|
||||
}));
|
||||
|
||||
const challengeUpdate
|
||||
= previous.dailyChallenges === undefined
|
||||
@@ -215,6 +223,23 @@ const applyBossResult = (
|
||||
? { ...u, unlocked: true }
|
||||
: u;
|
||||
}),
|
||||
...newlyUnlockedZoneIds.size === 0 || previous.exploration === undefined
|
||||
? {}
|
||||
: {
|
||||
exploration: {
|
||||
...previous.exploration,
|
||||
areas: previous.exploration.areas.map((area) => {
|
||||
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
||||
return definition.id === area.id;
|
||||
});
|
||||
return areaDefinition !== undefined
|
||||
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
||||
&& area.status === "locked"
|
||||
? { ...area, status: "available" as const }
|
||||
: area;
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,6 +268,11 @@ interface GameContextValue {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/**
|
||||
* Whether the player is currently a member of the NHCarrigan Discord server.
|
||||
*/
|
||||
inGuild: boolean;
|
||||
|
||||
/**
|
||||
* Click the crystal to earn gold.
|
||||
*/
|
||||
@@ -283,6 +313,12 @@ interface GameContextValue {
|
||||
*/
|
||||
reload: ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Reload state from the server without showing the loading screen (used
|
||||
* after prestige to avoid the visible flash/hang).
|
||||
*/
|
||||
reloadSilent: ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Unix timestamp of the last successful cloud save (null until first save response).
|
||||
*/
|
||||
@@ -428,6 +464,11 @@ interface GameContextValue {
|
||||
*/
|
||||
formatNumber: (value: number)=> string;
|
||||
|
||||
/**
|
||||
* Format a whole-number value without decimal places.
|
||||
*/
|
||||
formatInteger: (value: number)=> string;
|
||||
|
||||
/**
|
||||
* Buy a prestige upgrade from the runestone shop.
|
||||
*/
|
||||
@@ -438,6 +479,11 @@ interface GameContextValue {
|
||||
*/
|
||||
toggleAutoPrestige: ()=> void;
|
||||
|
||||
/**
|
||||
* Toggle whether auto-prestige waits for maximum runestone yield before firing.
|
||||
*/
|
||||
toggleAutoPrestigeMaxRunestones: ()=> void;
|
||||
|
||||
/**
|
||||
* Toggle the auto-quest setting on/off.
|
||||
*/
|
||||
@@ -580,16 +626,24 @@ interface GameContextValue {
|
||||
* @returns Counts of what was added per content type.
|
||||
*/
|
||||
syncNewContent: ()=> Promise<{
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
bossRewardsPatched: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
achievementsAdded: number;
|
||||
achievementsPatched: number;
|
||||
adventurerStatsPatched: number;
|
||||
adventurersAdded: number;
|
||||
bossRewardsPatched: number;
|
||||
bossesAdded: number;
|
||||
bossesPatched: number;
|
||||
craftingRecipesReapplied: number;
|
||||
equipmentAdded: number;
|
||||
equipmentPatched: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
questsPatched: number;
|
||||
upgradesAdded: number;
|
||||
upgradesPatched: number;
|
||||
zonesAdded: number;
|
||||
zonesPatched: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -683,9 +737,14 @@ export const GameProvider = ({
|
||||
|
||||
/* No-op placeholder */
|
||||
});
|
||||
const reloadSilentReference = useRef<()=> Promise<void>>(async() => {
|
||||
|
||||
/* No-op placeholder */
|
||||
});
|
||||
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
|
||||
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
|
||||
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
|
||||
const [ inGuild, setInGuild ] = useState(false);
|
||||
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
|
||||
Array<string>
|
||||
>([]);
|
||||
@@ -723,6 +782,7 @@ export const GameProvider = ({
|
||||
setSchemaOutdated(data.schemaOutdated);
|
||||
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||
setInGuild(data.inGuild);
|
||||
|
||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||
void fetch(`/api/profile/${data.state.player.discordId}`).
|
||||
@@ -768,6 +828,32 @@ export const GameProvider = ({
|
||||
|
||||
reloadReference.current = reload;
|
||||
|
||||
const reloadSilent = useCallback(async() => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await loadGame();
|
||||
setState(data.state);
|
||||
setLastSavedAt(data.state.player.lastSavedAt);
|
||||
if (data.signature !== undefined) {
|
||||
signatureReference.current = data.signature;
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
setLoginStreak(data.loginStreak);
|
||||
setSchemaOutdated(data.schemaOutdated);
|
||||
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||
setInGuild(data.inGuild);
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load game",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
reloadSilentReference.current = reloadSilent;
|
||||
|
||||
useEffect(() => {
|
||||
enableSoundsReference.current = enableSounds;
|
||||
}, [ enableSounds ]);
|
||||
@@ -1038,6 +1124,57 @@ export const GameProvider = ({
|
||||
});
|
||||
}, [ state ]);
|
||||
|
||||
// Detect newly unlocked companions whenever relevant state changes
|
||||
useEffect(() => {
|
||||
if (state === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedUnlocks = computeUnlockedCompanionIds({
|
||||
apotheosisCount: state.apotheosis?.count ?? 0,
|
||||
lifetimeBossesDefeated: state.player.lifetimeBossesDefeated,
|
||||
lifetimeGoldEarned: state.player.lifetimeGoldEarned,
|
||||
lifetimeQuestsCompleted: state.player.lifetimeQuestsCompleted,
|
||||
prestigeCount: state.prestige.count,
|
||||
transcendenceCount: state.transcendence?.count ?? 0,
|
||||
});
|
||||
|
||||
const currentUnlocks = state.companions?.unlockedCompanionIds ?? [];
|
||||
const toAdd = computedUnlocks.filter((id) => {
|
||||
return !currentUnlocks.includes(id);
|
||||
});
|
||||
|
||||
if (toAdd.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
return previous;
|
||||
}
|
||||
const existingUnlocks = previous.companions?.unlockedCompanionIds ?? [];
|
||||
const addedIds = computedUnlocks.filter((id) => {
|
||||
return !existingUnlocks.includes(id);
|
||||
});
|
||||
if (addedIds.length === 0) {
|
||||
return previous;
|
||||
}
|
||||
const updatedUnlocks = [ ...existingUnlocks, ...addedIds ];
|
||||
const activeId = previous.companions?.activeCompanionId ?? null;
|
||||
const validatedActiveId
|
||||
= activeId !== null && updatedUnlocks.includes(activeId)
|
||||
? activeId
|
||||
: null;
|
||||
return {
|
||||
...previous,
|
||||
companions: {
|
||||
activeCompanionId: validatedActiveId,
|
||||
unlockedCompanionIds: updatedUnlocks,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [ state ]);
|
||||
|
||||
// Game loop via requestAnimationFrame
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1063,11 +1200,7 @@ export const GameProvider = ({
|
||||
return q.status === "active";
|
||||
});
|
||||
if (!hasActiveQuest) {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
||||
const partyCombatPower = next.adventurers.reduce((total, a) => {
|
||||
const power = total + a.combatPower;
|
||||
return power * a.count;
|
||||
}, 0);
|
||||
const partyCombatPower = computePartyCombatPower(next);
|
||||
const zoneOrder = new Map(
|
||||
next.zones.map((z, index) => {
|
||||
return [ z.id, index ];
|
||||
@@ -1105,14 +1238,31 @@ export const GameProvider = ({
|
||||
next.autoAdventurer === true
|
||||
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
|
||||
) {
|
||||
const maxAdventurerLevel = Math.max(
|
||||
...next.adventurers.
|
||||
filter((a) => {
|
||||
return a.unlocked;
|
||||
}).
|
||||
map((a) => {
|
||||
return a.level;
|
||||
}),
|
||||
);
|
||||
const autoBuyCap = 100;
|
||||
const [ bestAdventurer ] = next.adventurers.
|
||||
filter((adventurer) => {
|
||||
const cost
|
||||
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
|
||||
return adventurer.unlocked && next.resources.gold >= cost;
|
||||
const isMaxTier = adventurer.level === maxAdventurerLevel;
|
||||
const withinCap
|
||||
= isMaxTier || adventurer.count < autoBuyCap;
|
||||
return (
|
||||
adventurer.unlocked
|
||||
&& next.resources.gold >= cost
|
||||
&& withinCap
|
||||
);
|
||||
}).
|
||||
sort((adventurerA, adventurerB) => {
|
||||
return adventurerB.combatPower - adventurerA.combatPower;
|
||||
return adventurerB.level - adventurerA.level;
|
||||
});
|
||||
if (bestAdventurer !== undefined) {
|
||||
const purchaseCost
|
||||
@@ -1246,14 +1396,27 @@ export const GameProvider = ({
|
||||
|
||||
// Auto-prestige: fire when unlocked, enabled, and threshold is met
|
||||
const autoState = stateReference.current;
|
||||
const autoPrestigeThreshold = autoPrestigeThresholdBase
|
||||
* Math.pow((autoState?.prestige.count ?? 0) + 1, 2.5)
|
||||
* (autoState?.transcendence?.echoPrestigeThresholdMultiplier ?? 1);
|
||||
const autoBaseRunestones = Math.min(
|
||||
Math.floor(
|
||||
Math.cbrt(
|
||||
(autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold,
|
||||
),
|
||||
) * 15,
|
||||
200,
|
||||
);
|
||||
const autoMaxRunestonesMet
|
||||
= autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true
|
||||
|| autoBaseRunestones >= 200;
|
||||
if (
|
||||
!isAutoPrestigingReference.current
|
||||
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
|
||||
=== true
|
||||
&& autoState.prestige.autoPrestigeEnabled === true
|
||||
&& autoState.player.totalGoldEarned
|
||||
>= autoPrestigeThresholdBase
|
||||
* Math.pow(autoPrestigeThresholdScale, autoState.prestige.count)
|
||||
&& autoState.player.totalGoldEarned >= autoPrestigeThreshold
|
||||
&& autoMaxRunestonesMet
|
||||
) {
|
||||
isAutoPrestigingReference.current = true;
|
||||
void prestigeApi({}).
|
||||
@@ -1265,7 +1428,7 @@ export const GameProvider = ({
|
||||
if (enableNotificationsReference.current) {
|
||||
sendNotification("⭐ Prestige!", "You have ascended!");
|
||||
}
|
||||
await reloadReference.current();
|
||||
await reloadSilentReference.current();
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
@@ -1331,6 +1494,22 @@ export const GameProvider = ({
|
||||
}
|
||||
return afterBoss;
|
||||
});
|
||||
|
||||
/*
|
||||
* Boss fight modifies server state; update signature chain so
|
||||
* the next pre-save or auto-save sends the correct token.
|
||||
*/
|
||||
if (result.signature === undefined) {
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
} else {
|
||||
signatureReference.current = result.signature;
|
||||
localStorage.setItem(
|
||||
"elysium_save_signature",
|
||||
result.signature,
|
||||
);
|
||||
}
|
||||
lastSaveReference.current = Date.now();
|
||||
setAutoBossLastResult({
|
||||
at: Date.now(),
|
||||
bossName: bossName,
|
||||
@@ -1774,7 +1953,18 @@ export const GameProvider = ({
|
||||
|
||||
const collectExploration = useCallback(
|
||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||
isSyncingReference.current = true;
|
||||
const result = await collectExplorationApi({ areaId });
|
||||
|
||||
/*
|
||||
* Collect mutates server state outside the normal save flow — clear the
|
||||
* stale HMAC signature and reset the timer so the next auto-save fires
|
||||
* after React has re-rendered with the new materials in stateReference.
|
||||
*/
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
lastSaveReference.current = Date.now();
|
||||
isSyncingReference.current = false;
|
||||
setState((previous) => {
|
||||
if (previous?.exploration === undefined) {
|
||||
return previous;
|
||||
@@ -1901,6 +2091,22 @@ export const GameProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAutoPrestigeMaxRunestones = useCallback(() => {
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
prestige: {
|
||||
...previous.prestige,
|
||||
autoPrestigeMaxRunestonesOnly:
|
||||
previous.prestige.autoPrestigeMaxRunestonesOnly !== true,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAutoQuest = useCallback(() => {
|
||||
setState((previous) => {
|
||||
if (previous === null) {
|
||||
@@ -1980,6 +2186,14 @@ export const GameProvider = ({
|
||||
}
|
||||
return applyBossResult(previous, bossId, result);
|
||||
});
|
||||
if (result.signature === undefined) {
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
} else {
|
||||
signatureReference.current = result.signature;
|
||||
localStorage.setItem("elysium_save_signature", result.signature);
|
||||
}
|
||||
lastSaveReference.current = Date.now();
|
||||
setBattleResult({ bossName: boss.name, result: result });
|
||||
} catch (error_: unknown) {
|
||||
const bossErrorMessage
|
||||
@@ -2170,16 +2384,24 @@ export const GameProvider = ({
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
return {
|
||||
achievementsAdded: data.achievementsAdded,
|
||||
adventurersAdded: data.adventurersAdded,
|
||||
bossRewardsPatched: data.bossRewardsPatched,
|
||||
bossesAdded: data.bossesAdded,
|
||||
equipmentAdded: data.equipmentAdded,
|
||||
explorationAreasAdded: data.explorationAreasAdded,
|
||||
questRewardsPatched: data.questRewardsPatched,
|
||||
questsAdded: data.questsAdded,
|
||||
upgradesAdded: data.upgradesAdded,
|
||||
zonesAdded: data.zonesAdded,
|
||||
achievementsAdded: data.achievementsAdded,
|
||||
achievementsPatched: data.achievementsPatched,
|
||||
adventurerStatsPatched: data.adventurerStatsPatched,
|
||||
adventurersAdded: data.adventurersAdded,
|
||||
bossRewardsPatched: data.bossRewardsPatched,
|
||||
bossesAdded: data.bossesAdded,
|
||||
bossesPatched: data.bossesPatched,
|
||||
craftingRecipesReapplied: data.craftingRecipesReapplied,
|
||||
equipmentAdded: data.equipmentAdded,
|
||||
equipmentPatched: data.equipmentPatched,
|
||||
explorationAreasAdded: data.explorationAreasAdded,
|
||||
questRewardsPatched: data.questRewardsPatched,
|
||||
questsAdded: data.questsAdded,
|
||||
questsPatched: data.questsPatched,
|
||||
upgradesAdded: data.upgradesAdded,
|
||||
upgradesPatched: data.upgradesPatched,
|
||||
zonesAdded: data.zonesAdded,
|
||||
zonesPatched: data.zonesPatched,
|
||||
};
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
@@ -2188,16 +2410,24 @@ export const GameProvider = ({
|
||||
: "Failed to sync new content",
|
||||
);
|
||||
return {
|
||||
achievementsAdded: 0,
|
||||
adventurersAdded: 0,
|
||||
bossRewardsPatched: 0,
|
||||
bossesAdded: 0,
|
||||
equipmentAdded: 0,
|
||||
explorationAreasAdded: 0,
|
||||
questRewardsPatched: 0,
|
||||
questsAdded: 0,
|
||||
upgradesAdded: 0,
|
||||
zonesAdded: 0,
|
||||
achievementsAdded: 0,
|
||||
achievementsPatched: 0,
|
||||
adventurerStatsPatched: 0,
|
||||
adventurersAdded: 0,
|
||||
bossRewardsPatched: 0,
|
||||
bossesAdded: 0,
|
||||
bossesPatched: 0,
|
||||
craftingRecipesReapplied: 0,
|
||||
equipmentAdded: 0,
|
||||
equipmentPatched: 0,
|
||||
explorationAreasAdded: 0,
|
||||
questRewardsPatched: 0,
|
||||
questsAdded: 0,
|
||||
questsPatched: 0,
|
||||
upgradesAdded: 0,
|
||||
upgradesPatched: 0,
|
||||
zonesAdded: 0,
|
||||
zonesPatched: 0,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
@@ -2239,6 +2469,13 @@ export const GameProvider = ({
|
||||
[ numberFormat ],
|
||||
);
|
||||
|
||||
const formatInteger = useCallback(
|
||||
(value: number) => {
|
||||
return formatIntegerUtil(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<GameContextValue>(() => {
|
||||
return {
|
||||
apotheosis,
|
||||
@@ -2277,8 +2514,10 @@ export const GameProvider = ({
|
||||
flushBossLoreToasts,
|
||||
forceSync,
|
||||
forceUnlocks,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
handleClick,
|
||||
inGuild,
|
||||
isLoading,
|
||||
isSyncing,
|
||||
lastSavedAt,
|
||||
@@ -2288,6 +2527,7 @@ export const GameProvider = ({
|
||||
offlineEssence,
|
||||
offlineGold,
|
||||
reload,
|
||||
reloadSilent,
|
||||
resetProgress,
|
||||
saveSchemaVersion,
|
||||
schemaOutdated,
|
||||
@@ -2306,6 +2546,7 @@ export const GameProvider = ({
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
toggleAutoPrestigeMaxRunestones,
|
||||
toggleAutoQuest,
|
||||
transcend,
|
||||
triggerPrestigeToast,
|
||||
@@ -2321,6 +2562,7 @@ export const GameProvider = ({
|
||||
bossError,
|
||||
completedQuestToasts,
|
||||
failedQuestToasts,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
buyAdventurer,
|
||||
buyEchoUpgrade,
|
||||
@@ -2350,6 +2592,7 @@ export const GameProvider = ({
|
||||
error,
|
||||
flushBossLoreToasts,
|
||||
forceSync,
|
||||
inGuild,
|
||||
forceUnlocks,
|
||||
handleClick,
|
||||
isLoading,
|
||||
@@ -2379,6 +2622,7 @@ export const GameProvider = ({
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
toggleAutoPrestigeMaxRunestones,
|
||||
toggleAutoQuest,
|
||||
transcend,
|
||||
triggerPrestigeToast,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
|
||||
durationSeconds: 3600,
|
||||
durationSeconds: 300,
|
||||
id: "verdant_meadow",
|
||||
name: "The Verdant Meadow",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -28,7 +28,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
|
||||
durationSeconds: 7200,
|
||||
durationSeconds: 600,
|
||||
id: "whispering_forest",
|
||||
name: "The Whispering Forest",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -36,7 +36,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
|
||||
durationSeconds: 10_800,
|
||||
durationSeconds: 900,
|
||||
id: "ancient_grove",
|
||||
name: "The Ancient Grove",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -44,7 +44,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
|
||||
durationSeconds: 14_400,
|
||||
durationSeconds: 1200,
|
||||
id: "forbidden_glen",
|
||||
name: "The Forbidden Glen",
|
||||
zoneId: "verdant_vale",
|
||||
@@ -54,7 +54,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
|
||||
durationSeconds: 7200,
|
||||
durationSeconds: 600,
|
||||
id: "collapsed_outpost",
|
||||
name: "The Collapsed Outpost",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -62,7 +62,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
|
||||
durationSeconds: 14_400,
|
||||
durationSeconds: 1200,
|
||||
id: "cursed_lake",
|
||||
name: "The Cursed Lake",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -70,7 +70,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
|
||||
durationSeconds: 21_600,
|
||||
durationSeconds: 1800,
|
||||
id: "runic_archive",
|
||||
name: "The Runic Archive",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -78,7 +78,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
|
||||
durationSeconds: 28_800,
|
||||
durationSeconds: 2400,
|
||||
id: "dragon_throne",
|
||||
name: "The Dragon's Throne",
|
||||
zoneId: "shattered_ruins",
|
||||
@@ -88,7 +88,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
|
||||
durationSeconds: 10_800,
|
||||
durationSeconds: 900,
|
||||
id: "glacial_cave",
|
||||
name: "The Glacial Cave",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -96,7 +96,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
|
||||
durationSeconds: 21_600,
|
||||
durationSeconds: 1800,
|
||||
id: "frozen_tundra",
|
||||
name: "The Frozen Tundra",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -104,7 +104,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
|
||||
durationSeconds: 32_400,
|
||||
durationSeconds: 2700,
|
||||
id: "void_rift",
|
||||
name: "The Void Rift",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -112,7 +112,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
|
||||
durationSeconds: 43_200,
|
||||
durationSeconds: 3600,
|
||||
id: "summit_shrine",
|
||||
name: "The Summit Shrine",
|
||||
zoneId: "frozen_peaks",
|
||||
@@ -122,7 +122,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
|
||||
durationSeconds: 18_000,
|
||||
durationSeconds: 1500,
|
||||
id: "fog_hollow",
|
||||
name: "The Fog Hollow",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -130,7 +130,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
|
||||
durationSeconds: 36_000,
|
||||
durationSeconds: 3000,
|
||||
id: "dark_grotto",
|
||||
name: "The Dark Grotto",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -138,7 +138,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
|
||||
durationSeconds: 54_000,
|
||||
durationSeconds: 5400,
|
||||
id: "cursed_barrow",
|
||||
name: "The Cursed Barrow",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -146,7 +146,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
|
||||
durationSeconds: 72_000,
|
||||
durationSeconds: 5400,
|
||||
id: "marsh_depths",
|
||||
name: "The Marsh Depths",
|
||||
zoneId: "shadow_marshes",
|
||||
@@ -156,7 +156,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
|
||||
durationSeconds: 25_200,
|
||||
durationSeconds: 2100,
|
||||
id: "magma_tunnel",
|
||||
name: "The Magma Tunnel",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -164,7 +164,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
|
||||
durationSeconds: 50_400,
|
||||
durationSeconds: 3600,
|
||||
id: "forge_chamber",
|
||||
name: "The Forge Chamber",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -172,7 +172,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
|
||||
durationSeconds: 75_600,
|
||||
durationSeconds: 7200,
|
||||
id: "fire_temple",
|
||||
name: "The Fire Temple",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -180,7 +180,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
|
||||
durationSeconds: 100_800,
|
||||
durationSeconds: 9000,
|
||||
id: "core_descent",
|
||||
name: "The Core Descent",
|
||||
zoneId: "volcanic_depths",
|
||||
@@ -190,7 +190,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
|
||||
durationSeconds: 36_000,
|
||||
durationSeconds: 3000,
|
||||
id: "star_field",
|
||||
name: "The Star Field",
|
||||
zoneId: "astral_void",
|
||||
@@ -198,7 +198,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
|
||||
durationSeconds: 72_000,
|
||||
durationSeconds: 5400,
|
||||
id: "probability_sea",
|
||||
name: "The Probability Sea",
|
||||
zoneId: "astral_void",
|
||||
@@ -206,7 +206,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
|
||||
durationSeconds: 108_000,
|
||||
durationSeconds: 9000,
|
||||
id: "void_current",
|
||||
name: "The Void Current",
|
||||
zoneId: "astral_void",
|
||||
@@ -214,7 +214,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
|
||||
durationSeconds: 144_000,
|
||||
durationSeconds: 12_600,
|
||||
id: "null_zenith",
|
||||
name: "The Null Zenith",
|
||||
zoneId: "astral_void",
|
||||
@@ -224,7 +224,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
|
||||
durationSeconds: 43_200,
|
||||
durationSeconds: 3600,
|
||||
id: "light_spire",
|
||||
name: "The Light Spire",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -232,7 +232,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
|
||||
durationSeconds: 86_400,
|
||||
durationSeconds: 7200,
|
||||
id: "choir_hall",
|
||||
name: "The Choir Hall",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -240,7 +240,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
|
||||
durationSeconds: 129_600,
|
||||
durationSeconds: 10_800,
|
||||
id: "divine_court",
|
||||
name: "The Divine Court",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -248,7 +248,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
|
||||
durationSeconds: 172_800,
|
||||
durationSeconds: 14_400,
|
||||
id: "celestial_vault",
|
||||
name: "The Celestial Vault",
|
||||
zoneId: "celestial_reaches",
|
||||
@@ -258,7 +258,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
|
||||
durationSeconds: 50_400,
|
||||
durationSeconds: 3600,
|
||||
id: "trench_entrance",
|
||||
name: "The Trench Entrance",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -266,7 +266,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
|
||||
durationSeconds: 100_800,
|
||||
durationSeconds: 9000,
|
||||
id: "deep_current",
|
||||
name: "The Deep Current",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -274,7 +274,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
|
||||
durationSeconds: 151_200,
|
||||
durationSeconds: 12_600,
|
||||
id: "sunless_chamber",
|
||||
name: "The Sunless Chamber",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -282,7 +282,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
|
||||
durationSeconds: 201_600,
|
||||
durationSeconds: 16_200,
|
||||
id: "the_waiting_place",
|
||||
name: "The Waiting Place",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -292,7 +292,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
|
||||
durationSeconds: 57_600,
|
||||
durationSeconds: 5400,
|
||||
id: "demon_market",
|
||||
name: "The Demon Market",
|
||||
zoneId: "infernal_court",
|
||||
@@ -300,7 +300,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
|
||||
durationSeconds: 115_200,
|
||||
durationSeconds: 9000,
|
||||
id: "torment_hall",
|
||||
name: "The Torment Hall",
|
||||
zoneId: "infernal_court",
|
||||
@@ -308,7 +308,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
|
||||
durationSeconds: 172_800,
|
||||
durationSeconds: 14_400,
|
||||
id: "soul_forge",
|
||||
name: "The Soul Forge",
|
||||
zoneId: "infernal_court",
|
||||
@@ -316,7 +316,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
|
||||
durationSeconds: 230_400,
|
||||
durationSeconds: 19_800,
|
||||
id: "lords_chamber",
|
||||
name: "The Lords' Chamber",
|
||||
zoneId: "infernal_court",
|
||||
@@ -326,7 +326,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
|
||||
durationSeconds: 64_800,
|
||||
durationSeconds: 5400,
|
||||
id: "facet_approach",
|
||||
name: "The Facet Approach",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -334,7 +334,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
|
||||
durationSeconds: 129_600,
|
||||
durationSeconds: 10_800,
|
||||
id: "calculation_chamber",
|
||||
name: "The Calculation Chamber",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -342,7 +342,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
|
||||
durationSeconds: 194_400,
|
||||
durationSeconds: 16_200,
|
||||
id: "mirror_hall",
|
||||
name: "The Mirror Hall",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -350,7 +350,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
|
||||
durationSeconds: 259_200,
|
||||
durationSeconds: 21_600,
|
||||
id: "core_access",
|
||||
name: "The Core Access",
|
||||
zoneId: "crystalline_spire",
|
||||
@@ -360,7 +360,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
|
||||
durationSeconds: 72_000,
|
||||
durationSeconds: 5400,
|
||||
id: "threshold",
|
||||
name: "The Threshold",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -368,7 +368,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
|
||||
durationSeconds: 144_000,
|
||||
durationSeconds: 12_600,
|
||||
id: "inner_silence",
|
||||
name: "The Inner Silence",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -376,7 +376,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
|
||||
durationSeconds: 216_000,
|
||||
durationSeconds: 18_000,
|
||||
id: "resonance_chamber",
|
||||
name: "The Resonance Chamber",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -384,7 +384,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
|
||||
durationSeconds: 288_000,
|
||||
durationSeconds: 25_200,
|
||||
id: "sanctum_heart",
|
||||
name: "The Sanctum Heart",
|
||||
zoneId: "void_sanctum",
|
||||
@@ -394,7 +394,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
|
||||
durationSeconds: 79_200,
|
||||
durationSeconds: 7200,
|
||||
id: "throne_approach",
|
||||
name: "The Throne Approach",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -402,7 +402,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
|
||||
durationSeconds: 158_400,
|
||||
durationSeconds: 12_600,
|
||||
id: "dominion_hall",
|
||||
name: "The Dominion Hall",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -410,7 +410,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
|
||||
durationSeconds: 237_600,
|
||||
durationSeconds: 19_800,
|
||||
id: "eternity_vault",
|
||||
name: "The Eternity Vault",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -418,7 +418,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
|
||||
durationSeconds: 316_800,
|
||||
durationSeconds: 25_200,
|
||||
id: "the_seat",
|
||||
name: "The Seat",
|
||||
zoneId: "eternal_throne",
|
||||
@@ -428,7 +428,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
|
||||
durationSeconds: 86_400,
|
||||
durationSeconds: 7200,
|
||||
id: "creation_storm",
|
||||
name: "The Creation Storm",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -436,7 +436,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
|
||||
durationSeconds: 172_800,
|
||||
durationSeconds: 14_400,
|
||||
id: "unmaking_sea",
|
||||
name: "The Unmaking Sea",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -444,7 +444,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
|
||||
durationSeconds: 259_200,
|
||||
durationSeconds: 21_600,
|
||||
id: "probability_void",
|
||||
name: "The Probability Void",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -452,7 +452,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
|
||||
durationSeconds: 345_600,
|
||||
durationSeconds: 28_800,
|
||||
id: "chaos_core",
|
||||
name: "The Chaos Core",
|
||||
zoneId: "primordial_chaos",
|
||||
@@ -462,7 +462,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
|
||||
durationSeconds: 93_600,
|
||||
durationSeconds: 7200,
|
||||
id: "first_horizon",
|
||||
name: "The First Horizon",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -470,7 +470,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
|
||||
durationSeconds: 187_200,
|
||||
durationSeconds: 16_200,
|
||||
id: "middle_nowhere",
|
||||
name: "The Middle of Nowhere",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -478,7 +478,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
|
||||
durationSeconds: 280_800,
|
||||
durationSeconds: 25_200,
|
||||
id: "edge_approach",
|
||||
name: "The Edge Approach",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -486,7 +486,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
|
||||
durationSeconds: 374_400,
|
||||
durationSeconds: 32_400,
|
||||
id: "the_furthest",
|
||||
name: "The Furthest",
|
||||
zoneId: "infinite_expanse",
|
||||
@@ -496,7 +496,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
|
||||
durationSeconds: 100_800,
|
||||
durationSeconds: 9000,
|
||||
id: "workshop_entrance",
|
||||
name: "The Workshop Entrance",
|
||||
zoneId: "reality_forge",
|
||||
@@ -504,7 +504,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
|
||||
durationSeconds: 201_600,
|
||||
durationSeconds: 16_200,
|
||||
id: "creation_floor",
|
||||
name: "The Creation Floor",
|
||||
zoneId: "reality_forge",
|
||||
@@ -512,7 +512,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
|
||||
durationSeconds: 302_400,
|
||||
durationSeconds: 25_200,
|
||||
id: "master_forge",
|
||||
name: "The Master Forge",
|
||||
zoneId: "reality_forge",
|
||||
@@ -520,7 +520,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
|
||||
durationSeconds: 403_200,
|
||||
durationSeconds: 32_400,
|
||||
id: "forge_core",
|
||||
name: "The Forge Core",
|
||||
zoneId: "reality_forge",
|
||||
@@ -530,7 +530,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
|
||||
durationSeconds: 108_000,
|
||||
durationSeconds: 9000,
|
||||
id: "outer_current",
|
||||
name: "The Outer Current",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -538,7 +538,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
|
||||
durationSeconds: 216_000,
|
||||
durationSeconds: 18_000,
|
||||
id: "debris_field",
|
||||
name: "The Debris Field",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -546,7 +546,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
|
||||
durationSeconds: 324_000,
|
||||
durationSeconds: 28_800,
|
||||
id: "force_confluence",
|
||||
name: "The Force Confluence",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -554,7 +554,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
|
||||
durationSeconds: 432_000,
|
||||
durationSeconds: 36_000,
|
||||
id: "eye_approach",
|
||||
name: "The Eye Approach",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
@@ -564,7 +564,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
|
||||
durationSeconds: 115_200,
|
||||
durationSeconds: 9000,
|
||||
id: "first_steps",
|
||||
name: "The First Steps",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -572,7 +572,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
|
||||
durationSeconds: 230_400,
|
||||
durationSeconds: 19_800,
|
||||
id: "ancient_archive",
|
||||
name: "The Ancient Archive",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -580,7 +580,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
|
||||
durationSeconds: 345_600,
|
||||
durationSeconds: 28_800,
|
||||
id: "memory_chamber",
|
||||
name: "The Memory Chamber",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -588,7 +588,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
|
||||
durationSeconds: 460_800,
|
||||
durationSeconds: 39_600,
|
||||
id: "the_oldest_place",
|
||||
name: "The Oldest Place",
|
||||
zoneId: "primeval_sanctum",
|
||||
@@ -598,7 +598,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
|
||||
durationSeconds: 129_600,
|
||||
durationSeconds: 10_800,
|
||||
id: "edge_of_everything",
|
||||
name: "The Edge of Everything",
|
||||
zoneId: "the_absolute",
|
||||
@@ -606,7 +606,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
|
||||
durationSeconds: 259_200,
|
||||
durationSeconds: 21_600,
|
||||
id: "truth_approach",
|
||||
name: "The Truth Approach",
|
||||
zoneId: "the_absolute",
|
||||
@@ -614,7 +614,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
|
||||
durationSeconds: 388_800,
|
||||
durationSeconds: 32_400,
|
||||
id: "final_antechamber",
|
||||
name: "The Final Antechamber",
|
||||
zoneId: "the_absolute",
|
||||
@@ -622,7 +622,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
{
|
||||
description:
|
||||
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
|
||||
durationSeconds: 518_400,
|
||||
durationSeconds: 43_200,
|
||||
id: "the_absolute_heart",
|
||||
name: "The Absolute Heart",
|
||||
zoneId: "the_absolute",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.08 },
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
||||
id: "elder_bark_shield",
|
||||
@@ -102,7 +102,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
description:
|
||||
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
||||
id: "cursed_focus",
|
||||
@@ -128,7 +128,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.12 },
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
||||
id: "elemental_ore_ingot",
|
||||
@@ -194,7 +194,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description:
|
||||
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
||||
id: "pressure_forged_core",
|
||||
@@ -272,7 +272,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.18 },
|
||||
bonus: { type: "combat_power", value: 1.28 },
|
||||
description:
|
||||
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
||||
id: "null_field_generator",
|
||||
@@ -310,7 +310,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description:
|
||||
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
||||
id: "eternity_bound_ring",
|
||||
@@ -376,7 +376,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.22 },
|
||||
bonus: { type: "combat_power", value: 1.35 },
|
||||
description:
|
||||
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
||||
id: "reality_ingot",
|
||||
@@ -428,7 +428,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
bonus: { type: "combat_power", value: 1.4 },
|
||||
description:
|
||||
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
||||
id: "ancient_memory_array",
|
||||
@@ -466,7 +466,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
bonus: { type: "combat_power", value: 1.55 },
|
||||
description:
|
||||
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
||||
id: "omega_convergence",
|
||||
|
||||
+358
-34
@@ -11,7 +11,6 @@
|
||||
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
|
||||
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */
|
||||
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
|
||||
import {
|
||||
type Achievement,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
getActiveCompanionBonus,
|
||||
} from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
met = state.player.totalGoldEarned >= condition.amount;
|
||||
met = state.player.lifetimeGoldEarned >= condition.amount;
|
||||
break;
|
||||
case "totalClicks":
|
||||
met = state.player.totalClicks >= condition.amount;
|
||||
@@ -83,35 +83,41 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||
*/
|
||||
export const PRESTIGE_COMBAT_BASE = 4;
|
||||
|
||||
/**
|
||||
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
|
||||
*/
|
||||
export const RESOURCE_CAP = 1e300;
|
||||
|
||||
/**
|
||||
* Probability of quest failure per zone — scales from 10% (early game) to 40% (end game).
|
||||
* Probability of quest failure per zone — scales from 4% (early game) to 15% (end game).
|
||||
* On failure the quest resets to "available" with no rewards; the player must wait the
|
||||
* full duration again on their next attempt.
|
||||
*/
|
||||
export const zoneFailureChance: Record<string, number> = {
|
||||
abyssal_trench: 0.24,
|
||||
astral_void: 0.2,
|
||||
celestial_reaches: 0.22,
|
||||
cosmic_maelstrom: 0.4,
|
||||
crystalline_spire: 0.28,
|
||||
eternal_throne: 0.32,
|
||||
frozen_peaks: 0.14,
|
||||
infernal_court: 0.26,
|
||||
infinite_expanse: 0.36,
|
||||
primeval_sanctum: 0.4,
|
||||
primordial_chaos: 0.34,
|
||||
reality_forge: 0.38,
|
||||
shadow_marshes: 0.16,
|
||||
shattered_ruins: 0.12,
|
||||
the_absolute: 0.4,
|
||||
verdant_vale: 0.1,
|
||||
void_sanctum: 0.3,
|
||||
volcanic_depths: 0.18,
|
||||
abyssal_trench: 0.09,
|
||||
astral_void: 0.08,
|
||||
celestial_reaches: 0.08,
|
||||
cosmic_maelstrom: 0.15,
|
||||
crystalline_spire: 0.11,
|
||||
eternal_throne: 0.12,
|
||||
frozen_peaks: 0.05,
|
||||
infernal_court: 0.1,
|
||||
infinite_expanse: 0.14,
|
||||
primeval_sanctum: 0.15,
|
||||
primordial_chaos: 0.13,
|
||||
reality_forge: 0.14,
|
||||
shadow_marshes: 0.06,
|
||||
shattered_ruins: 0.05,
|
||||
the_absolute: 0.15,
|
||||
verdant_vale: 0.04,
|
||||
void_sanctum: 0.11,
|
||||
volcanic_depths: 0.07,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -195,6 +201,288 @@ export const computeGoldPerSecond = (state: GameState): number => {
|
||||
return goldPerSecond;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the current essence per second for the given game state,
|
||||
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
|
||||
* @param state - The current game state.
|
||||
* @returns The total essence per second.
|
||||
*/
|
||||
export const computeEssencePerSecond = (state: GameState): number => {
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier
|
||||
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionEssenceMult
|
||||
= companionBonus?.type === "essenceIncome"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
let essencePerSecond = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isThisAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id;
|
||||
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
const contribution
|
||||
= adventurer.essencePerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesEssence
|
||||
* craftedEssenceMultiplier
|
||||
* companionEssenceMult;
|
||||
essencePerSecond = essencePerSecond + contribution;
|
||||
}
|
||||
return essencePerSecond;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the effective per-unit stats for a single adventurer type,
|
||||
* applying all active multipliers (upgrades, prestige, equipment, echo,
|
||||
* crafted, companion). The returned values represent what a single
|
||||
* adventurer of this type currently contributes per second, matching the
|
||||
* per-unit contribution used by computeGoldPerSecond and
|
||||
* computeEssencePerSecond.
|
||||
* @param state - The current game state.
|
||||
* @param adventurerId - The ID of the adventurer to compute stats for.
|
||||
* @returns Effective per-unit goldPerSecond, essencePerSecond, and combatPower.
|
||||
*/
|
||||
export const computeEffectiveAdventurerStats = (
|
||||
state: GameState,
|
||||
adventurerId: string,
|
||||
): { combatPower: number; essencePerSecond: number; goldPerSecond: number } => {
|
||||
const adventurer = state.adventurers.find((a) => {
|
||||
return a.id === adventurerId;
|
||||
});
|
||||
|
||||
/* V8 ignore next 3 -- @preserve */
|
||||
if (adventurer === undefined) {
|
||||
return { combatPower: 0, essencePerSecond: 0, goldPerSecond: 0 };
|
||||
}
|
||||
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isThisAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurerId;
|
||||
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const equippedItems = state.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||
}, 1);
|
||||
const equipmentCombatMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
const equippedItemIds = equippedItems.map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const setBonuses = computeSetBonuses(equippedItemIds, EQUIPMENT_SETS);
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
||||
const craftedGoldMultiplier
|
||||
= state.exploration?.craftedGoldMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier
|
||||
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
const craftedCombatMultiplier
|
||||
= state.exploration?.craftedCombatMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionGoldMult
|
||||
= companionBonus?.type === "passiveGold"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionEssenceMult
|
||||
= companionBonus?.type === "essenceIncome"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionCombatMult
|
||||
= companionBonus?.type === "bossDamage"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
const goldPerSecond
|
||||
= adventurer.goldPerSecond
|
||||
* upgradeMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesIncome
|
||||
* echoIncome
|
||||
* equipmentGoldMultiplier
|
||||
* setBonuses.goldMultiplier
|
||||
* craftedGoldMultiplier
|
||||
* companionGoldMult;
|
||||
|
||||
const essencePerSecond
|
||||
= adventurer.essencePerSecond
|
||||
* upgradeMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesEssence
|
||||
* craftedEssenceMultiplier
|
||||
* companionEssenceMult;
|
||||
|
||||
const combatPower
|
||||
= adventurer.combatPower
|
||||
* upgradeMultiplier
|
||||
* prestigeCombatMultiplier
|
||||
* equipmentCombatMultiplier
|
||||
* setBonuses.combatMultiplier
|
||||
* echoCombatMultiplier
|
||||
* craftedCombatMultiplier
|
||||
* companionCombatMult;
|
||||
|
||||
return { combatPower, essencePerSecond, goldPerSecond };
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the party's total combat power, applying all active multipliers
|
||||
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
|
||||
* This mirrors the server-side calculatePartyStats in boss.ts and is the
|
||||
* single source of truth for all combat-power checks in the client:
|
||||
* - Displayed as "Combat Power" in the resource bar
|
||||
* - Displayed as "Party DPS" in the boss panel
|
||||
* - Used to gate quest availability
|
||||
* Note: the active companion's bossDamage bonus is intentionally included
|
||||
* here, as it applies to the full combat power calculation (boss fights and
|
||||
* quest gating alike), matching the server-side behaviour.
|
||||
* @param state - The current game state.
|
||||
* @returns The total party combat power.
|
||||
*/
|
||||
export const computePartyCombatPower = (state: GameState): number => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
|
||||
|
||||
const equipmentCombatMultiplier = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
const equippedItemIds = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
|
||||
equippedItemIds,
|
||||
EQUIPMENT_SETS,
|
||||
);
|
||||
|
||||
const echoCombatMultiplier
|
||||
= state.transcendence?.echoCombatMultiplier ?? 1;
|
||||
const craftedCombatMultiplier
|
||||
= state.exploration?.craftedCombatMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionCombatMult
|
||||
= companionBonus?.type === "bossDamage"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
let partyCombatPower = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const contribution
|
||||
= adventurer.combatPower
|
||||
* adventurer.count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyCombatPower = partyCombatPower + contribution;
|
||||
}
|
||||
|
||||
return partyCombatPower
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* echoCombatMultiplier
|
||||
* craftedCombatMultiplier
|
||||
* companionCombatMult;
|
||||
};
|
||||
|
||||
const basePrestigeThreshold = 1_000_000;
|
||||
const runestonesPerPrestigeLevelClient = 20;
|
||||
const maxBaseRunestones = 200;
|
||||
|
||||
/**
|
||||
* Computes the projected runestone reward if the player were to prestige right now.
|
||||
* Mirrors the server-side calculateRunestones formula exactly.
|
||||
* @param state - The current game state.
|
||||
* @returns The number of runestones the player would earn from a prestige now.
|
||||
*/
|
||||
export const computeProjectedRunestones = (state: GameState): number => {
|
||||
const { count, purchasedUpgradeIds } = state.prestige;
|
||||
const thresholdMult: number
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
const threshold
|
||||
= basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
|
||||
const base = Math.min(
|
||||
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevelClient,
|
||||
maxBaseRunestones,
|
||||
);
|
||||
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
|
||||
? 1.25
|
||||
: 1;
|
||||
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
|
||||
? 1.5
|
||||
: 1;
|
||||
const runestoneMult = gain1Mult * gain2Mult;
|
||||
const echoMult: number
|
||||
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
|
||||
return Math.floor(base * runestoneMult * echoMult);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — applies one game tick to the state.
|
||||
* DeltaSeconds: time elapsed since last tick.
|
||||
@@ -469,6 +757,19 @@ export const applyTick = (
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
// Auto-unlock adventurer-specific upgrades when their adventurer is recruited
|
||||
updatedUpgrades = updatedUpgrades.map((upgrade) => {
|
||||
if (upgrade.unlocked || upgrade.adventurerId === undefined) {
|
||||
return upgrade;
|
||||
}
|
||||
const adventurer = updatedAdventurers.find((a) => {
|
||||
return a.id === upgrade.adventurerId;
|
||||
});
|
||||
return adventurer !== undefined && adventurer.count > 0
|
||||
? { ...upgrade, unlocked: true }
|
||||
: upgrade;
|
||||
});
|
||||
|
||||
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||
const essenceValue = capResource(
|
||||
state.resources.essence + essenceGained + questEssence,
|
||||
@@ -489,6 +790,23 @@ export const applyTick = (
|
||||
...updatedDailyChallenges === undefined
|
||||
? {}
|
||||
: { dailyChallenges: updatedDailyChallenges },
|
||||
...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined
|
||||
? {}
|
||||
: {
|
||||
exploration: {
|
||||
...state.exploration,
|
||||
areas: state.exploration.areas.map((area) => {
|
||||
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
||||
return definition.id === area.id;
|
||||
});
|
||||
return areaDefinition !== undefined
|
||||
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
||||
&& area.status === "locked"
|
||||
? { ...area, status: "available" as const }
|
||||
: area;
|
||||
}),
|
||||
},
|
||||
},
|
||||
adventurers: updatedAdventurers,
|
||||
bosses: updatedBosses,
|
||||
equipment: updatedEquipmentReference,
|
||||
@@ -502,24 +820,30 @@ export const applyTick = (
|
||||
zones: updatedZones,
|
||||
};
|
||||
|
||||
// Check achievements and apply crystal rewards for newly unlocked ones
|
||||
// Check achievements and apply crystal and runestone rewards for newly unlocked ones
|
||||
const updatedAchievements = checkAchievements(partialState);
|
||||
const crystalsFromAchievements = updatedAchievements.reduce(
|
||||
(sum, achievement, index) => {
|
||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
return sum + (achievement.reward?.crystals ?? 0);
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
let crystalsFromAchievements = 0;
|
||||
let runestonesFromAchievements = 0;
|
||||
for (const [ index, achievement ] of updatedAchievements.entries()) {
|
||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
crystalsFromAchievements
|
||||
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
|
||||
runestonesFromAchievements
|
||||
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...partialState,
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
prestige: {
|
||||
...partialState.prestige,
|
||||
runestones:
|
||||
partialState.prestige.runestones + runestonesFromAchievements,
|
||||
},
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: capResource(
|
||||
partialState.resources.crystals + crystalsFromAchievements,
|
||||
|
||||
@@ -4586,6 +4586,7 @@ body::before {
|
||||
height: 220px;
|
||||
margin-bottom: 1rem;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,35 @@ const formatEngineering = (value: number): string => {
|
||||
return `${mantissa.toFixed(2)}E${String(engExp)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a whole-number value for display without decimal places.
|
||||
* Uses the same suffix/letter logic as formatNumber but rounds to integers.
|
||||
* @param value - The integer value to format.
|
||||
* @returns The formatted string with no decimal places.
|
||||
*/
|
||||
const formatInteger = (value: number): string => {
|
||||
if (!Number.isFinite(value) || Number.isNaN(value)) {
|
||||
return "0";
|
||||
}
|
||||
if (value < 0) {
|
||||
return `-${formatInteger(-value)}`;
|
||||
}
|
||||
if (value >= Math.pow(10, letterBaseExp)) {
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
|
||||
const steps = stepsAboveBase * 3;
|
||||
const divisorExp = letterBaseExp + steps;
|
||||
const divisor = Math.pow(10, divisorExp);
|
||||
return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`;
|
||||
}
|
||||
for (const { threshold, suffix } of namedSuffixes) {
|
||||
if (value >= threshold) {
|
||||
return `${String(Math.floor(value / threshold))}${suffix}`;
|
||||
}
|
||||
}
|
||||
return String(Math.floor(value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number for display using the player's chosen notation style.
|
||||
* Negative values are formatted with a leading minus sign.
|
||||
@@ -115,7 +144,7 @@ const formatEngineering = (value: number): string => {
|
||||
* @param format - The notation style to use.
|
||||
* @returns The formatted number string.
|
||||
*/
|
||||
export const formatNumber = (
|
||||
const formatNumber = (
|
||||
value: number,
|
||||
format: NumberFormat = "suffix",
|
||||
): string => {
|
||||
@@ -139,3 +168,5 @@ export const formatNumber = (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { formatInteger, formatNumber };
|
||||
|
||||
@@ -5,14 +5,22 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
|
||||
import { ValidationError } from "../api/client.js";
|
||||
|
||||
/**
|
||||
* Logs an error to the backend telemetry service.
|
||||
* Accepts the same arguments as console.error — conventionally a context string
|
||||
* followed by the error value.
|
||||
* @param logArguments - The values to log, forwarded directly to console.error.
|
||||
* ValidationErrors (4xx API rejections) are downgraded to console.warn so they
|
||||
* are not forwarded to the error-email pipeline — they are expected server responses.
|
||||
* @param logArguments - The values to log, forwarded directly to console.error or console.warn.
|
||||
*/
|
||||
const logError = (...logArguments: Array<unknown>): void => {
|
||||
const isValidation = logArguments.some((argument) => {
|
||||
return argument instanceof ValidationError;
|
||||
});
|
||||
if (isValidation) {
|
||||
console.warn(...logArguments);
|
||||
return;
|
||||
}
|
||||
console.error(...logArguments);
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,18 @@ const initialiseFrontendLogger = (): void => {
|
||||
? argument
|
||||
: JSON.stringify(argument);
|
||||
}).join(" ");
|
||||
|
||||
/*
|
||||
* Ignore errors originating entirely from third-party scripts (e.g. AdSense).
|
||||
* Stack frames from our own code reference elysium.nhcarrigan.com or localhost;
|
||||
* if none are present but external URLs are, the error is not actionable.
|
||||
*/
|
||||
const hasExternalUrl = (/https?:\/\//u).test(message);
|
||||
const hasOurDomain = message.includes("elysium.nhcarrigan.com");
|
||||
const hasOwnFrame = hasOurDomain || message.includes("localhost");
|
||||
if (hasExternalUrl && !hasOwnFrame) {
|
||||
return;
|
||||
}
|
||||
const context = "console.error";
|
||||
post("/api/fe/error", { context, message });
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatNumber } from "../src/utils/format.js";
|
||||
import { formatInteger, formatNumber } from "../src/utils/format.js";
|
||||
|
||||
describe("formatNumber", () => {
|
||||
describe("edge cases", () => {
|
||||
@@ -142,3 +142,59 @@ describe("formatNumber", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInteger", () => {
|
||||
describe("edge cases", () => {
|
||||
it("should return '0' for NaN", () => {
|
||||
expect(formatInteger(Number.NaN)).toBe("0");
|
||||
});
|
||||
|
||||
it("should return '0' for Infinity", () => {
|
||||
expect(formatInteger(Infinity)).toBe("0");
|
||||
});
|
||||
|
||||
it("should format negative integers with a leading minus sign", () => {
|
||||
expect(formatInteger(-1500)).toBe("-1K");
|
||||
});
|
||||
|
||||
it("should format zero as '0'", () => {
|
||||
expect(formatInteger(0)).toBe("0");
|
||||
});
|
||||
|
||||
it("should format small integers without decimals", () => {
|
||||
expect(formatInteger(42)).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("named suffixes", () => {
|
||||
it("should format thousands with K suffix and no decimals", () => {
|
||||
expect(formatInteger(1500)).toBe("1K");
|
||||
});
|
||||
|
||||
it("should format millions with M suffix and no decimals", () => {
|
||||
expect(formatInteger(2_500_000)).toBe("2M");
|
||||
});
|
||||
|
||||
it("should format billions with B suffix", () => {
|
||||
expect(formatInteger(3_000_000_000)).toBe("3B");
|
||||
});
|
||||
|
||||
it("should format trillions with T suffix", () => {
|
||||
expect(formatInteger(1e12)).toBe("1T");
|
||||
});
|
||||
|
||||
it("should format quintillions with Qi suffix", () => {
|
||||
expect(formatInteger(1e18)).toBe("1Qi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("letter suffixes", () => {
|
||||
it("should format values >= 1e36 with letter suffix 'a'", () => {
|
||||
expect(formatInteger(1e36)).toBe("1a");
|
||||
});
|
||||
|
||||
it("should format values >= 1e39 with letter suffix 'b'", () => {
|
||||
expect(formatInteger(1e39)).toBe("1b");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Dev Puppeteer script — intercepts /api/game/load and injects a fresh
|
||||
* game state built from the actual compiled data files, so we can browse
|
||||
* the game UI without auth or a real DB record.
|
||||
*/
|
||||
import puppeteer from "puppeteer";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Load actual game data from compiled API output
|
||||
const { defaultAchievements } = require("./apps/api/prod/src/data/achievements.js");
|
||||
const { defaultEquipment } = require("./apps/api/prod/src/data/equipment.js");
|
||||
const { defaultBosses } = require("./apps/api/prod/src/data/bosses.js");
|
||||
const { defaultQuests } = require("./apps/api/prod/src/data/quests.js");
|
||||
const { defaultAdventurers } = require("./apps/api/prod/src/data/adventurers.js");
|
||||
const { defaultUpgrades } = require("./apps/api/prod/src/data/upgrades.js");
|
||||
const { defaultZones } = require("./apps/api/prod/src/data/zones.js");
|
||||
|
||||
console.log("📦 Data loaded:");
|
||||
console.log(` achievements : ${defaultAchievements.length}`);
|
||||
console.log(` equipment : ${defaultEquipment.length}`);
|
||||
console.log(` bosses : ${defaultBosses.length}`);
|
||||
console.log(` quests : ${defaultQuests.length}`);
|
||||
|
||||
// Spot-check for our new items
|
||||
const newEquipIds = [
|
||||
"chaos_mantle", "titan_core", "expanse_blade", "void_armour_mk2",
|
||||
"cosmos_blade", "reality_plate", "maelstrom_edge", "cosmic_plate",
|
||||
"primeval_blade", "ancient_aegis", "absolute_blade", "eternity_plate",
|
||||
"omniversal_core",
|
||||
];
|
||||
const foundNew = newEquipIds.filter(id => defaultEquipment.some(e => e.id === id));
|
||||
const missingNew = newEquipIds.filter(id => !defaultEquipment.some(e => e.id === id));
|
||||
console.log(`\n🗡️ New equipment found (${foundNew.length}/13): ${foundNew.join(", ")}`);
|
||||
if (missingNew.length > 0) console.log(` ❌ Missing: ${missingNew.join(", ")}`);
|
||||
|
||||
const questEternal = defaultAchievements.find(a => a.id === "quest_eternal");
|
||||
const fullyEquipped = defaultAchievements.find(a => a.id === "fully_equipped");
|
||||
console.log(`\n🏆 quest_eternal condition amount : ${questEternal?.condition?.amount}`);
|
||||
console.log(`🏆 fully_equipped condition amount: ${fullyEquipped?.condition?.amount}`);
|
||||
|
||||
// Build a minimal but valid mock game state
|
||||
const mockState = {
|
||||
achievements : defaultAchievements,
|
||||
adventurers : defaultAdventurers,
|
||||
baseClickPower: 1,
|
||||
bosses : defaultBosses,
|
||||
equipment : defaultEquipment,
|
||||
lastTickAt : Date.now(),
|
||||
player : {
|
||||
avatar : null,
|
||||
characterName : "Hikari Test",
|
||||
createdAt : Date.now(),
|
||||
discordId : "000000000000000001",
|
||||
discriminator : "0",
|
||||
lastSavedAt : Date.now(),
|
||||
lifetimeAchievementsUnlocked: 0,
|
||||
lifetimeAdventurersRecruited: 0,
|
||||
lifetimeBossesDefeated : 0,
|
||||
lifetimeClicks : 0,
|
||||
lifetimeGoldEarned : 0,
|
||||
lifetimeQuestsCompleted : 0,
|
||||
totalClicks : 0,
|
||||
totalGoldEarned : 0,
|
||||
username : "HikariTest",
|
||||
},
|
||||
prestige : {
|
||||
count : 0,
|
||||
runestones : 0,
|
||||
},
|
||||
quests : defaultQuests,
|
||||
resources : {
|
||||
crystals : 0,
|
||||
essence : 0,
|
||||
gold : 0,
|
||||
},
|
||||
upgrades : defaultUpgrades,
|
||||
zones : defaultZones,
|
||||
};
|
||||
|
||||
const mockLoadResponse = {
|
||||
currentSchemaVersion: 1,
|
||||
inGuild : true,
|
||||
loginBonus : null,
|
||||
loginStreak : 0,
|
||||
offlineEssence : 0,
|
||||
offlineGold : 0,
|
||||
offlineSeconds : 0,
|
||||
schemaOutdated : false,
|
||||
signature : undefined,
|
||||
state : mockState,
|
||||
};
|
||||
|
||||
console.log("\n🌐 Launching browser...");
|
||||
const browser = await puppeteer.launch({
|
||||
args : ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
headless: false,
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ height: 900, width: 1400 });
|
||||
|
||||
// Intercept the game load call and inject our mock state
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/game/load") && req.method() === "GET") {
|
||||
console.log(" ↩️ Intercepted /api/game/load — injecting mock state");
|
||||
req.respond({
|
||||
body : JSON.stringify(mockLoadResponse),
|
||||
contentType : "application/json",
|
||||
headers : { "Content-Type": "application/json" },
|
||||
status : 200,
|
||||
});
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Set a fake token so the frontend thinks we're logged in
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem("elysium_token", "dev.fake.token");
|
||||
});
|
||||
|
||||
console.log(" 🔗 Navigating to http://localhost:5173 ...");
|
||||
await page.goto("http://localhost:5173", { waitUntil: "networkidle2" });
|
||||
|
||||
// Give the game a moment to tick and render
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
await page.screenshot({ path: "/tmp/elysium-01-game.png" });
|
||||
console.log(" 📸 Screenshot: /tmp/elysium-01-game.png");
|
||||
|
||||
// Try to find the equipment panel
|
||||
const equipmentTab = await page.$("button, a, [role='tab']");
|
||||
console.log(`\n🔍 Checking page title: ${await page.title()}`);
|
||||
|
||||
// Log any visible text that mentions our new items
|
||||
const pageText = await page.evaluate(() => document.body.innerText);
|
||||
const newItemsVisible = newEquipIds.filter(id => pageText.toLowerCase().includes(id.replace(/_/g, " ").toLowerCase().slice(0, 8)));
|
||||
console.log(`\n🗡️ New item names visible in UI: ${newItemsVisible.length > 0 ? newItemsVisible.join(", ") : "none yet (may need to navigate to equipment panel)"}`);
|
||||
|
||||
// Check achievement counts visible in page
|
||||
const hasQuestEternal = pageText.includes("112");
|
||||
const hasFullyEquipped = pageText.includes("78");
|
||||
console.log(` quest_eternal (112) visible: ${hasQuestEternal}`);
|
||||
console.log(` fully_equipped (78) visible: ${hasFullyEquipped}`);
|
||||
|
||||
console.log("\n✅ Browser open — take a look around! Close it when done.");
|
||||
console.log(" (or Ctrl+C to exit)\n");
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "elysium",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/types",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
@@ -20,7 +20,8 @@ interface AchievementCondition {
|
||||
}
|
||||
|
||||
interface AchievementReward {
|
||||
crystals?: number;
|
||||
crystals?: number;
|
||||
runestones?: number;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- API types file grows with each new endpoint */
|
||||
import type {
|
||||
EquipmentBonus,
|
||||
EquipmentRarity,
|
||||
@@ -69,6 +70,11 @@ interface LoginBonusResult {
|
||||
interface LoadResponse {
|
||||
state: GameState;
|
||||
|
||||
/**
|
||||
* Whether the player is currently a member of the NHCarrigan Discord server.
|
||||
*/
|
||||
inGuild: boolean;
|
||||
|
||||
/**
|
||||
* Offline gold earned since last save (server-calculated).
|
||||
*/
|
||||
@@ -164,6 +170,11 @@ interface BossChallengeResponse {
|
||||
adventurerId: string;
|
||||
killed: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||
*/
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
type PrestigeRequest = Record<string, never>;
|
||||
@@ -468,6 +479,11 @@ interface SyncNewContentResponse {
|
||||
*/
|
||||
adventurersAdded: number;
|
||||
|
||||
/**
|
||||
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||
*/
|
||||
adventurerStatsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades added to the save.
|
||||
*/
|
||||
@@ -513,6 +529,41 @@ interface SyncNewContentResponse {
|
||||
*/
|
||||
explorationAreasAdded: number;
|
||||
|
||||
/**
|
||||
* Number of achievements whose stats were updated to match current defaults.
|
||||
*/
|
||||
achievementsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of bosses whose stats were updated to match current defaults.
|
||||
*/
|
||||
bossesPatched: number;
|
||||
|
||||
/**
|
||||
* Number of crafted recipes whose multiplier contribution was reapplied during recompute.
|
||||
*/
|
||||
craftingRecipesReapplied: number;
|
||||
|
||||
/**
|
||||
* Number of equipment items whose stats were updated to match current defaults.
|
||||
*/
|
||||
equipmentPatched: number;
|
||||
|
||||
/**
|
||||
* Number of quests whose stats were updated to match current defaults.
|
||||
*/
|
||||
questsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades whose stats were updated to match current defaults.
|
||||
*/
|
||||
upgradesPatched: number;
|
||||
|
||||
/**
|
||||
* Number of zones whose stats were updated to match current defaults.
|
||||
*/
|
||||
zonesPatched: number;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
type DailyChallengeType =
|
||||
| "clicks"
|
||||
| "bossesDefeated"
|
||||
| "crafting"
|
||||
| "questsCompleted"
|
||||
| "prestige";
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ interface PrestigeData {
|
||||
* Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade).
|
||||
*/
|
||||
autoPrestigeEnabled?: boolean;
|
||||
|
||||
/**
|
||||
* When true, auto-prestige only fires when the runestone yield is at its maximum base cap.
|
||||
* Requires auto_prestige upgrade and autoPrestigeEnabled to be true.
|
||||
*/
|
||||
autoPrestigeMaxRunestonesOnly?: boolean;
|
||||
}
|
||||
|
||||
export type { PrestigeData };
|
||||
|
||||
@@ -48,11 +48,17 @@ interface ProfileSettings {
|
||||
* Whether browser system notifications are enabled.
|
||||
*/
|
||||
enableNotifications: boolean;
|
||||
|
||||
/**
|
||||
* Whether prestige milestones are announced in the Discord server.
|
||||
*/
|
||||
enablePrestigeAnnouncements: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
||||
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||
enableNotifications: false,
|
||||
enablePrestigeAnnouncements: true,
|
||||
enableSounds: false,
|
||||
numberFormat: "suffix",
|
||||
showAchievementsUnlocked: true,
|
||||
|
||||
Generated
+794
-642
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- "@prisma/engines"
|
||||
- "prisma"
|
||||
|
||||
Reference in New Issue
Block a user