generated from nhcarrigan/template
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 666a5b2d6d | |||
|
9926e7f639
|
|||
| 6bf1ac5e7d | |||
| b48beef474 | |||
| 6e573bea14 | |||
|
790d35420f
|
|||
|
9f9edae45e
|
|||
|
a7a255dab6
|
|||
|
e92cf3c9a1
|
|||
|
26d30c271d
|
|||
| 34d07bec95 | |||
| 3ac1d566cb | |||
|
7bd6b2d3e3
|
|||
| 354b7e372e | |||
| dc1782bec9 | |||
| 635c630e49 | |||
| bb60ae3390 | |||
| ee47c1e8c9 | |||
| 2236d1dc9f | |||
|
621f594018
|
|||
| 1e845b14ce |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.1.2",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ model Player {
|
|||||||
lifetimeAchievementsUnlocked Float @default(0)
|
lifetimeAchievementsUnlocked Float @default(0)
|
||||||
lastLoginDate String?
|
lastLoginDate String?
|
||||||
loginStreak Int @default(1)
|
loginStreak Int @default(1)
|
||||||
|
inGuild Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model GameState {
|
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_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"
|
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
||||||
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
||||||
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
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"
|
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
||||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
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"
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 18, type: "bossesDefeated" },
|
condition: { amount: 18, type: "bossesDefeated" },
|
||||||
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
|
description: "Defeat the 18 bosses of the mortal realms.",
|
||||||
icon: "🌟",
|
icon: "🌟",
|
||||||
id: "devourer_slayer",
|
id: "devourer_slayer",
|
||||||
name: "World Saver",
|
name: "World Saver",
|
||||||
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 40, type: "equipmentOwned" },
|
condition: { amount: 78, type: "equipmentOwned" },
|
||||||
description: "Own 40 pieces of equipment.",
|
description: "Own all 78 pieces of equipment.",
|
||||||
icon: "🛡️",
|
icon: "🛡️",
|
||||||
id: "fully_equipped",
|
id: "fully_equipped",
|
||||||
name: "Fully Equipped",
|
name: "Fully Equipped",
|
||||||
@@ -269,6 +269,33 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
reward: { crystals: 50_000 },
|
reward: { crystals: 50_000 },
|
||||||
unlockedAt: null,
|
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
|
// Higher quest milestones
|
||||||
{
|
{
|
||||||
condition: { amount: 30, type: "questsCompleted" },
|
condition: { amount: 30, type: "questsCompleted" },
|
||||||
@@ -289,8 +316,26 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 72, type: "questsCompleted" },
|
condition: { amount: 75, type: "questsCompleted" },
|
||||||
description: "Complete all 72 quests across the known multiverse.",
|
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: 112, type: "questsCompleted" },
|
||||||
|
description: "Complete all 112 quests across the known multiverse.",
|
||||||
icon: "🌌",
|
icon: "🌌",
|
||||||
id: "quest_eternal",
|
id: "quest_eternal",
|
||||||
name: "Quest Eternal",
|
name: "Quest Eternal",
|
||||||
@@ -317,8 +362,17 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 60, type: "bossesDefeated" },
|
condition: { amount: 50, type: "bossesDefeated" },
|
||||||
description: "Defeat all 60 bosses across every plane of existence.",
|
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.",
|
||||||
icon: "💀",
|
icon: "💀",
|
||||||
id: "boss_eternal",
|
id: "boss_eternal",
|
||||||
name: "Eternal Vanquisher",
|
name: "Eternal Vanquisher",
|
||||||
@@ -363,4 +417,40 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
reward: { crystals: 25_000 },
|
reward: { crystals: 25_000 },
|
||||||
unlockedAt: null,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
combatPower: 3,
|
combatPower: 3,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 0,
|
essencePerSecond: 0,
|
||||||
goldPerSecond: 0.5,
|
goldPerSecond: 0.7,
|
||||||
id: "militia",
|
id: "militia",
|
||||||
level: 2,
|
level: 2,
|
||||||
name: "Militia",
|
name: "Militia",
|
||||||
@@ -129,50 +129,62 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 4_000_000_000,
|
baseCost: 2_850_000_000,
|
||||||
class: "rogue",
|
class: "mage",
|
||||||
combatPower: 18_000,
|
combatPower: 13_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 6,
|
essencePerSecond: 6,
|
||||||
goldPerSecond: 5000,
|
goldPerSecond: 4500,
|
||||||
id: "shadow_assassin",
|
|
||||||
level: 11,
|
|
||||||
name: "Shadow Assassin",
|
|
||||||
unlocked: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
baseCost: 28_000_000_000,
|
|
||||||
class: "mage",
|
|
||||||
combatPower: 45_000,
|
|
||||||
count: 0,
|
|
||||||
essencePerSecond: 15,
|
|
||||||
goldPerSecond: 14_000,
|
|
||||||
id: "arcane_scholar",
|
id: "arcane_scholar",
|
||||||
level: 12,
|
level: 11,
|
||||||
name: "Arcane Scholar",
|
name: "Arcane Scholar",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 200_000_000_000,
|
baseCost: 13_500_000_000,
|
||||||
|
class: "rogue",
|
||||||
|
combatPower: 28_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 11,
|
||||||
|
goldPerSecond: 9500,
|
||||||
|
id: "shadow_assassin",
|
||||||
|
level: 12,
|
||||||
|
name: "Shadow Assassin",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseCost: 64_000_000_000,
|
||||||
|
class: "paladin",
|
||||||
|
combatPower: 60_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 20,
|
||||||
|
goldPerSecond: 20_000,
|
||||||
|
id: "dark_templar",
|
||||||
|
level: 13,
|
||||||
|
name: "Dark Templar",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseCost: 300_000_000_000,
|
||||||
class: "rogue",
|
class: "rogue",
|
||||||
combatPower: 130_000,
|
combatPower: 130_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 35,
|
essencePerSecond: 35,
|
||||||
goldPerSecond: 40_000,
|
goldPerSecond: 40_000,
|
||||||
id: "void_walker",
|
id: "void_walker",
|
||||||
level: 13,
|
level: 14,
|
||||||
name: "Void Walker",
|
name: "Void Walker",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 1_400_000_000_000,
|
baseCost: 1_800_000_000_000,
|
||||||
class: "paladin",
|
class: "paladin",
|
||||||
combatPower: 400_000,
|
combatPower: 400_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 100,
|
essencePerSecond: 100,
|
||||||
goldPerSecond: 120_000,
|
goldPerSecond: 120_000,
|
||||||
id: "celestial_guard",
|
id: "celestial_guard",
|
||||||
level: 14,
|
level: 15,
|
||||||
name: "Celestial Guard",
|
name: "Celestial Guard",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300,
|
essencePerSecond: 300,
|
||||||
goldPerSecond: 400_000,
|
goldPerSecond: 400_000,
|
||||||
id: "divine_champion",
|
id: "divine_champion",
|
||||||
level: 15,
|
level: 16,
|
||||||
name: "Divine Champion",
|
name: "Divine Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 800,
|
essencePerSecond: 800,
|
||||||
goldPerSecond: 1_200_000,
|
goldPerSecond: 1_200_000,
|
||||||
id: "seraph_knight",
|
id: "seraph_knight",
|
||||||
level: 16,
|
level: 17,
|
||||||
name: "Seraph Knight",
|
name: "Seraph Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2000,
|
essencePerSecond: 2000,
|
||||||
goldPerSecond: 3_500_000,
|
goldPerSecond: 3_500_000,
|
||||||
id: "abyss_diver",
|
id: "abyss_diver",
|
||||||
level: 17,
|
level: 18,
|
||||||
name: "Abyss Diver",
|
name: "Abyss Diver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 5000,
|
essencePerSecond: 5000,
|
||||||
goldPerSecond: 10_000_000,
|
goldPerSecond: 10_000_000,
|
||||||
id: "infernal_warden",
|
id: "infernal_warden",
|
||||||
level: 18,
|
level: 19,
|
||||||
name: "Infernal Warden",
|
name: "Infernal Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000,
|
essencePerSecond: 12_000,
|
||||||
goldPerSecond: 30_000_000,
|
goldPerSecond: 30_000_000,
|
||||||
id: "crystal_sage",
|
id: "crystal_sage",
|
||||||
level: 19,
|
level: 20,
|
||||||
name: "Crystal Sage",
|
name: "Crystal Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 30_000,
|
essencePerSecond: 30_000,
|
||||||
goldPerSecond: 90_000_000,
|
goldPerSecond: 90_000_000,
|
||||||
id: "void_sentinel",
|
id: "void_sentinel",
|
||||||
level: 20,
|
level: 21,
|
||||||
name: "Void Sentinel",
|
name: "Void Sentinel",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 80_000,
|
essencePerSecond: 80_000,
|
||||||
goldPerSecond: 270_000_000,
|
goldPerSecond: 270_000_000,
|
||||||
id: "eternal_champion",
|
id: "eternal_champion",
|
||||||
level: 21,
|
level: 22,
|
||||||
name: "Eternal Champion",
|
name: "Eternal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 220_000,
|
essencePerSecond: 220_000,
|
||||||
goldPerSecond: 800_000_000,
|
goldPerSecond: 800_000_000,
|
||||||
id: "aether_weaver",
|
id: "aether_weaver",
|
||||||
level: 22,
|
level: 23,
|
||||||
name: "Aether Weaver",
|
name: "Aether Weaver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 600_000,
|
essencePerSecond: 600_000,
|
||||||
goldPerSecond: 2_500_000_000,
|
goldPerSecond: 2_500_000_000,
|
||||||
id: "titan_warrior",
|
id: "titan_warrior",
|
||||||
level: 23,
|
level: 24,
|
||||||
name: "Titan Warrior",
|
name: "Titan Warrior",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 1_600_000,
|
essencePerSecond: 1_600_000,
|
||||||
goldPerSecond: 7_500_000_000,
|
goldPerSecond: 7_500_000_000,
|
||||||
id: "nexus_sage",
|
id: "nexus_sage",
|
||||||
level: 24,
|
level: 25,
|
||||||
name: "Nexus Sage",
|
name: "Nexus Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 4_500_000,
|
essencePerSecond: 4_500_000,
|
||||||
goldPerSecond: 22_000_000_000,
|
goldPerSecond: 22_000_000_000,
|
||||||
id: "cosmos_knight",
|
id: "cosmos_knight",
|
||||||
level: 25,
|
level: 26,
|
||||||
name: "Cosmos Knight",
|
name: "Cosmos Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000_000,
|
essencePerSecond: 12_000_000,
|
||||||
goldPerSecond: 65_000_000_000,
|
goldPerSecond: 65_000_000_000,
|
||||||
id: "astral_sovereign",
|
id: "astral_sovereign",
|
||||||
level: 26,
|
level: 27,
|
||||||
name: "Astral Sovereign",
|
name: "Astral Sovereign",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 35_000_000,
|
essencePerSecond: 35_000_000,
|
||||||
goldPerSecond: 200_000_000_000,
|
goldPerSecond: 200_000_000_000,
|
||||||
id: "primordial_mage",
|
id: "primordial_mage",
|
||||||
level: 27,
|
level: 28,
|
||||||
name: "Primordial Mage",
|
name: "Primordial Mage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 100_000_000,
|
essencePerSecond: 100_000_000,
|
||||||
goldPerSecond: 600_000_000_000,
|
goldPerSecond: 600_000_000_000,
|
||||||
id: "reality_warden",
|
id: "reality_warden",
|
||||||
level: 28,
|
level: 29,
|
||||||
name: "Reality Warden",
|
name: "Reality Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300_000_000,
|
essencePerSecond: 300_000_000,
|
||||||
goldPerSecond: 1_800_000_000_000,
|
goldPerSecond: 1_800_000_000_000,
|
||||||
id: "infinity_ranger",
|
id: "infinity_ranger",
|
||||||
level: 29,
|
level: 30,
|
||||||
name: "Infinity Ranger",
|
name: "Infinity Ranger",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 850_000_000,
|
essencePerSecond: 850_000_000,
|
||||||
goldPerSecond: 5_500_000_000_000,
|
goldPerSecond: 5_500_000_000_000,
|
||||||
id: "oblivion_paladin",
|
id: "oblivion_paladin",
|
||||||
level: 30,
|
level: 31,
|
||||||
name: "Oblivion Paladin",
|
name: "Oblivion Paladin",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2_500_000_000,
|
essencePerSecond: 2_500_000_000,
|
||||||
goldPerSecond: 16_000_000_000_000,
|
goldPerSecond: 16_000_000_000_000,
|
||||||
id: "transcendent_rogue",
|
id: "transcendent_rogue",
|
||||||
level: 31,
|
level: 32,
|
||||||
name: "Transcendent Rogue",
|
name: "Transcendent Rogue",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 7_000_000_000,
|
essencePerSecond: 7_000_000_000,
|
||||||
goldPerSecond: 50_000_000_000_000,
|
goldPerSecond: 50_000_000_000_000,
|
||||||
id: "omniversal_champion",
|
id: "omniversal_champion",
|
||||||
level: 32,
|
level: 33,
|
||||||
name: "Omniversal Champion",
|
name: "Omniversal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+172
-172
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 },
|
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 },
|
||||||
description:
|
description:
|
||||||
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -305,9 +305,9 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 },
|
||||||
description:
|
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,
|
equipped: false,
|
||||||
id: "philosophers_stone",
|
id: "philosophers_stone",
|
||||||
name: "Philosopher's Stone",
|
name: "Philosopher's Stone",
|
||||||
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
|
||||||
description:
|
description:
|
||||||
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -695,9 +695,171 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
setId: "eternal_throne",
|
setId: "eternal_throne",
|
||||||
type: "trinket",
|
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 ─────────────────────────────────────────────
|
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 3 },
|
bonus: { clickMultiplier: 4.25 },
|
||||||
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
||||||
@@ -721,7 +883,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 7 },
|
bonus: { combatMultiplier: 10.5 },
|
||||||
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
"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",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 4.75 },
|
bonus: { goldMultiplier: 7.5 },
|
||||||
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
"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",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 },
|
bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 },
|
||||||
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
||||||
|
|||||||
+144
-144
File diff suppressed because it is too large
Load Diff
@@ -92,20 +92,20 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
description:
|
description:
|
||||||
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
|
"The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
|
||||||
id: "income_10",
|
id: "income_10",
|
||||||
multiplier: 500,
|
multiplier: 200,
|
||||||
name: "Eternal Rune I",
|
name: "Eternal Rune I",
|
||||||
runestonesCost: 30_000,
|
runestonesCost: 22_500,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
description:
|
description:
|
||||||
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
|
"Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
|
||||||
id: "income_11",
|
id: "income_11",
|
||||||
multiplier: 1000,
|
multiplier: 500,
|
||||||
name: "Eternal Rune II",
|
name: "Eternal Rune II",
|
||||||
runestonesCost: 80_000,
|
runestonesCost: 60_000,
|
||||||
},
|
},
|
||||||
// ── Click Power ───────────────────────────────────────────────────────────
|
// ── Click Power ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
|||||||
+604
-251
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.08 },
|
bonus: { type: "combat_power", value: 1.2 },
|
||||||
description:
|
description:
|
||||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
"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",
|
id: "elder_bark_shield",
|
||||||
@@ -75,7 +75,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "gold_income", value: 1.1 },
|
bonus: { type: "gold_income", value: 1.15 },
|
||||||
description:
|
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.",
|
"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",
|
id: "void_fragment_amulet",
|
||||||
@@ -101,7 +101,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.1 },
|
bonus: { type: "combat_power", value: 1.15 },
|
||||||
description:
|
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.",
|
"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",
|
id: "cursed_focus",
|
||||||
@@ -127,7 +127,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.12 },
|
bonus: { type: "combat_power", value: 1.2 },
|
||||||
description:
|
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.",
|
"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",
|
id: "elemental_ore_ingot",
|
||||||
@@ -193,7 +193,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 8: abyssal_trench
|
// Zone 8: abyssal_trench
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.15 },
|
bonus: { type: "combat_power", value: 1.25 },
|
||||||
description:
|
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.",
|
"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",
|
id: "pressure_forged_core",
|
||||||
@@ -231,7 +231,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "essence_income", value: 1.15 },
|
bonus: { type: "essence_income", value: 1.2 },
|
||||||
description:
|
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.",
|
"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",
|
id: "soul_bound_catalyst",
|
||||||
@@ -271,7 +271,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 11: void_sanctum
|
// Zone 11: void_sanctum
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.18 },
|
bonus: { type: "combat_power", value: 1.28 },
|
||||||
description:
|
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.",
|
"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",
|
id: "null_field_generator",
|
||||||
@@ -309,7 +309,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.2 },
|
bonus: { type: "combat_power", value: 1.3 },
|
||||||
description:
|
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.",
|
"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",
|
id: "eternity_bound_ring",
|
||||||
@@ -375,7 +375,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 15: reality_forge
|
// Zone 15: reality_forge
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.22 },
|
bonus: { type: "combat_power", value: 1.35 },
|
||||||
description:
|
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.",
|
"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",
|
id: "reality_ingot",
|
||||||
@@ -427,7 +427,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 17: primeval_sanctum
|
// Zone 17: primeval_sanctum
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.25 },
|
bonus: { type: "combat_power", value: 1.4 },
|
||||||
description:
|
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.",
|
"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",
|
id: "ancient_memory_array",
|
||||||
@@ -451,7 +451,88 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Cross-zone recipes ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { type: "gold_income", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
|
||||||
|
id: "verdant_pyre_seal",
|
||||||
|
name: "Verdant Pyre Seal",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "verdant_sap", quantity: 8 },
|
||||||
|
{ materialId: "ember_crystal", quantity: 6 },
|
||||||
|
{ materialId: "legendary_ore", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "volcanic_depths",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "click_power", value: 1.22 },
|
||||||
|
description:
|
||||||
|
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
|
||||||
|
id: "voidfrost_weave",
|
||||||
|
name: "Voidfrost Weave",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "glacial_ice", quantity: 8 },
|
||||||
|
{ materialId: "void_shard", quantity: 3 },
|
||||||
|
{ materialId: "shadow_essence", quantity: 5 },
|
||||||
|
],
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "essence_income", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
|
||||||
|
id: "choir_of_the_deep",
|
||||||
|
name: "Choir of the Deep",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "celestial_dust", quantity: 8 },
|
||||||
|
{ materialId: "choir_shard", quantity: 2 },
|
||||||
|
{ materialId: "ancient_tooth", quantity: 2 },
|
||||||
|
{ materialId: "pressure_gem", quantity: 5 },
|
||||||
|
],
|
||||||
|
zoneId: "abyssal_trench",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "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",
|
||||||
|
name: "Eternal Omega",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "crown_fragment", quantity: 6 },
|
||||||
|
{ materialId: "eternity_splinter", quantity: 2 },
|
||||||
|
{ materialId: "boundary_shard", quantity: 4 },
|
||||||
|
{ materialId: "omega_crystal", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "the_absolute",
|
||||||
|
},
|
||||||
|
|
||||||
// Zone 18: the_absolute
|
// Zone 18: the_absolute
|
||||||
|
{
|
||||||
|
bonus: { type: "click_power", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
|
||||||
|
id: "absolute_focus",
|
||||||
|
name: "Absolute Focus",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "absolute_fragment", quantity: 8 },
|
||||||
|
{ materialId: "omega_crystal", quantity: 3 },
|
||||||
|
],
|
||||||
|
zoneId: "the_absolute",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "gold_income", value: 1.3 },
|
bonus: { type: "gold_income", value: 1.3 },
|
||||||
description:
|
description:
|
||||||
@@ -465,7 +546,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.3 },
|
bonus: { type: "combat_power", value: 1.55 },
|
||||||
description:
|
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.",
|
"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",
|
id: "omega_convergence",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Income multipliers ──────────────────────────────────────────────────────
|
// ── Income multipliers ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 5,
|
cost: 2,
|
||||||
description:
|
description:
|
||||||
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
||||||
id: "echo_income_1",
|
id: "echo_income_1",
|
||||||
@@ -20,7 +20,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 10,
|
cost: 4,
|
||||||
description:
|
description:
|
||||||
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
||||||
id: "echo_income_2",
|
id: "echo_income_2",
|
||||||
@@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 20,
|
cost: 8,
|
||||||
description:
|
description:
|
||||||
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
||||||
id: "echo_income_3",
|
id: "echo_income_3",
|
||||||
@@ -38,7 +38,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 40,
|
cost: 16,
|
||||||
description:
|
description:
|
||||||
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
||||||
id: "echo_income_4",
|
id: "echo_income_4",
|
||||||
@@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 80,
|
cost: 32,
|
||||||
description:
|
description:
|
||||||
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
||||||
id: "echo_income_5",
|
id: "echo_income_5",
|
||||||
@@ -58,7 +58,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Combat multipliers ──────────────────────────────────────────────────────
|
// ── Combat multipliers ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "combat",
|
category: "combat",
|
||||||
cost: 5,
|
cost: 2,
|
||||||
description:
|
description:
|
||||||
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
||||||
id: "echo_combat_1",
|
id: "echo_combat_1",
|
||||||
@@ -67,7 +67,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "combat",
|
category: "combat",
|
||||||
cost: 15,
|
cost: 6,
|
||||||
description:
|
description:
|
||||||
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
||||||
id: "echo_combat_2",
|
id: "echo_combat_2",
|
||||||
@@ -76,7 +76,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "combat",
|
category: "combat",
|
||||||
cost: 35,
|
cost: 12,
|
||||||
description:
|
description:
|
||||||
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
||||||
id: "echo_combat_3",
|
id: "echo_combat_3",
|
||||||
@@ -87,7 +87,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Prestige threshold reductions ──────────────────────────────────────────
|
// ── Prestige threshold reductions ──────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "prestige_threshold",
|
category: "prestige_threshold",
|
||||||
cost: 8,
|
cost: 3,
|
||||||
description:
|
description:
|
||||||
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
||||||
id: "echo_prestige_threshold_1",
|
id: "echo_prestige_threshold_1",
|
||||||
@@ -96,7 +96,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "prestige_threshold",
|
category: "prestige_threshold",
|
||||||
cost: 20,
|
cost: 6,
|
||||||
description:
|
description:
|
||||||
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
||||||
id: "echo_prestige_threshold_2",
|
id: "echo_prestige_threshold_2",
|
||||||
@@ -107,7 +107,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "prestige_runestones",
|
category: "prestige_runestones",
|
||||||
cost: 8,
|
cost: 3,
|
||||||
description:
|
description:
|
||||||
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
||||||
id: "echo_prestige_runestones_1",
|
id: "echo_prestige_runestones_1",
|
||||||
@@ -116,7 +116,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "prestige_runestones",
|
category: "prestige_runestones",
|
||||||
cost: 20,
|
cost: 6,
|
||||||
description:
|
description:
|
||||||
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
||||||
id: "echo_prestige_runestones_2",
|
id: "echo_prestige_runestones_2",
|
||||||
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 10,
|
cost: 15,
|
||||||
description:
|
description:
|
||||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||||
id: "echo_meta_1",
|
id: "echo_meta_1",
|
||||||
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 25,
|
cost: 45,
|
||||||
description:
|
description:
|
||||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||||
id: "echo_meta_2",
|
id: "echo_meta_2",
|
||||||
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 50,
|
cost: 100,
|
||||||
description:
|
description:
|
||||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||||
id: "echo_meta_3",
|
id: "echo_meta_3",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
costCrystals: 100,
|
costCrystals: 50,
|
||||||
costEssence: 0,
|
costEssence: 0,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
@@ -104,7 +104,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
description:
|
description:
|
||||||
"Forge partnerships with mage guilds across the realm. All income +50%.",
|
"Forge partnerships with mage guilds across the realm. All income +50%.",
|
||||||
id: "essence_guild",
|
id: "essence_guild",
|
||||||
multiplier: 1.5,
|
multiplier: 2,
|
||||||
name: "Essence Guild",
|
name: "Essence Guild",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
target: "global",
|
target: "global",
|
||||||
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "peasant",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 20,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Organised labour guilds and proper scheduling make peasants ten times more productive.",
|
||||||
|
id: "peasant_2",
|
||||||
|
multiplier: 10,
|
||||||
|
name: "Guild Organisation",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "peasant",
|
||||||
|
costCrystals: 50,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Magical augmentation through crystalline resonance supercharges even the humblest worker.",
|
||||||
|
id: "peasant_3",
|
||||||
|
multiplier: 50,
|
||||||
|
name: "Crystal Augmentation",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
adventurerId: "militia",
|
adventurerId: "militia",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
costEssence: 2,
|
costEssence: 2,
|
||||||
costGold: 5000,
|
costGold: 5000,
|
||||||
description: "Ancient books of magic double mage output.",
|
description: "Ancient books of magic double mage output.",
|
||||||
id: "mage_1",
|
id: "apprentice_1",
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
name: "Arcane Tomes",
|
name: "Arcane Tomes",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
costEssence: 3,
|
costEssence: 3,
|
||||||
costGold: 8000,
|
costGold: 8000,
|
||||||
description: "Sacred ceremonies double the output of your clerics.",
|
description: "Sacred ceremonies double the output of your clerics.",
|
||||||
id: "cleric_1",
|
id: "acolyte_1",
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
name: "Holy Rites",
|
name: "Holy Rites",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
adventurerId: "shadow_assassin",
|
|
||||||
costCrystals: 0,
|
|
||||||
costEssence: 50,
|
|
||||||
costGold: 0,
|
|
||||||
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
|
||||||
id: "shadow_assassin_1",
|
|
||||||
multiplier: 2,
|
|
||||||
name: "Shadow Arts",
|
|
||||||
purchased: false,
|
|
||||||
target: "adventurer",
|
|
||||||
unlocked: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
adventurerId: "arcane_scholar",
|
adventurerId: "arcane_scholar",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 150,
|
costEssence: 1000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "Access to forbidden libraries doubles scholar output.",
|
description: "Access to forbidden libraries doubles scholar output.",
|
||||||
id: "arcane_scholar_1",
|
id: "arcane_scholar_1",
|
||||||
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "shadow_assassin",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5000,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
||||||
|
id: "shadow_assassin_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Shadow Arts",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "dark_templar",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 25_000,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"A sworn oath to the darkness of the marshes doubles templar output.",
|
||||||
|
id: "dark_templar_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Templar's Oath",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
adventurerId: "void_walker",
|
adventurerId: "void_walker",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 300,
|
costEssence: 100_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
"Walking through the void itself doubles the output of your void walkers.",
|
"Walking through the void itself doubles the output of your void walkers.",
|
||||||
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "celestial_guard",
|
adventurerId: "celestial_guard",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 750,
|
costEssence: 500_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
"A blessing from the celestials themselves doubles guard output.",
|
"A blessing from the celestials themselves doubles guard output.",
|
||||||
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "divine_champion",
|
adventurerId: "divine_champion",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 2000,
|
costEssence: 2_000_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "An unbreakable oath to the divine doubles champion output.",
|
description: "An unbreakable oath to the divine doubles champion output.",
|
||||||
id: "divine_champion_1",
|
id: "divine_champion_1",
|
||||||
@@ -417,7 +459,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
costCrystals: 10_000_000,
|
costCrystals: 50_000_000,
|
||||||
costEssence: 0,
|
costEssence: 0,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "Transcend mortal limits through void energy. All income x3.",
|
description: "Transcend mortal limits through void energy. All income x3.",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { leaderboardRouter } from "./routes/leaderboards.js";
|
|||||||
import { prestigeRouter } from "./routes/prestige.js";
|
import { prestigeRouter } from "./routes/prestige.js";
|
||||||
import { profileRouter } from "./routes/profile.js";
|
import { profileRouter } from "./routes/profile.js";
|
||||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||||
|
import { connectGateway } from "./services/gateway.js";
|
||||||
import { logger } from "./services/logger.js";
|
import { logger } from "./services/logger.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -68,6 +69,7 @@ const port = Number(process.env.PORT ?? 3001);
|
|||||||
try {
|
try {
|
||||||
serve({ fetch: app.fetch, port: port }, () => {
|
serve({ fetch: app.fetch, port: port }, () => {
|
||||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||||
|
connectGateway();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
void logger.error(
|
void logger.error(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "../services/discord.js";
|
} from "../services/discord.js";
|
||||||
import { signToken } from "../services/jwt.js";
|
import { signToken } from "../services/jwt.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
|
import { grantElysianRole } from "../services/webhook.js";
|
||||||
import type { Player } from "@elysium/types";
|
import type { Player } from "@elysium/types";
|
||||||
|
|
||||||
const authRouter = new Hono();
|
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);
|
const jwtToken = signToken(player.discordId);
|
||||||
void logger.log("info", `New player registered: ${player.discordId}`);
|
void logger.log("info", `New player registered: ${player.discordId}`);
|
||||||
void logger.metric("user_registered", 1, { discordId: 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({
|
const updated = await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
avatar: discordUser.avatar,
|
avatar: discordUser.avatar,
|
||||||
discriminator: discordUser.discriminator,
|
discriminator: discordUser.discriminator,
|
||||||
|
inGuild: inGuild,
|
||||||
username: discordUser.username,
|
username: discordUser.username,
|
||||||
},
|
},
|
||||||
where: { discordId: discordUser.id },
|
where: { discordId: discordUser.id },
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
|||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||||
|
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||||
|
* Must be kept in sync with prestigeCombatBase in apps/web/src/engine/tick.ts.
|
||||||
|
*/
|
||||||
|
const prestigeCombatBase = 4;
|
||||||
|
|
||||||
const bossRouter = new Hono<HonoEnvironment>();
|
const bossRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
bossRouter.use("*", authMiddleware);
|
bossRouter.use("*", authMiddleware);
|
||||||
@@ -38,8 +45,7 @@ const calculatePartyStats = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
|
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count);
|
||||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
|
||||||
|
|
||||||
// Apply equipped weapon's combat bonus
|
// Apply equipped weapon's combat bonus
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
|||||||
@@ -148,11 +148,22 @@ craftRouter.post("/", async(context) => {
|
|||||||
|
|
||||||
const bonusType = recipe.bonus.type;
|
const bonusType = recipe.bonus.type;
|
||||||
const bonusValue = recipe.bonus.value;
|
const bonusValue = recipe.bonus.value;
|
||||||
|
const { materials } = state.exploration;
|
||||||
|
const {
|
||||||
|
craftedGoldMultiplier,
|
||||||
|
craftedEssenceMultiplier,
|
||||||
|
craftedClickMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
} = updatedMultipliers;
|
||||||
const response: CraftRecipeResponse = {
|
const response: CraftRecipeResponse = {
|
||||||
bonusType,
|
bonusType,
|
||||||
bonusValue,
|
bonusValue,
|
||||||
|
craftedClickMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
craftedEssenceMultiplier,
|
||||||
|
craftedGoldMultiplier,
|
||||||
|
materials,
|
||||||
recipeId,
|
recipeId,
|
||||||
...updatedMultipliers,
|
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,18 +7,27 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
|
||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
|
import {
|
||||||
|
STORY_CHAPTERS,
|
||||||
|
isStoryChapterUnlocked,
|
||||||
|
type GameState,
|
||||||
|
} from "@elysium/types";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { defaultAchievements } from "../data/achievements.js";
|
||||||
|
import { defaultAdventurers } from "../data/adventurers.js";
|
||||||
import { defaultBosses } from "../data/bosses.js";
|
import { defaultBosses } from "../data/bosses.js";
|
||||||
|
import { defaultEquipment } from "../data/equipment.js";
|
||||||
import { defaultExplorations } from "../data/explorations.js";
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
import { initialGameState } from "../data/initialState.js";
|
import { initialGameState } from "../data/initialState.js";
|
||||||
import { defaultQuests } from "../data/quests.js";
|
import { defaultQuests } from "../data/quests.js";
|
||||||
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||||
|
import { defaultUpgrades } from "../data/upgrades.js";
|
||||||
import { defaultZones } from "../data/zones.js";
|
import { defaultZones } from "../data/zones.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the HMAC-SHA256 of data using the given secret.
|
* Computes the HMAC-SHA256 of data using the given secret.
|
||||||
@@ -257,6 +266,180 @@ const applyBossUnlocks = (state: GameState): number => {
|
|||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks any adventurer tiers that were granted as rewards for completed quests
|
||||||
|
* but are still locked in the player's state.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of adventurer tiers that were unlocked.
|
||||||
|
*/
|
||||||
|
const applyAdventurerUnlocks = (state: GameState): number => {
|
||||||
|
let count = 0;
|
||||||
|
const completedQuestIds = new Set(
|
||||||
|
state.quests.
|
||||||
|
filter((q) => {
|
||||||
|
return q.status === "completed";
|
||||||
|
}).
|
||||||
|
map((q) => {
|
||||||
|
return q.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const earnedAdventurerIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const questDefinition of defaultQuests) {
|
||||||
|
if (!completedQuestIds.has(questDefinition.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const reward of questDefinition.rewards) {
|
||||||
|
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
||||||
|
earnedAdventurerIds.add(reward.targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
|
||||||
|
adventurer.unlocked = true;
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects all upgrade IDs the player has legitimately earned via boss defeats
|
||||||
|
* and completed quest rewards, sourcing reward data from game definitions.
|
||||||
|
* @param state - The player's current game state.
|
||||||
|
* @returns A set of earned upgrade IDs.
|
||||||
|
*/
|
||||||
|
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
|
||||||
|
const earnedIds = new Set<string>();
|
||||||
|
const defeatedBossIds = new Set(
|
||||||
|
state.bosses.
|
||||||
|
filter((b) => {
|
||||||
|
return b.status === "defeated";
|
||||||
|
}).
|
||||||
|
map((b) => {
|
||||||
|
return b.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const completedQuestIds = new Set(
|
||||||
|
state.quests.
|
||||||
|
filter((q) => {
|
||||||
|
return q.status === "completed";
|
||||||
|
}).
|
||||||
|
map((q) => {
|
||||||
|
return q.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const bossDefinition of defaultBosses) {
|
||||||
|
if (!defeatedBossIds.has(bossDefinition.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const upgradeId of bossDefinition.upgradeRewards) {
|
||||||
|
earnedIds.add(upgradeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const questDefinition of defaultQuests) {
|
||||||
|
if (!completedQuestIds.has(questDefinition.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const reward of questDefinition.rewards) {
|
||||||
|
if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
||||||
|
earnedIds.add(reward.targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return earnedIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks any upgrades that were granted as rewards for defeated bosses or
|
||||||
|
* completed quests but are still locked in the player's state.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of upgrades that were unlocked.
|
||||||
|
*/
|
||||||
|
const applyUpgradeUnlocks = (state: GameState): number => {
|
||||||
|
let count = 0;
|
||||||
|
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
|
||||||
|
|
||||||
|
for (const upgrade of state.upgrades) {
|
||||||
|
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
|
||||||
|
upgrade.unlocked = true;
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks as owned any equipment that was granted as a reward for defeated bosses
|
||||||
|
* but is still unowned in the player's state.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of equipment items that were marked as owned.
|
||||||
|
*/
|
||||||
|
const applyEquipmentUnlocks = (state: GameState): number => {
|
||||||
|
let count = 0;
|
||||||
|
const defeatedBossIds = new Set(
|
||||||
|
state.bosses.
|
||||||
|
filter((b) => {
|
||||||
|
return b.status === "defeated";
|
||||||
|
}).
|
||||||
|
map((b) => {
|
||||||
|
return b.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const earnedEquipmentIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const bossDefinition of defaultBosses) {
|
||||||
|
if (!defeatedBossIds.has(bossDefinition.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const equipmentId of bossDefinition.equipmentRewards) {
|
||||||
|
earnedEquipmentIds.add(equipmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of state.equipment) {
|
||||||
|
if (!item.owned && earnedEquipmentIds.has(item.id)) {
|
||||||
|
item.owned = true;
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks any story chapters whose conditions are met by the current game state
|
||||||
|
* but are still absent from the player's unlockedChapterIds list.
|
||||||
|
* @param state - The player's current game state (mutated directly).
|
||||||
|
* @returns The number of story chapters that were unlocked.
|
||||||
|
*/
|
||||||
|
const applyStoryUnlocks = (state: GameState): number => {
|
||||||
|
if (state.story === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
|
||||||
|
|
||||||
|
for (const chapter of STORY_CHAPTERS) {
|
||||||
|
if (alreadyUnlocked.has(chapter.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isStoryChapterUnlocked(chapter, state)) {
|
||||||
|
state.story.unlockedChapterIds.push(chapter.id);
|
||||||
|
count = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes available any exploration areas whose parent zone is now unlocked.
|
* Makes available any exploration areas whose parent zone is now unlocked.
|
||||||
* @param state - The player's current game state (mutated directly).
|
* @param state - The player's current game state (mutated directly).
|
||||||
@@ -301,18 +484,552 @@ const applyExplorationUnlocks = (state: GameState): number => {
|
|||||||
const applyForceUnlocks = (
|
const applyForceUnlocks = (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
): {
|
): {
|
||||||
|
adventurersUnlocked: number;
|
||||||
bossesUnlocked: number;
|
bossesUnlocked: number;
|
||||||
|
equipmentUnlocked: number;
|
||||||
explorationUnlocked: number;
|
explorationUnlocked: number;
|
||||||
questsUnlocked: number;
|
questsUnlocked: number;
|
||||||
|
storyUnlocked: number;
|
||||||
|
upgradesUnlocked: number;
|
||||||
zonesUnlocked: number;
|
zonesUnlocked: number;
|
||||||
} => {
|
} => {
|
||||||
const zonesUnlocked = applyZoneUnlocks(state);
|
const zonesUnlocked = applyZoneUnlocks(state);
|
||||||
const questsUnlocked = applyQuestUnlocks(state);
|
const questsUnlocked = applyQuestUnlocks(state);
|
||||||
const bossesUnlocked = applyBossUnlocks(state);
|
const bossesUnlocked = applyBossUnlocks(state);
|
||||||
const explorationUnlocked = applyExplorationUnlocks(state);
|
const explorationUnlocked = applyExplorationUnlocks(state);
|
||||||
return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked };
|
const adventurersUnlocked = applyAdventurerUnlocks(state);
|
||||||
|
const upgradesUnlocked = applyUpgradeUnlocks(state);
|
||||||
|
const equipmentUnlocked = applyEquipmentUnlocks(state);
|
||||||
|
const storyUnlocked = applyStoryUnlocks(state);
|
||||||
|
return {
|
||||||
|
adventurersUnlocked,
|
||||||
|
bossesUnlocked,
|
||||||
|
equipmentUnlocked,
|
||||||
|
explorationUnlocked,
|
||||||
|
questsUnlocked,
|
||||||
|
storyUnlocked,
|
||||||
|
upgradesUnlocked,
|
||||||
|
zonesUnlocked,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects any entries from a defaults array that are missing from an existing
|
||||||
|
* saved array (matched by `id`), cloning each new entry before pushing.
|
||||||
|
* @param existing - The player's saved array (mutated in place).
|
||||||
|
* @param defaults - The current default data array to compare against.
|
||||||
|
* @returns The number of entries that were added.
|
||||||
|
*/
|
||||||
|
const injectMissingEntries = <T extends { id: string }>(
|
||||||
|
existing: Array<T>,
|
||||||
|
defaults: Array<T>,
|
||||||
|
): number => {
|
||||||
|
const existingIds = new Set(existing.map((item) => {
|
||||||
|
return item.id;
|
||||||
|
}));
|
||||||
|
let added = 0;
|
||||||
|
for (const item of defaults) {
|
||||||
|
if (!existingIds.has(item.id)) {
|
||||||
|
existing.push(structuredClone(item));
|
||||||
|
added = added + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const defaultOrder = new Map(defaults.map((item, index) => {
|
||||||
|
return [ item.id, index ] as const;
|
||||||
|
}));
|
||||||
|
existing.sort((itemA, itemB) => {
|
||||||
|
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
return added;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects any exploration areas from the defaults that are missing from the
|
||||||
|
* player's exploration state, seeding each new area as locked.
|
||||||
|
* @param state - The player's current game state (mutated in place).
|
||||||
|
* @returns The number of exploration areas that were added.
|
||||||
|
*/
|
||||||
|
const injectMissingExplorationAreas = (state: GameState): number => {
|
||||||
|
if (state.exploration === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const existingIds = new Set(state.exploration.areas.map((area) => {
|
||||||
|
return area.id;
|
||||||
|
}));
|
||||||
|
let added = 0;
|
||||||
|
for (const area of defaultExplorations) {
|
||||||
|
if (!existingIds.has(area.id)) {
|
||||||
|
state.exploration.areas.push({ id: area.id, status: "locked" });
|
||||||
|
added = added + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patches rewards on existing quests whose reward lists have grown since the
|
||||||
|
* save was created (e.g. A new upgrade added as a reward to an old quest).
|
||||||
|
* @param state - The player's current game state (mutated in place).
|
||||||
|
* @returns The total number of individual rewards that were added.
|
||||||
|
*/
|
||||||
|
const patchQuestRewards = (state: GameState): number => {
|
||||||
|
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
|
||||||
|
return [ quest.id, quest ] as const;
|
||||||
|
}));
|
||||||
|
let added = 0;
|
||||||
|
for (const savedQuest of state.quests) {
|
||||||
|
const defaultQuest = defaultQuestMap.get(savedQuest.id);
|
||||||
|
if (defaultQuest === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingKeys = new Set(savedQuest.rewards.map((reward) => {
|
||||||
|
return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
||||||
|
}));
|
||||||
|
for (const reward of defaultQuest.rewards) {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
||||||
|
if (!existingKeys.has(key)) {
|
||||||
|
savedQuest.rewards.push(structuredClone(reward));
|
||||||
|
added = added + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patches upgradeRewards on existing bosses whose reward lists have grown
|
||||||
|
* since the save was created.
|
||||||
|
* @param state - The player's current game state (mutated in place).
|
||||||
|
* @returns The total number of upgrade reward IDs that were added.
|
||||||
|
*/
|
||||||
|
const patchBossUpgradeRewards = (state: GameState): number => {
|
||||||
|
const defaultBossMap = new Map(defaultBosses.map((boss) => {
|
||||||
|
return [ boss.id, boss ] as const;
|
||||||
|
}));
|
||||||
|
let added = 0;
|
||||||
|
for (const savedBoss of state.bosses) {
|
||||||
|
const defaultBoss = defaultBossMap.get(savedBoss.id);
|
||||||
|
if (defaultBoss === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingIds = new Set(savedBoss.upgradeRewards);
|
||||||
|
for (const upgradeId of defaultBoss.upgradeRewards) {
|
||||||
|
if (!existingIds.has(upgradeId)) {
|
||||||
|
savedBoss.upgradeRewards.push(upgradeId);
|
||||||
|
added = added + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
* 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 or patched per content type.
|
||||||
|
*/
|
||||||
|
const syncNewContent = (
|
||||||
|
state: GameState,
|
||||||
|
): {
|
||||||
|
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,
|
||||||
|
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 */
|
||||||
|
|
||||||
const debugRouter = new Hono<HonoEnvironment>();
|
const debugRouter = new Hono<HonoEnvironment>();
|
||||||
debugRouter.use(authMiddleware);
|
debugRouter.use(authMiddleware);
|
||||||
|
|
||||||
@@ -330,8 +1047,16 @@ debugRouter.post("/force-unlocks", async(context) => {
|
|||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||||
const state = gameStateRecord.state as unknown as GameState;
|
const state = gameStateRecord.state as unknown as GameState;
|
||||||
|
|
||||||
const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }
|
const {
|
||||||
= applyForceUnlocks(state);
|
adventurersUnlocked,
|
||||||
|
bossesUnlocked,
|
||||||
|
equipmentUnlocked,
|
||||||
|
explorationUnlocked,
|
||||||
|
questsUnlocked,
|
||||||
|
storyUnlocked,
|
||||||
|
upgradesUnlocked,
|
||||||
|
zonesUnlocked,
|
||||||
|
} = applyForceUnlocks(state);
|
||||||
|
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
@@ -347,11 +1072,15 @@ debugRouter.post("/force-unlocks", async(context) => {
|
|||||||
: computeHmac(JSON.stringify(state), secret);
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
|
adventurersUnlocked,
|
||||||
bossesUnlocked,
|
bossesUnlocked,
|
||||||
|
equipmentUnlocked,
|
||||||
explorationUnlocked,
|
explorationUnlocked,
|
||||||
questsUnlocked,
|
questsUnlocked,
|
||||||
signature,
|
signature,
|
||||||
state,
|
state,
|
||||||
|
storyUnlocked,
|
||||||
|
upgradesUnlocked,
|
||||||
zonesUnlocked,
|
zonesUnlocked,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -365,6 +1094,87 @@ debugRouter.post("/force-unlocks", async(context) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
debugRouter.post("/sync-new-content", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const gameStateRecord = await prisma.gameState.findUnique({
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
if (!gameStateRecord) {
|
||||||
|
return context.json({ error: "No game state found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||||
|
const state = gameStateRecord.state as unknown as GameState;
|
||||||
|
|
||||||
|
const {
|
||||||
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
|
adventurersAdded,
|
||||||
|
adventurerStatsPatched,
|
||||||
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
|
bossRewardsPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
|
} = syncNewContent(state);
|
||||||
|
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: updatedAt },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const signature
|
||||||
|
= secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
|
adventurerStatsPatched,
|
||||||
|
adventurersAdded,
|
||||||
|
bossRewardsPatched,
|
||||||
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
|
signature,
|
||||||
|
state,
|
||||||
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"debug_sync_new_content",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
debugRouter.post("/hard-reset", async(context) => {
|
debugRouter.post("/hard-reset", async(context) => {
|
||||||
try {
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
|
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultExplorations } from "../data/explorations.js";
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
import { initialExploration } from "../data/initialState.js";
|
import { initialExploration } from "../data/initialState.js";
|
||||||
@@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js";
|
|||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
@@ -49,6 +51,64 @@ const pickNothingMessage = (): string => {
|
|||||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exploreRouter.get("/claimable", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
const areaId = context.req.query("areaId");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||||
|
if (!areaId) {
|
||||||
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
|
return a.id === areaId;
|
||||||
|
});
|
||||||
|
if (!explorationArea) {
|
||||||
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawState: unknown = record.state;
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
|
const state = rawState as GameState;
|
||||||
|
|
||||||
|
if (!state.exploration) {
|
||||||
|
const response: ExploreClaimableResponse = { claimable: false };
|
||||||
|
return context.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = state.exploration.areas.find((a) => {
|
||||||
|
return a.id === areaId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!area || area.status !== "in_progress") {
|
||||||
|
const response: ExploreClaimableResponse = { claimable: false };
|
||||||
|
return context.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const startedAt = area.startedAt ?? 0;
|
||||||
|
const durationMs = explorationArea.durationSeconds * 1000;
|
||||||
|
const expiresAt = startedAt + durationMs;
|
||||||
|
const claimable = Date.now() >= expiresAt;
|
||||||
|
const response: ExploreClaimableResponse = { claimable };
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_claimable",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
exploreRouter.post("/start", async(context) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
try {
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { fetchDiscordUserById } from "../services/discord.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
|
|||||||
try {
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
Promise.all([
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
|
]),
|
||||||
|
fetchDiscordUserById(discordId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Refresh avatar in DB when Discord returns an updated hash
|
||||||
|
if (
|
||||||
|
freshDiscordUser !== null
|
||||||
|
&& playerRecord !== null
|
||||||
|
&& freshDiscordUser.avatar !== playerRecord.avatar
|
||||||
|
) {
|
||||||
|
playerRecord.avatar = freshDiscordUser.avatar;
|
||||||
|
void prisma.player.update({
|
||||||
|
data: { avatar: freshDiscordUser.avatar },
|
||||||
|
where: { discordId },
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
void logger.error(
|
||||||
|
"avatar_refresh",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
@@ -736,6 +760,7 @@ gameRouter.get("/load", async(context) => {
|
|||||||
: computeHmac(JSON.stringify(freshState), secret);
|
: computeHmac(JSON.stringify(freshState), secret);
|
||||||
return context.json({
|
return context.json({
|
||||||
currentSchemaVersion: currentSchemaVersion,
|
currentSchemaVersion: currentSchemaVersion,
|
||||||
|
inGuild: playerRecord.inGuild,
|
||||||
loginBonus: null,
|
loginBonus: null,
|
||||||
loginStreak: playerRecord.loginStreak,
|
loginStreak: playerRecord.loginStreak,
|
||||||
offlineEssence: 0,
|
offlineEssence: 0,
|
||||||
@@ -757,6 +782,7 @@ gameRouter.get("/load", async(context) => {
|
|||||||
*/
|
*/
|
||||||
if (playerRecord !== null) {
|
if (playerRecord !== null) {
|
||||||
state.player.characterName = playerRecord.characterName;
|
state.player.characterName = playerRecord.characterName;
|
||||||
|
state.player.avatar = playerRecord.avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -873,8 +899,10 @@ gameRouter.get("/load", async(context) => {
|
|||||||
const signature = secret === undefined
|
const signature = secret === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: computeHmac(JSON.stringify(state), secret);
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
const inGuild = playerRecord?.inGuild ?? false;
|
||||||
return context.json({
|
return context.json({
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
inGuild,
|
||||||
loginBonus,
|
loginBonus,
|
||||||
loginStreak,
|
loginStreak,
|
||||||
offlineEssence,
|
offlineEssence,
|
||||||
|
|||||||
@@ -102,12 +102,23 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const now = Date.now();
|
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 */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: finalState as object, updatedAt: now },
|
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({
|
await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
characterName: state.player.characterName,
|
characterName: state.player.characterName,
|
||||||
@@ -136,17 +147,30 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
|
|
||||||
const prestigeCount = prestigeData.count;
|
const prestigeCount = prestigeData.count;
|
||||||
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
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,
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
select: { profileSettings: true },
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
where: { discordId },
|
||||||
/* v8 ignore next 2 -- @preserve */
|
|
||||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
|
||||||
});
|
});
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
|
||||||
|
const playerSettings = playerRecord?.profileSettings as
|
||||||
|
Record<string, unknown> | null | undefined;
|
||||||
|
const announcementsEnabled
|
||||||
|
= playerSettings?.enablePrestigeAnnouncements !== false;
|
||||||
|
|
||||||
|
if (announcementsEnabled) {
|
||||||
|
void postMilestoneWebhook(discordId, "prestige", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: prestigeData.count,
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 2 -- @preserve */
|
||||||
|
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
milestoneRunestones: milestoneRunestones,
|
milestoneRunestones: milestoneRunestones,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
|||||||
: "suffix";
|
: "suffix";
|
||||||
return {
|
return {
|
||||||
enableNotifications: rawObject.enableNotifications === true,
|
enableNotifications: rawObject.enableNotifications === true,
|
||||||
|
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
|
||||||
enableSounds: rawObject.enableSounds === true,
|
enableSounds: rawObject.enableSounds === true,
|
||||||
numberFormat: numberFormat,
|
numberFormat: numberFormat,
|
||||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||||
@@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
|||||||
: "suffix";
|
: "suffix";
|
||||||
const profileSettings: ProfileSettings = {
|
const profileSettings: ProfileSettings = {
|
||||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||||
|
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
|
||||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||||
numberFormat: numberFormat,
|
numberFormat: numberFormat,
|
||||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const challengeTypes: Array<DailyChallengeType> = [
|
const progressionChallengeTypes: Array<DailyChallengeType> = [
|
||||||
"clicks",
|
|
||||||
"bossesDefeated",
|
"bossesDefeated",
|
||||||
"questsCompleted",
|
"questsCompleted",
|
||||||
"prestige",
|
"prestige",
|
||||||
@@ -80,7 +79,8 @@ const challengeTypes: Array<DailyChallengeType> = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates 3 daily challenges for the given date string, deterministically.
|
* Generates 3 daily challenges for the given date string, deterministically.
|
||||||
* Picks one challenge from 3 different randomly-selected types.
|
* Always includes a "clicks" challenge (always completable regardless of
|
||||||
|
* progression), then picks 2 more from the remaining types.
|
||||||
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
||||||
* @returns An array of 3 DailyChallenge objects.
|
* @returns An array of 3 DailyChallenge objects.
|
||||||
*/
|
*/
|
||||||
@@ -88,8 +88,10 @@ const generateDailyChallenges = (
|
|||||||
dateString: string,
|
dateString: string,
|
||||||
): Array<DailyChallenge> => {
|
): Array<DailyChallenge> => {
|
||||||
const seed = dateSeed(dateString);
|
const seed = dateSeed(dateString);
|
||||||
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
|
const selectedTypes: Array<DailyChallengeType> = [
|
||||||
slice(0, 3);
|
"clicks",
|
||||||
|
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2),
|
||||||
|
];
|
||||||
|
|
||||||
return selectedTypes.map((type, index) => {
|
return selectedTypes.map((type, index) => {
|
||||||
const templates = dailyChallengeTemplates.filter((template) => {
|
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 */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const discordClientId = "1479551654264049908";
|
||||||
|
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
@@ -31,24 +34,18 @@ interface DiscordUser {
|
|||||||
const exchangeCode = async(
|
const exchangeCode = async(
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<DiscordTokenResponse> => {
|
): Promise<DiscordTokenResponse> => {
|
||||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
|
||||||
|
|
||||||
if (
|
if (clientSecret === undefined || clientSecret === "") {
|
||||||
clientId === undefined || clientId === ""
|
|
||||||
|| clientSecret === undefined || clientSecret === ""
|
|
||||||
|| redirectUri === undefined || redirectUri === ""
|
|
||||||
) {
|
|
||||||
throw new Error("Discord OAuth environment variables are required");
|
throw new Error("Discord OAuth environment variables are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = new URLSearchParams({
|
const parameters = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: discordClientId,
|
||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
code: code,
|
code: code,
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: discordRedirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -106,25 +103,49 @@ const fetchDiscordUser = async(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a Discord user's profile by their Discord ID using the bot token.
|
||||||
|
* Returns null on any failure so callers are never blocked by Discord API issues.
|
||||||
|
* @param discordId - The Discord user ID to look up.
|
||||||
|
* @returns The Discord user object, or null if the fetch fails.
|
||||||
|
*/
|
||||||
|
const fetchDiscordUserById = async(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<DiscordUser | null> => {
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
if (botToken === undefined || botToken === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://discord.com/api/v10/users/${discordId}`,
|
||||||
|
{ headers: { Authorization: `Bot ${botToken}` } },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user_by_id",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the Discord OAuth authorisation URL.
|
* Builds the Discord OAuth authorisation URL.
|
||||||
* @returns The full OAuth URL to redirect the user to.
|
* @returns The full OAuth URL to redirect the user to.
|
||||||
* @throws {Error} If OAuth environment variables are missing.
|
* @throws {Error} If OAuth environment variables are missing.
|
||||||
*/
|
*/
|
||||||
const buildOAuthUrl = (): string => {
|
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({
|
const parameters = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: discordClientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: discordRedirectUri,
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "identify",
|
scope: "identify",
|
||||||
});
|
});
|
||||||
@@ -133,4 +154,4 @@ const buildOAuthUrl = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type { DiscordTokenResponse, DiscordUser };
|
export type { DiscordTokenResponse, DiscordUser };
|
||||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
|
||||||
|
|||||||
@@ -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";
|
} from "@elysium/types";
|
||||||
|
|
||||||
const basePrestigeGoldThreshold = 1_000_000;
|
const basePrestigeGoldThreshold = 1_000_000;
|
||||||
const thresholdScaleFactor = 5;
|
const runestonesPerPrestigeLevel = 15;
|
||||||
const runestonesPerPrestigeLevel = 10;
|
|
||||||
const milestoneInterval = 5;
|
const milestoneInterval = 5;
|
||||||
const milestoneRunestonesPerInterval = 25;
|
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.
|
* 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 — polynomial growth that peaks around prestige 8–10
|
||||||
|
* then gets easier as the production multiplier overtakes it.
|
||||||
* @param prestigeCount - The current number of prestiges completed.
|
* @param prestigeCount - The current number of prestiges completed.
|
||||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||||
* @returns The gold amount required to prestige.
|
* @returns The gold amount required to prestige.
|
||||||
@@ -33,7 +40,7 @@ const calculatePrestigeThreshold = (
|
|||||||
): number => {
|
): number => {
|
||||||
return (
|
return (
|
||||||
basePrestigeGoldThreshold
|
basePrestigeGoldThreshold
|
||||||
* Math.pow(thresholdScaleFactor, prestigeCount)
|
* Math.pow(prestigeCount + 1, 2)
|
||||||
* thresholdMultiplier
|
* thresholdMultiplier
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -107,7 +114,9 @@ interface RunestoneParameters {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates how many runestones the player earns from a prestige.
|
* 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 - The parameters for the runestone calculation.
|
||||||
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
||||||
* @param parameters.prestigeCount - The current prestige count.
|
* @param parameters.prestigeCount - The current prestige count.
|
||||||
@@ -123,9 +132,11 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
|
|||||||
echoRunestoneMultiplier = 1,
|
echoRunestoneMultiplier = 1,
|
||||||
} = parameters;
|
} = parameters;
|
||||||
const threshold = calculatePrestigeThreshold(prestigeCount);
|
const threshold = calculatePrestigeThreshold(prestigeCount);
|
||||||
const base
|
const base = Math.min(
|
||||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
|
Math.floor(Math.cbrt(totalGoldEarned / threshold))
|
||||||
* runestonesPerPrestigeLevel;
|
* runestonesPerPrestigeLevel,
|
||||||
|
maxBaseRunestones,
|
||||||
|
);
|
||||||
const runestoneMult = getCategoryMultiplier(
|
const runestoneMult = getCategoryMultiplier(
|
||||||
purchasedUpgradeIds,
|
purchasedUpgradeIds,
|
||||||
"runestones",
|
"runestones",
|
||||||
@@ -135,19 +146,20 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the new prestige production multiplier.
|
* 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.
|
* @param prestigeCount - The new prestige count.
|
||||||
* @returns The production multiplier for the new prestige level.
|
* @returns The production multiplier for the new prestige level.
|
||||||
*/
|
*/
|
||||||
const calculateProductionMultiplier = (
|
const calculateProductionMultiplier = (
|
||||||
prestigeCount: number,
|
prestigeCount: number,
|
||||||
): number => {
|
): number => {
|
||||||
return Math.pow(1.15, prestigeCount);
|
return Math.pow(1.3, prestigeCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the milestone runestone bonus for the given prestige count.
|
* 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.
|
* @param prestigeCount - The prestige count after the current prestige.
|
||||||
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
||||||
*/
|
*/
|
||||||
@@ -156,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const milestoneNumber = prestigeCount / milestoneInterval;
|
const milestoneNumber = prestigeCount / milestoneInterval;
|
||||||
return milestoneNumber * milestoneRunestonesPerInterval;
|
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,7 +263,8 @@ const buildPostPrestigeState = (
|
|||||||
* Preserve automation preferences across prestige — the player explicitly
|
* Preserve automation preferences across prestige — the player explicitly
|
||||||
* opted into these settings and would not expect them to silently reset.
|
* 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,
|
autoQuest: currentState.autoQuest ?? false,
|
||||||
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one";
|
|||||||
/**
|
/**
|
||||||
* Base constant used in the echo yield formula.
|
* Base constant used in the echo yield formula.
|
||||||
*/
|
*/
|
||||||
const echoFormulaConstant = 853;
|
const echoFormulaConstant = 224;
|
||||||
|
|
||||||
const getCategoryMultiplier = (
|
const getCategoryMultiplier = (
|
||||||
purchasedIds: Array<string>,
|
purchasedIds: Array<string>,
|
||||||
|
|||||||
@@ -15,6 +15,49 @@ const discordApi = "https://discord.com/api/v10";
|
|||||||
*/
|
*/
|
||||||
const suppressNotifications = 4096;
|
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.
|
* Grants the apotheosis Discord role to the given player if configured.
|
||||||
* Fails silently so role grant errors do not affect the game action.
|
* 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 grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
const guildId = process.env.DISCORD_GUILD_ID;
|
|
||||||
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
|
|
||||||
|
|
||||||
if (
|
if (botToken === undefined || botToken === "") {
|
||||||
botToken === undefined || botToken === ""
|
|
||||||
|| guildId === undefined || guildId === ""
|
|
||||||
|| roleId === undefined || roleId === ""
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(
|
await fetch(
|
||||||
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
|
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bot ${botToken}` },
|
headers: {
|
||||||
method: "PUT",
|
"Authorization": `Bot ${botToken}`,
|
||||||
|
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,4 +149,4 @@ const postMilestoneWebhook = async(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { grantApotheosisRole, postMilestoneWebhook };
|
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
|
||||||
|
|||||||
@@ -366,6 +366,161 @@ describe("debug route", () => {
|
|||||||
expect(body.explorationUnlocked).toBe(0);
|
expect(body.explorationUnlocked).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("unlocks adventurer tier when its quest has been completed", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"],
|
||||||
|
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { adventurersUnlocked: number };
|
||||||
|
expect(body.adventurersUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock adventurer tier when it is already unlocked", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"],
|
||||||
|
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { adventurersUnlocked: number };
|
||||||
|
expect(body.adventurersUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks upgrade when its boss has been defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||||
|
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { upgradesUnlocked: number };
|
||||||
|
expect(body.upgradesUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock upgrade when boss is not defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
|
||||||
|
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { upgradesUnlocked: number };
|
||||||
|
expect(body.upgradesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock upgrade when it is already unlocked", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||||
|
upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { upgradesUnlocked: number };
|
||||||
|
expect(body.upgradesUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks upgrade granted as a quest reward", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
||||||
|
upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { upgradesUnlocked: number };
|
||||||
|
expect(body.upgradesUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks equipment as owned when its boss has been defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||||
|
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { equipmentUnlocked: number };
|
||||||
|
expect(body.equipmentUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark equipment as owned when boss is not defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
|
||||||
|
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { equipmentUnlocked: number };
|
||||||
|
expect(body.equipmentUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark equipment as owned when it is already owned", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
||||||
|
equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { equipmentUnlocked: number };
|
||||||
|
expect(body.equipmentUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns storyUnlocked=0 when story is undefined", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
story: undefined as unknown as GameState["story"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { storyUnlocked: number };
|
||||||
|
expect(body.storyUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks story chapter when its boss has been defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
|
||||||
|
story: { completedChapters: [], unlockedChapterIds: [] },
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { storyUnlocked: number };
|
||||||
|
expect(body.storyUnlocked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock story chapter when boss is not defeated", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"],
|
||||||
|
story: { completedChapters: [], unlockedChapterIds: [] },
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { storyUnlocked: number };
|
||||||
|
expect(body.storyUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not unlock story chapter when it is already unlocked", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
|
||||||
|
story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] },
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await forceUnlocks();
|
||||||
|
const body = await res.json() as { storyUnlocked: number };
|
||||||
|
expect(body.storyUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
@@ -402,6 +557,579 @@ describe("debug route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncNewContent = () =>
|
||||||
|
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
|
||||||
|
|
||||||
|
describe("POST /sync-new-content", () => {
|
||||||
|
it("returns 404 when no game state found", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 100, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { adventurerStatsPatched: number };
|
||||||
|
expect(body.adventurerStatsPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips adventurer stat patching for adventurers not in defaults", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
|
||||||
|
});
|
||||||
|
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 () => {
|
||||||
|
const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], 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 { adventurersAdded: number; bossesAdded: number };
|
||||||
|
expect(body.adventurersAdded).toBeGreaterThan(0);
|
||||||
|
expect(body.bossesAdded).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects missing exploration areas when exploration has no areas", async () => {
|
||||||
|
const state = makeState({ exploration: makeExploration([]) });
|
||||||
|
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 { explorationAreasAdded: number };
|
||||||
|
expect(body.explorationAreasAdded).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips existing exploration areas when building the id set", async () => {
|
||||||
|
const state = makeState({ exploration: makeExploration([
|
||||||
|
{ id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0],
|
||||||
|
]) });
|
||||||
|
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 { explorationAreasAdded: number };
|
||||||
|
// One area already existed so total injected is one less than full count
|
||||||
|
expect(body.explorationAreasAdded).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns explorationAreasAdded=0 when exploration state 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 { explorationAreasAdded: number };
|
||||||
|
expect(body.explorationAreasAdded).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches quest rewards when saved quest has fewer rewards than default", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "first_steps", status: "available", rewards: [] }] 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 { state: GameState };
|
||||||
|
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
||||||
|
expect(quest?.rewards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips quest reward patching for quests not in defaults", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] 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 { state: GameState };
|
||||||
|
const quest = body.state.quests.find((q) => q.id === "nonexistent_quest");
|
||||||
|
expect(quest?.rewards).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-add rewards that are already present in the saved quest", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] 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 { state: GameState };
|
||||||
|
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
||||||
|
// Reward already present so count stays the same
|
||||||
|
expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] 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 { state: GameState };
|
||||||
|
const boss = body.state.bosses.find((b) => b.id === "troll_king");
|
||||||
|
expect(boss?.upgradeRewards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips boss reward patching for bosses not in defaults", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] 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 { state: GameState };
|
||||||
|
const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss");
|
||||||
|
expect(boss?.upgradeRewards).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
achievements: [
|
||||||
|
{ id: "legacy_achievement_a", status: "locked" },
|
||||||
|
{ id: "legacy_achievement_b", status: "locked" },
|
||||||
|
] 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses amount field when building the reward key for quests with amount-based rewards", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to empty string when reward has neither targetId nor amount", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
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 { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeDefined();
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when DB throws an Error", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when DB throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
||||||
|
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", () => {
|
describe("POST /hard-reset", () => {
|
||||||
it("returns 404 when no player found", async () => {
|
it("returns 404 when no player found", async () => {
|
||||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||||
|
|||||||
@@ -77,6 +77,99 @@ describe("explore route", () => {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const getClaimable = (areaId?: string) => {
|
||||||
|
const url = areaId === undefined
|
||||||
|
? "http://localhost/explore/claimable"
|
||||||
|
: `http://localhost/explore/claimable?areaId=${areaId}`;
|
||||||
|
return app.fetch(new Request(url));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GET /claimable", () => {
|
||||||
|
it("returns 400 when areaId is missing", async () => {
|
||||||
|
const res = await getClaimable();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for unknown area", async () => {
|
||||||
|
const res = await getClaimable("nonexistent_area");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when no save is found", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=false when no exploration state exists", async () => {
|
||||||
|
const state = makeState({ exploration: undefined });
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=false when area is not in_progress", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=false when exploration is still in progress", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: {
|
||||||
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
|
||||||
|
materials: [],
|
||||||
|
craftedRecipeIds: [],
|
||||||
|
craftedGoldMultiplier: 1,
|
||||||
|
craftedEssenceMultiplier: 1,
|
||||||
|
craftedClickMultiplier: 1,
|
||||||
|
craftedCombatMultiplier: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=true when exploration is complete", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: {
|
||||||
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
||||||
|
materials: [],
|
||||||
|
craftedRecipeIds: [],
|
||||||
|
craftedGoldMultiplier: 1,
|
||||||
|
craftedEssenceMultiplier: 1,
|
||||||
|
craftedClickMultiplier: 1,
|
||||||
|
craftedCombatMultiplier: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /start", () => {
|
describe("POST /start", () => {
|
||||||
it("returns 400 when areaId is missing", async () => {
|
it("returns 400 when areaId is missing", async () => {
|
||||||
const res = await postStart({});
|
const res = await postStart({});
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/discord.js", () => ({
|
||||||
|
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
const DISCORD_ID = "test_discord_id";
|
const DISCORD_ID = "test_discord_id";
|
||||||
const CURRENT_SCHEMA_VERSION = 1;
|
const CURRENT_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
@@ -200,6 +204,75 @@ describe("game route", () => {
|
|||||||
expect(body.offlineGold).toBeGreaterThan(0);
|
expect(body.offlineGold).toBeGreaterThan(0);
|
||||||
expect(body.offlineEssence).toBeGreaterThan(0);
|
expect(body.offlineEssence).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("syncs updated avatar from Discord into the returned state", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { state: GameState };
|
||||||
|
expect(body.state.player.avatar).toBe("new_hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading when the avatar DB update fails", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stored avatar when Discord returns null", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { state: GameState };
|
||||||
|
expect(body.state.player.avatar).toBe("stored_hash");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /save", () => {
|
describe("POST /save", () => {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types";
|
|||||||
|
|
||||||
vi.mock("../../src/db/client.js", () => ({
|
vi.mock("../../src/db/client.js", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
player: { update: vi.fn() },
|
player: { findUnique: vi.fn(), update: vi.fn() },
|
||||||
gameState: { 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", () => {
|
describe("prestige route", () => {
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
player: { 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> };
|
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -83,8 +83,8 @@ describe("prestige route", () => {
|
|||||||
|
|
||||||
it("returns runestones on successful prestige", async () => {
|
it("returns runestones on successful prestige", async () => {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -93,6 +93,14 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
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 () => {
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
@@ -112,14 +120,26 @@ describe("prestige route", () => {
|
|||||||
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
||||||
} as GameState["dailyChallenges"],
|
} as GameState["dailyChallenges"],
|
||||||
});
|
});
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
||||||
expect(body.newPrestigeCount).toBe(1);
|
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", () => {
|
describe("POST /buy-upgrade", () => {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ describe("transcendence route", () => {
|
|||||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
|
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");
|
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,24 @@ describe("generateDailyChallenges", () => {
|
|||||||
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("always includes a clicks challenge regardless of date", async () => {
|
||||||
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
|
const day1 = generateDailyChallenges("2024-01-15");
|
||||||
|
const day2 = generateDailyChallenges("2024-01-16");
|
||||||
|
expect(day1.some((c) => c.type === "clicks")).toBe(true);
|
||||||
|
expect(day2.some((c) => c.type === "clicks")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("generates different challenges for different dates", async () => {
|
it("generates different challenges for different dates", async () => {
|
||||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
const day1 = generateDailyChallenges("2024-01-15");
|
const day1 = generateDailyChallenges("2024-01-15");
|
||||||
const day2 = generateDailyChallenges("2024-01-16");
|
const day2 = generateDailyChallenges("2024-01-16");
|
||||||
// They should differ in at least one challenge ID (types vary by seed)
|
// The 2 non-clicks types should vary by seed between dates
|
||||||
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
|
const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type);
|
||||||
|
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type);
|
||||||
|
expect(day1NonClicks).not.toEqual(day2NonClicks);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,51 +18,31 @@ describe("discord service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildOAuthUrl", () => {
|
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 () => {
|
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 { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||||
const url = buildOAuthUrl();
|
const url = buildOAuthUrl();
|
||||||
expect(url).toContain("client_id=client123");
|
expect(url).toContain("client_id=1479551654264049908");
|
||||||
expect(url).toContain("response_type=code");
|
expect(url).toContain("response_type=code");
|
||||||
expect(url).toContain("scope=identify");
|
expect(url).toContain("scope=identify");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("exchangeCode", () => {
|
describe("exchangeCode", () => {
|
||||||
it("throws when env vars are missing", async () => {
|
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
|
||||||
delete process.env["DISCORD_CLIENT_ID"];
|
delete process.env["DISCORD_CLIENT_SECRET"];
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when response is not ok", async () => {
|
it("throws when response is not ok", async () => {
|
||||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns parsed body on success", async () => {
|
it("returns parsed body on success", async () => {
|
||||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
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" };
|
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
@@ -96,12 +76,59 @@ describe("discord service", () => {
|
|||||||
|
|
||||||
describe("exchangeCode non-Error throw", () => {
|
describe("exchangeCode non-Error throw", () => {
|
||||||
it("re-throws when fetch rejects with a non-Error value", async () => {
|
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_CLIENT_SECRET"] = "secret";
|
||||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
|
||||||
mockFetch.mockRejectedValueOnce("raw string error");
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchDiscordUserById", () => {
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is missing", async () => {
|
||||||
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is empty", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "";
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when response is not ok", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user on success", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" };
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toMatchObject({ id: "123456", avatar: "abc123" });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
describe("calculatePrestigeThreshold", () => {
|
||||||
it("returns base threshold at count 0", () => {
|
it("returns base threshold at count 0", () => {
|
||||||
|
// base × (0+1)^2 = 1_000_000 × 1 = 1_000_000
|
||||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 5× at count 1", () => {
|
it("returns 4× base at count 1", () => {
|
||||||
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
|
// base × (1+1)^2 = 1_000_000 × 4 = 4_000_000
|
||||||
|
expect(calculatePrestigeThreshold(1)).toBe(4_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 25× at count 2", () => {
|
it("returns 9× base at count 2", () => {
|
||||||
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
|
// base × (2+1)^2 = 1_000_000 × 9 = 9_000_000
|
||||||
|
expect(calculatePrestigeThreshold(2)).toBe(9_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies threshold multiplier correctly", () => {
|
it("applies threshold multiplier correctly", () => {
|
||||||
@@ -99,21 +102,27 @@ describe("isEligibleForPrestige", () => {
|
|||||||
|
|
||||||
describe("calculateRunestones", () => {
|
describe("calculateRunestones", () => {
|
||||||
it("calculates basic runestones formula", () => {
|
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)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||||
expect(result).toBe(20);
|
expect(result).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies echo runestone multiplier", () => {
|
it("applies echo runestone multiplier", () => {
|
||||||
// floor(sqrt(4) × 10) = 20; × 2 = 40
|
// floor(cbrt(4)) × 15 = 15; × 2 = 30
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||||||
expect(result).toBe(40);
|
expect(result).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies purchased runestone upgrade multiplier", () => {
|
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(15 × 1.25) = 18
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||||||
expect(result).toBeGreaterThan(20);
|
expect(result).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps base runestones before multipliers", () => {
|
||||||
|
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200
|
||||||
|
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||||
|
expect(result).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
|
|||||||
expect(calculateProductionMultiplier(0)).toBe(1);
|
expect(calculateProductionMultiplier(0)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 1.15 at count 1", () => {
|
it("returns 1.3 at count 1", () => {
|
||||||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
|
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scales exponentially", () => {
|
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);
|
expect(calculateMilestoneBonus(5)).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 50 at prestige 10", () => {
|
it("returns 100 at prestige 10", () => {
|
||||||
expect(calculateMilestoneBonus(10)).toBe(50);
|
expect(calculateMilestoneBonus(10)).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 75 at prestige 15", () => {
|
it("returns 225 at prestige 15", () => {
|
||||||
expect(calculateMilestoneBonus(15)).toBe(75);
|
expect(calculateMilestoneBonus(15)).toBe(225);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -97,20 +97,21 @@ describe("isEligibleForTranscendence", () => {
|
|||||||
|
|
||||||
describe("calculateEchoes", () => {
|
describe("calculateEchoes", () => {
|
||||||
it("handles prestige count of 0 by treating it as 1", () => {
|
it("handles prestige count of 0 by treating it as 1", () => {
|
||||||
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
|
// safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224
|
||||||
expect(calculateEchoes(0, 1)).toBe(853);
|
expect(calculateEchoes(0, 1)).toBe(224);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calculates echoes at count 1", () => {
|
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", () => {
|
it("decreases echoes with higher prestige count", () => {
|
||||||
const echoesAt1 = calculateEchoes(1, 1);
|
const echoesAt1 = calculateEchoes(1, 1);
|
||||||
const echoesAt4 = calculateEchoes(4, 1);
|
const echoesAt4 = calculateEchoes(4, 1);
|
||||||
expect(echoesAt4).toBeLessThan(echoesAt1);
|
expect(echoesAt4).toBeLessThan(echoesAt1);
|
||||||
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
|
// floor(224 / sqrt(4)) = floor(224 / 2) = 112
|
||||||
expect(echoesAt4).toBe(426);
|
expect(echoesAt4).toBe(112);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies echoMetaMultiplier", () => {
|
it("applies echoMetaMultiplier", () => {
|
||||||
@@ -118,6 +119,11 @@ describe("calculateEchoes", () => {
|
|||||||
const withMult = calculateEchoes(1, 2);
|
const withMult = calculateEchoes(1, 2);
|
||||||
expect(withMult).toBe(base * 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", () => {
|
describe("buildPostTranscendenceState", () => {
|
||||||
|
|||||||
@@ -20,42 +20,20 @@ describe("webhook service", () => {
|
|||||||
describe("grantApotheosisRole", () => {
|
describe("grantApotheosisRole", () => {
|
||||||
it("does nothing when bot token is missing", async () => {
|
it("does nothing when bot token is missing", async () => {
|
||||||
delete process.env["DISCORD_BOT_TOKEN"];
|
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");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await grantApotheosisRole("user123");
|
await grantApotheosisRole("user123");
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing when guild id is missing", async () => {
|
it("calls Discord API with correct URL and auth when bot token is set", 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 () => {
|
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
|
||||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await grantApotheosisRole("user789");
|
await grantApotheosisRole("user789");
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
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({
|
expect.objectContaining({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -63,8 +41,6 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("swallows fetch errors gracefully", async () => {
|
it("swallows fetch errors gracefully", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
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"));
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
@@ -72,14 +48,69 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("swallows non-Error fetch rejections gracefully", async () => {
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
process.env["DISCORD_GUILD_ID"] = "g";
|
|
||||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
|
||||||
mockFetch.mockRejectedValueOnce("raw string error");
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
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", () => {
|
describe("postMilestoneWebhook", () => {
|
||||||
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
|
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.1.2",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
@@ -28,6 +29,7 @@ import type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
@@ -243,6 +245,19 @@ const collectExploration = async(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a given exploration area is ready to claim on the server.
|
||||||
|
* @param areaId - The area ID to check.
|
||||||
|
* @returns Whether the exploration is claimable.
|
||||||
|
*/
|
||||||
|
const checkExplorationClaimable = async(
|
||||||
|
areaId: string,
|
||||||
|
): Promise<ExploreClaimableResponse> => {
|
||||||
|
return await fetchJson<ExploreClaimableResponse>(
|
||||||
|
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crafts a recipe on the server.
|
* Crafts a recipe on the server.
|
||||||
* @param body - The craft recipe request payload.
|
* @param body - The craft recipe request payload.
|
||||||
@@ -267,6 +282,16 @@ const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs any content added after the player's save was created into their save.
|
||||||
|
* @returns The updated game state and counts of what was added per content type.
|
||||||
|
*/
|
||||||
|
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
|
||||||
|
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
||||||
* @returns The fresh game state as a LoadResponse.
|
* @returns The fresh game state as a LoadResponse.
|
||||||
@@ -305,10 +330,12 @@ export {
|
|||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
challengeBoss,
|
challengeBoss,
|
||||||
|
checkExplorationClaimable,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
debugHardReset,
|
debugHardReset,
|
||||||
forceUnlocks,
|
forceUnlocks,
|
||||||
|
syncNewContent,
|
||||||
getAbout,
|
getAbout,
|
||||||
getAuthUrl,
|
getAuthUrl,
|
||||||
getPublicProfile,
|
getPublicProfile,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/* eslint-disable complexity -- Complex component with many render paths */
|
/* eslint-disable complexity -- Complex component with many render paths */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Adventurer } from "@elysium/types";
|
import type { Adventurer } from "@elysium/types";
|
||||||
@@ -76,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
|||||||
return quantity;
|
return quantity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EffectiveAdventurerStats {
|
||||||
|
readonly combatPower: number;
|
||||||
|
readonly essencePerSecond: number;
|
||||||
|
readonly goldPerSecond: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AdventurerCardProperties {
|
interface AdventurerCardProperties {
|
||||||
readonly adventurer: Adventurer;
|
readonly adventurer: Adventurer;
|
||||||
readonly currentGold: number;
|
readonly currentGold: number;
|
||||||
readonly batchSize: BatchSize;
|
readonly batchSize: BatchSize;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly effectiveStats: EffectiveAdventurerStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +100,7 @@ interface AdventurerCardProperties {
|
|||||||
* @param props.batchSize - The selected batch size.
|
* @param props.batchSize - The selected batch size.
|
||||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.effectiveStats - The post-multiplier per-unit stats.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const AdventurerCard = ({
|
const AdventurerCard = ({
|
||||||
@@ -100,6 +109,7 @@ const AdventurerCard = ({
|
|||||||
batchSize,
|
batchSize,
|
||||||
unlockHint,
|
unlockHint,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
effectiveStats,
|
||||||
}: AdventurerCardProperties): JSX.Element => {
|
}: AdventurerCardProperties): JSX.Element => {
|
||||||
const { buyAdventurer } = useGame();
|
const { buyAdventurer } = useGame();
|
||||||
|
|
||||||
@@ -134,15 +144,19 @@ const AdventurerCard = ({
|
|||||||
<div className="adventurer-info">
|
<div className="adventurer-info">
|
||||||
<h3>{adventurer.name}</h3>
|
<h3>{adventurer.name}</h3>
|
||||||
<p>
|
<p>
|
||||||
{formatNumber(adventurer.goldPerSecond)}
|
{formatNumber(effectiveStats.goldPerSecond)}
|
||||||
{" gold/s each"}
|
{" gold/s each"}
|
||||||
</p>
|
</p>
|
||||||
{adventurer.essencePerSecond > 0
|
{adventurer.essencePerSecond > 0
|
||||||
&& <p>
|
&& <p>
|
||||||
{formatNumber(adventurer.essencePerSecond)}
|
{formatNumber(effectiveStats.essencePerSecond)}
|
||||||
{" essence/s each"}
|
{" essence/s each"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
<p>
|
||||||
|
{formatNumber(effectiveStats.combatPower)}
|
||||||
|
{" combat power each"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="adventurer-count">
|
<div className="adventurer-count">
|
||||||
{"×"}
|
{"×"}
|
||||||
@@ -171,7 +185,7 @@ const AdventurerCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const AdventurerPanel = (): JSX.Element => {
|
const AdventurerPanel = (): JSX.Element => {
|
||||||
const { state, formatNumber } = useGame();
|
const { state, formatNumber, toggleAutoAdventurer } = useGame();
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
|
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
|
||||||
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
|
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
|
||||||
@@ -203,6 +217,11 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
|
||||||
|
"auto_adventurer",
|
||||||
|
);
|
||||||
|
const autoAdventurerOn = state.autoAdventurer === true;
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -213,11 +232,34 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
<section className="panel adventurer-panel">
|
<section className="panel adventurer-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>{"Adventurers"}</h2>
|
<h2>{"Adventurers"}</h2>
|
||||||
<LockToggle
|
<div className="panel-header-controls">
|
||||||
lockedCount={locked.length}
|
{autoAdventurerUnlocked
|
||||||
onToggle={handleToggle}
|
? <button
|
||||||
showLocked={showLocked}
|
className={`auto-toggle-btn ${
|
||||||
/>
|
autoAdventurerOn
|
||||||
|
? "auto-toggle-on"
|
||||||
|
: "auto-toggle-off"
|
||||||
|
}`}
|
||||||
|
onClick={toggleAutoAdventurer}
|
||||||
|
title={
|
||||||
|
"Automatically purchase the highest-tier"
|
||||||
|
+ " affordable adventurer"
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"🤖 Auto: "}
|
||||||
|
{autoAdventurerOn
|
||||||
|
? "ON"
|
||||||
|
: "OFF"}
|
||||||
|
</button>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<LockToggle
|
||||||
|
lockedCount={locked.length}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
showLocked={showLocked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-selector">
|
<div className="batch-selector">
|
||||||
{batchOptions.map((option) => {
|
{batchOptions.map((option) => {
|
||||||
@@ -248,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
adventurer={adventurer}
|
adventurer={adventurer}
|
||||||
batchSize={batchSize}
|
batchSize={batchSize}
|
||||||
currentGold={state.resources.gold}
|
currentGold={state.resources.gold}
|
||||||
|
effectiveStats={computeEffectiveAdventurerStats(
|
||||||
|
state,
|
||||||
|
adventurer.id,
|
||||||
|
)}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
key={adventurer.id}
|
key={adventurer.id}
|
||||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@
|
|||||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { computePartyCombatPower } from "../../engine/tick.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss, GameState } from "@elysium/types";
|
import type { Boss } from "@elysium/types";
|
||||||
|
|
||||||
interface BossCardProperties {
|
interface BossCardProperties {
|
||||||
readonly boss: Boss;
|
readonly boss: Boss;
|
||||||
@@ -157,72 +158,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.
|
* Renders the boss panel with zone selection and boss list.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -266,7 +201,31 @@ const BossPanel = (): JSX.Element => {
|
|||||||
void handleChallenge(bossId);
|
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;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
const zoneBosses = bosses.filter((boss) => {
|
const zoneBosses = bosses.filter((boss) => {
|
||||||
return boss.zoneId === activeZoneId;
|
return boss.zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -332,7 +291,12 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoBossOn = autoBoss === true;
|
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;
|
const { count: prestigeCount } = playerPrestige;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -393,6 +357,27 @@ const BossPanel = (): JSX.Element => {
|
|||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="party-combat-stats">
|
<div className="party-combat-stats">
|
||||||
<div className="combat-stat">
|
<div className="combat-stat">
|
||||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||||
|
|||||||
@@ -49,6 +49,40 @@ const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
|||||||
zone: "zones",
|
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.
|
* Renders the codex panel with lore entries grouped by zone.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -136,6 +170,9 @@ const CodexPanel = (): JSX.Element => {
|
|||||||
<span className="codex-lock">{"🔒"}</span>
|
<span className="codex-lock">{"🔒"}</span>
|
||||||
<span className="codex-entry-title">{"???"}</span>
|
<span className="codex-entry-title">{"???"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="codex-unlock-hint">
|
||||||
|
{buildUnlockHint(entry)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,138 @@ import { type JSX, useState } from "react";
|
|||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||||
|
|
||||||
type ActiveModal = "force-unlocks" | "hard-reset" | null;
|
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||||
|
|
||||||
|
interface SyncNewContentResult {
|
||||||
|
achievementsAdded: number | 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 => {
|
||||||
|
return value ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a human-readable summary of what the sync-new-content operation added.
|
||||||
|
* @param result - The counts returned by the operation.
|
||||||
|
* @returns A message string describing what was added, or a confirmation nothing was needed.
|
||||||
|
*/
|
||||||
|
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||||
|
const entries: Array<[ number, string ]> = [
|
||||||
|
[ safeNumber(result.zonesAdded), "zone(s)" ],
|
||||||
|
[ safeNumber(result.questsAdded), "quest(s)" ],
|
||||||
|
[ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ],
|
||||||
|
[ safeNumber(result.bossesAdded), "boss(es)" ],
|
||||||
|
[ 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 ]) => {
|
||||||
|
return count > 0;
|
||||||
|
}).
|
||||||
|
map(([ count, label ]) => {
|
||||||
|
return `${String(count)} ${label}`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Your save is already up to date — no new content was found.";
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
|
return sum + count;
|
||||||
|
}, 0);
|
||||||
|
return `Synced ${String(total)} item(s): ${parts.join(", ")}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ForceUnlocksResult {
|
||||||
|
adventurersUnlocked: number | undefined;
|
||||||
|
bossesUnlocked: number | undefined;
|
||||||
|
equipmentUnlocked: number | undefined;
|
||||||
|
explorationUnlocked: number | undefined;
|
||||||
|
questsUnlocked: number | undefined;
|
||||||
|
storyUnlocked: number | undefined;
|
||||||
|
upgradesUnlocked: number | undefined;
|
||||||
|
zonesUnlocked: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a human-readable summary of what the force-unlock operation corrected.
|
||||||
|
* @param result - The counts returned by the force-unlock operation.
|
||||||
|
* @returns A message string describing what was fixed, or a confirmation that nothing needed fixing.
|
||||||
|
*/
|
||||||
|
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
|
||||||
|
const entries: Array<[ number, string ]> = [
|
||||||
|
[ safeNumber(result.zonesUnlocked), "zone(s)" ],
|
||||||
|
[ safeNumber(result.questsUnlocked), "quest(s)" ],
|
||||||
|
[ safeNumber(result.bossesUnlocked), "boss(es)" ],
|
||||||
|
[ safeNumber(result.explorationUnlocked), "exploration area(s)" ],
|
||||||
|
[ safeNumber(result.adventurersUnlocked), "adventurer tier(s)" ],
|
||||||
|
[ safeNumber(result.upgradesUnlocked), "upgrade(s)" ],
|
||||||
|
[ safeNumber(result.equipmentUnlocked), "equipment item(s)" ],
|
||||||
|
[ safeNumber(result.storyUnlocked), "story chapter(s)" ],
|
||||||
|
];
|
||||||
|
const parts = entries.
|
||||||
|
filter(([ count ]) => {
|
||||||
|
return count > 0;
|
||||||
|
}).
|
||||||
|
map(([ count, label ]) => {
|
||||||
|
return `${String(count)} ${label}`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Everything looks correct — no missing unlocks were found.";
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
|
return sum + count;
|
||||||
|
}, 0);
|
||||||
|
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the debug panel with tools for fixing stuck game state.
|
* Renders the debug panel with tools for fixing stuck game state.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const DebugPanel = (): JSX.Element => {
|
const DebugPanel = (): JSX.Element => {
|
||||||
const { forceUnlocks, debugHardReset, isLoading } = useGame();
|
const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
|
||||||
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
||||||
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
||||||
|
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleOpenForceUnlocks(): void {
|
function handleOpenForceUnlocks(): void {
|
||||||
setForceUnlocksResult(null);
|
setForceUnlocksResult(null);
|
||||||
setActiveModal("force-unlocks");
|
setActiveModal("force-unlocks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenSyncNewContent(): void {
|
||||||
|
setSyncNewContentResult(null);
|
||||||
|
setActiveModal("sync-new-content");
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenHardReset(): void {
|
function handleOpenHardReset(): void {
|
||||||
setActiveModal("hard-reset");
|
setActiveModal("hard-reset");
|
||||||
}
|
}
|
||||||
@@ -38,29 +154,15 @@ const DebugPanel = (): JSX.Element => {
|
|||||||
setActiveModal(null);
|
setActiveModal(null);
|
||||||
void (async(): Promise<void> => {
|
void (async(): Promise<void> => {
|
||||||
const result = await forceUnlocks();
|
const result = await forceUnlocks();
|
||||||
const parts: Array<string> = [];
|
setForceUnlocksResult(buildForceUnlocksMessage(result));
|
||||||
if (result.zonesUnlocked > 0) {
|
})();
|
||||||
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
|
}
|
||||||
}
|
|
||||||
if (result.questsUnlocked > 0) {
|
function handleConfirmSyncNewContent(): void {
|
||||||
parts.push(`${String(result.questsUnlocked)} quest(s)`);
|
setActiveModal(null);
|
||||||
}
|
void (async(): Promise<void> => {
|
||||||
if (result.bossesUnlocked > 0) {
|
const result = await syncNewContent();
|
||||||
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
|
setSyncNewContentResult(buildSyncNewContentMessage(result));
|
||||||
}
|
|
||||||
if (result.explorationUnlocked > 0) {
|
|
||||||
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
|
|
||||||
}
|
|
||||||
const total
|
|
||||||
= result.zonesUnlocked
|
|
||||||
+ result.questsUnlocked
|
|
||||||
+ result.bossesUnlocked
|
|
||||||
+ result.explorationUnlocked;
|
|
||||||
const message
|
|
||||||
= parts.length === 0
|
|
||||||
? "Everything looks correct — no missing unlocks were found."
|
|
||||||
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
|
||||||
setForceUnlocksResult(message);
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +201,26 @@ const DebugPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"🔄 Sync New Content"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenSyncNewContent}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Sync New Content"}
|
||||||
|
</button>
|
||||||
|
{syncNewContentResult !== null
|
||||||
|
&& <p className="debug-result-message">{syncNewContentResult}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="debug-action-card">
|
<div className="debug-action-card">
|
||||||
<h3>{"💀 Hard Reset"}</h3>
|
<h3>{"💀 Hard Reset"}</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -128,6 +250,17 @@ const DebugPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{activeModal === "sync-new-content"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Sync New Content"
|
||||||
|
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmSyncNewContent}
|
||||||
|
title="Sync New Content"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
{activeModal === "hard-reset"
|
{activeModal === "hard-reset"
|
||||||
&& <ConfirmationModal
|
&& <ConfirmationModal
|
||||||
confirmLabel="Yes, Wipe Everything"
|
confirmLabel="Yes, Wipe Everything"
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ const EditProfileModal = ({
|
|||||||
void handleNotificationsEnable();
|
void handleNotificationsEnable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePrestigeAnnouncementsToggle(): void {
|
||||||
|
toggleSetting("enablePrestigeAnnouncements");
|
||||||
|
}
|
||||||
|
|
||||||
const isSaveDisabled = saving || characterName.trim() === "";
|
const isSaveDisabled = saving || characterName.trim() === "";
|
||||||
|
|
||||||
let saveLabel = "Save Profile";
|
let saveLabel = "Save Profile";
|
||||||
@@ -417,6 +421,23 @@ const EditProfileModal = ({
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="edit-profile-section">
|
<div className="edit-profile-section">
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||||
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||||
import { type JSX, useState } from "react";
|
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
|
||||||
|
import { type JSX, useEffect, useRef, useState } from "react";
|
||||||
|
import { checkExplorationClaimable } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { ExploreCollectResponse } from "@elysium/types";
|
import type {
|
||||||
|
ExploreClaimableResponse,
|
||||||
|
ExploreCollectResponse,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a duration in seconds to a human-readable string.
|
* Formats a duration in seconds to a human-readable string.
|
||||||
@@ -83,6 +88,61 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||||
|
const [ claimableAreaIds, setClaimableAreaIds ]
|
||||||
|
= useState<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
|
const stateReference = useRef(state);
|
||||||
|
stateReference.current = state;
|
||||||
|
|
||||||
|
const claimableReference = useRef(claimableAreaIds);
|
||||||
|
claimableReference.current = claimableAreaIds;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pollClaimable = async(): Promise<void> => {
|
||||||
|
const currentState = stateReference.current;
|
||||||
|
if (currentState === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inProgressArea = currentState.exploration?.areas.find((a) => {
|
||||||
|
return a.status === "in_progress";
|
||||||
|
});
|
||||||
|
if (inProgressArea === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (claimableReference.current.has(inProgressArea.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||||
|
return a.id === inProgressArea.id;
|
||||||
|
});
|
||||||
|
if (areaData === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = timeRemaining(
|
||||||
|
inProgressArea.endsAt,
|
||||||
|
inProgressArea.startedAt ?? 0,
|
||||||
|
areaData.durationSeconds,
|
||||||
|
);
|
||||||
|
if (remaining > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result: ExploreClaimableResponse
|
||||||
|
= await checkExplorationClaimable(inProgressArea.id);
|
||||||
|
if (result.claimable) {
|
||||||
|
setClaimableAreaIds((previous) => {
|
||||||
|
return new Set([ ...previous, inProgressArea.id ]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
void pollClaimable();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
try {
|
try {
|
||||||
const result = await collectExploration(areaId);
|
const result = await collectExploration(areaId);
|
||||||
setLastResult({ areaId: areaId, response: result });
|
setLastResult({ areaId: areaId, response: result });
|
||||||
|
setClaimableAreaIds((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
next.delete(areaId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPendingAreaId(null);
|
setPendingAreaId(null);
|
||||||
}
|
}
|
||||||
@@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
const endsAt = areaState?.endsAt;
|
const endsAt = areaState?.endsAt;
|
||||||
const isReady
|
const isReady
|
||||||
= status === "in_progress"
|
= status === "in_progress"
|
||||||
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
|
&& claimableAreaIds.has(area.id);
|
||||||
const isPending = pendingAreaId === area.id;
|
const isPending = pendingAreaId === area.id;
|
||||||
|
|
||||||
function handleStartClick(): void {
|
function handleStartClick(): void {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js";
|
|||||||
import { EditProfileModal } from "./editProfileModal.js";
|
import { EditProfileModal } from "./editProfileModal.js";
|
||||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||||
import { ExplorationPanel } from "./explorationPanel.js";
|
import { ExplorationPanel } from "./explorationPanel.js";
|
||||||
|
import { JoinCommunityModal } from "./joinCommunityModal.js";
|
||||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||||
import { MilestoneToast } from "./milestoneToast.js";
|
import { MilestoneToast } from "./milestoneToast.js";
|
||||||
import { OfflineModal } from "./offlineModal.js";
|
import { OfflineModal } from "./offlineModal.js";
|
||||||
@@ -135,7 +136,6 @@ const GameLayout = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileUrl = `/profile/${state.player.discordId}`;
|
|
||||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||||
|
|
||||||
@@ -160,12 +160,12 @@ const GameLayout = (): JSX.Element => {
|
|||||||
onEditProfile={handleOpenEditProfile}
|
onEditProfile={handleOpenEditProfile}
|
||||||
onForceSync={forceSync}
|
onForceSync={forceSync}
|
||||||
prestigeCount={state.prestige.count}
|
prestigeCount={state.prestige.count}
|
||||||
profileUrl={profileUrl}
|
|
||||||
resources={state.resources}
|
resources={state.resources}
|
||||||
runestones={state.prestige.runestones}
|
runestones={state.prestige.runestones}
|
||||||
transcendenceCount={state.transcendence?.count ?? 0}
|
transcendenceCount={state.transcendence?.count ?? 0}
|
||||||
/>
|
/>
|
||||||
<OfflineModal />
|
<OfflineModal />
|
||||||
|
<JoinCommunityModal />
|
||||||
{schemaOutdated && !dismissedOutdatedWarning
|
{schemaOutdated && !dismissedOutdatedWarning
|
||||||
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
||||||
: null}
|
: 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 { prestige } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import {
|
import {
|
||||||
PRESTIGE_UPGRADES,
|
|
||||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||||
|
PRESTIGE_UPGRADES,
|
||||||
} from "../../data/prestigeUpgrades.js";
|
} from "../../data/prestigeUpgrades.js";
|
||||||
|
import {
|
||||||
|
computeProjectedRunestones,
|
||||||
|
} from "../../engine/tick.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { sendNotification } from "../../utils/notification.js";
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
import { playSound } from "../../utils/sound.js";
|
import { playSound } from "../../utils/sound.js";
|
||||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const baseThreshold = 1_000_000;
|
const baseThreshold = 1_000_000;
|
||||||
const thresholdScale = 5;
|
|
||||||
const runestonesPerLevel = 10;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the prestige threshold for a given prestige count.
|
* Calculates the prestige threshold for a given prestige count.
|
||||||
|
* Mirrors the server formula: BASE * (count + 1)^2.
|
||||||
* @param prestigeCount - The current prestige count.
|
* @param prestigeCount - The current prestige count.
|
||||||
* @returns The required gold to prestige.
|
* @returns The required gold to prestige.
|
||||||
*/
|
*/
|
||||||
const calculateThreshold = (prestigeCount: number): number => {
|
const calculateThreshold = (prestigeCount: number): number => {
|
||||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
return baseThreshold * Math.pow(prestigeCount + 1, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,32 +44,6 @@ const calculateProductionMultiplier = (prestigeCount: number): number => {
|
|||||||
return Math.pow(1.15, prestigeCount);
|
return Math.pow(1.15, prestigeCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the runestone preview for a prestige.
|
|
||||||
* @param totalGoldEarned - Total gold earned this run.
|
|
||||||
* @param prestigeCount - The current prestige count.
|
|
||||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
|
||||||
* @returns The predicted runestone reward.
|
|
||||||
*/
|
|
||||||
const calculateRunestonePreview = (
|
|
||||||
totalGoldEarned: number,
|
|
||||||
prestigeCount: number,
|
|
||||||
purchasedUpgradeIds: Array<string>,
|
|
||||||
): number => {
|
|
||||||
const threshold = calculateThreshold(prestigeCount);
|
|
||||||
const base
|
|
||||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
|
||||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
|
||||||
return (
|
|
||||||
upgrade.category === "runestones"
|
|
||||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
|
||||||
);
|
|
||||||
}).reduce((mult, upgrade) => {
|
|
||||||
return mult * upgrade.multiplier;
|
|
||||||
}, 1);
|
|
||||||
return Math.floor(base * runestoneMult);
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||||
"income",
|
"income",
|
||||||
"click",
|
"click",
|
||||||
@@ -84,7 +60,7 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
|||||||
const PrestigePanel = (): JSX.Element => {
|
const PrestigePanel = (): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
reload,
|
reloadSilent,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
@@ -114,11 +90,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
const { autoAdventurer, prestige: prestigeData, player } = state;
|
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||||
const threshold = calculateThreshold(prestigeData.count);
|
const threshold = calculateThreshold(prestigeData.count);
|
||||||
const isEligible = player.totalGoldEarned >= threshold;
|
const isEligible = player.totalGoldEarned >= threshold;
|
||||||
const runestonePreview = calculateRunestonePreview(
|
const runestonePreview = computeProjectedRunestones(state);
|
||||||
player.totalGoldEarned,
|
|
||||||
prestigeData.count,
|
|
||||||
prestigeData.purchasedUpgradeIds,
|
|
||||||
);
|
|
||||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||||
|
|
||||||
async function handlePrestige(): Promise<void> {
|
async function handlePrestige(): Promise<void> {
|
||||||
@@ -141,7 +113,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await reload();
|
await reloadSilent();
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
setPrestigeError(
|
setPrestigeError(
|
||||||
error_ instanceof Error
|
error_ instanceof Error
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
|
||||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
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 { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
@@ -148,8 +152,7 @@ const QuestCard = ({
|
|||||||
&& <p className="quest-failure-chance">
|
&& <p className="quest-failure-chance">
|
||||||
{"🎲 "}
|
{"🎲 "}
|
||||||
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
||||||
{"% failure chance — if failed, the quest resets"}
|
{"% failure chance"}
|
||||||
{" and must be retried."}
|
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||||
@@ -208,12 +211,25 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { adventurers, autoQuest, quests, zones } = state;
|
const { autoQuest, bosses, quests, zones } = state;
|
||||||
let partyCombatPower = 0;
|
|
||||||
for (const adventurer of adventurers) {
|
const activeZone = zones.find((zone) => {
|
||||||
const contribution = adventurer.combatPower * adventurer.count;
|
return zone.id === activeZoneId;
|
||||||
partyCombatPower = partyCombatPower + contribution;
|
});
|
||||||
}
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
|
const partyCombatPower = computePartyCombatPower(state);
|
||||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||||
return zoneId === activeZoneId;
|
return zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -307,6 +323,31 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
<p className="quest-failure-note">
|
||||||
|
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="quest-list">
|
<div className="quest-list">
|
||||||
{visibleQuests.map((quest) => {
|
{visibleQuests.map((quest) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* 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 max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
/* 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 { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { cdnImage } from "../../utils/cdn.js";
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
@@ -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 {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
|
|||||||
@@ -4,12 +4,20 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- Resource bar has many resource and action elements */
|
||||||
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
||||||
|
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
|
||||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||||
|
import { useState, type FocusEvent, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
import {
|
||||||
|
RESOURCE_CAP,
|
||||||
|
computeEssencePerSecond,
|
||||||
|
computeGoldPerSecond,
|
||||||
|
computePartyCombatPower,
|
||||||
|
computeProjectedRunestones,
|
||||||
|
} from "../../engine/tick.js";
|
||||||
import type { Resource } from "@elysium/types";
|
import type { Resource } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
|
||||||
|
|
||||||
interface ResourceBarProperties {
|
interface ResourceBarProperties {
|
||||||
readonly resources: Resource;
|
readonly resources: Resource;
|
||||||
@@ -17,7 +25,6 @@ interface ResourceBarProperties {
|
|||||||
readonly prestigeCount: number;
|
readonly prestigeCount: number;
|
||||||
readonly transcendenceCount: number;
|
readonly transcendenceCount: number;
|
||||||
readonly apotheosisCount: number;
|
readonly apotheosisCount: number;
|
||||||
readonly profileUrl: string;
|
|
||||||
readonly onEditProfile: ()=> void;
|
readonly onEditProfile: ()=> void;
|
||||||
readonly lastSavedAt: number | null;
|
readonly lastSavedAt: number | null;
|
||||||
readonly isSyncing: boolean;
|
readonly isSyncing: boolean;
|
||||||
@@ -58,7 +65,6 @@ const resourceFullTooltip = [
|
|||||||
* @param props.prestigeCount - The number of prestiges completed.
|
* @param props.prestigeCount - The number of prestiges completed.
|
||||||
* @param props.transcendenceCount - The number of transcendences completed.
|
* @param props.transcendenceCount - The number of transcendences completed.
|
||||||
* @param props.apotheosisCount - The number of apotheoses completed.
|
* @param props.apotheosisCount - The number of apotheoses completed.
|
||||||
* @param props.profileUrl - The URL of the player's public profile.
|
|
||||||
* @param props.onEditProfile - Callback to open the edit profile modal.
|
* @param props.onEditProfile - Callback to open the edit profile modal.
|
||||||
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
||||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||||
@@ -71,84 +77,183 @@ const ResourceBar = ({
|
|||||||
prestigeCount,
|
prestigeCount,
|
||||||
transcendenceCount,
|
transcendenceCount,
|
||||||
apotheosisCount,
|
apotheosisCount,
|
||||||
profileUrl,
|
|
||||||
onEditProfile,
|
onEditProfile,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
onForceSync,
|
onForceSync,
|
||||||
}: ResourceBarProperties): JSX.Element => {
|
}: ResourceBarProperties): JSX.Element => {
|
||||||
const { formatNumber, syncError, state } = useGame();
|
const { formatNumber, syncError, state } = useGame();
|
||||||
|
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||||
|
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||||
|
|
||||||
const { gold, essence, crystals } = resources;
|
const { gold, essence, crystals } = resources;
|
||||||
let partyCombatPower = 0;
|
let partyCombatPower = 0;
|
||||||
|
let goldPerSecond = 0;
|
||||||
|
let essencePerSecond = 0;
|
||||||
|
let projectedRunestones = 0;
|
||||||
if (state !== null) {
|
if (state !== null) {
|
||||||
for (const adventurer of state.adventurers) {
|
partyCombatPower = computePartyCombatPower(state);
|
||||||
const contribution = adventurer.combatPower * adventurer.count;
|
goldPerSecond = computeGoldPerSecond(state);
|
||||||
partyCombatPower = partyCombatPower + contribution;
|
essencePerSecond = computeEssencePerSecond(state);
|
||||||
}
|
projectedRunestones = computeProjectedRunestones(state);
|
||||||
}
|
}
|
||||||
const resourceValues = [ gold, essence, crystals ];
|
|
||||||
const anyFull = resourceValues.some((v) => {
|
let avatarUrl: string | null = null;
|
||||||
return v >= RESOURCE_CAP;
|
if (state !== null) {
|
||||||
});
|
avatarUrl = state.player.avatar === null
|
||||||
|
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
|
||||||
|
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
|
||||||
|
}
|
||||||
|
const profileUrl = state === null
|
||||||
|
? "#"
|
||||||
|
: `/profile/${state.player.discordId}`;
|
||||||
|
|
||||||
const goldFull = gold >= RESOURCE_CAP;
|
const goldFull = gold >= RESOURCE_CAP;
|
||||||
const essenceFull = essence >= RESOURCE_CAP;
|
const essenceFull = essence >= RESOURCE_CAP;
|
||||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||||
|
const anyFull = goldFull || essenceFull || crystalsFull;
|
||||||
|
const hiddenResourcesFull = essenceFull || crystalsFull;
|
||||||
|
|
||||||
function handleForceSync(): void {
|
function handleForceSync(): void {
|
||||||
void onForceSync();
|
void onForceSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleResources(): void {
|
||||||
|
setIsResourcesOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResourceBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsResourcesOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleProfile(): void {
|
||||||
|
setIsProfileOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditProfile(): void {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
onEditProfile();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="resource-bar">
|
<header className="resource-bar">
|
||||||
<div className={`resource${goldFull
|
<div
|
||||||
? " resource-full"
|
className="resource-menu"
|
||||||
: ""}`}>
|
onBlur={handleResourceBlur}
|
||||||
<span className="resource-icon">{"🪙"}</span>
|
>
|
||||||
<span className="resource-value">{formatNumber(gold)}</span>
|
<button
|
||||||
<span className="resource-label">{"Gold"}</span>
|
className={`resource resource-toggle${goldFull
|
||||||
{goldFull
|
? " resource-full"
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
: ""}`}
|
||||||
{"FULL"}
|
onClick={handleToggleResources}
|
||||||
</span>
|
title="Click to see all resources"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="resource-icon">{"🪙"}</span>
|
||||||
|
<span className="resource-value">{formatNumber(gold)}</span>
|
||||||
|
<span className="resource-label">{"Gold"}</span>
|
||||||
|
{goldFull
|
||||||
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
|
{"FULL"}
|
||||||
|
</span>
|
||||||
|
: null}
|
||||||
|
{hiddenResourcesFull
|
||||||
|
? <span
|
||||||
|
className="resource-alert-dot"
|
||||||
|
title={"One or more resources are full!"}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
</button>
|
||||||
|
{isResourcesOpen
|
||||||
|
? <div className="resources-dropdown">
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"📈"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(goldPerSecond)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Gold/s"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<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"
|
||||||
|
: ""}`}>
|
||||||
|
<span className="resource-icon">{"✨"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(essence)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Essence"}</span>
|
||||||
|
{essenceFull
|
||||||
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
|
{"FULL"}
|
||||||
|
</span>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className={`resource${crystalsFull
|
||||||
|
? " resource-full"
|
||||||
|
: ""}`}>
|
||||||
|
<span className="resource-icon">{"💎"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(crystals)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Crystals"}</span>
|
||||||
|
{crystalsFull
|
||||||
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
|
{"FULL"}
|
||||||
|
</span>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"🔮"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(runestones)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Runestones"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⭐"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{`+${formatNumber(projectedRunestones)}`}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"On Prestige"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⚔️"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(partyCombatPower)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Combat Power"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className={`resource${essenceFull
|
|
||||||
? " resource-full"
|
|
||||||
: ""}`}>
|
|
||||||
<span className="resource-icon">{"✨"}</span>
|
|
||||||
<span className="resource-value">{formatNumber(essence)}</span>
|
|
||||||
<span className="resource-label">{"Essence"}</span>
|
|
||||||
{essenceFull
|
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
|
||||||
{"FULL"}
|
|
||||||
</span>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<div className={`resource${crystalsFull
|
|
||||||
? " resource-full"
|
|
||||||
: ""}`}>
|
|
||||||
<span className="resource-icon">{"💎"}</span>
|
|
||||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
|
||||||
<span className="resource-label">{"Crystals"}</span>
|
|
||||||
{crystalsFull
|
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
|
||||||
{"FULL"}
|
|
||||||
</span>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<div className="resource">
|
|
||||||
<span className="resource-icon">{"🔮"}</span>
|
|
||||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
|
||||||
<span className="resource-label">{"Runestones"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="resource">
|
|
||||||
<span className="resource-icon">{"⚔️"}</span>
|
|
||||||
<span className="resource-value">
|
|
||||||
{formatNumber(partyCombatPower)}
|
|
||||||
</span>
|
|
||||||
<span className="resource-label">{"Combat Power"}</span>
|
|
||||||
</div>
|
|
||||||
{apotheosisCount > 0
|
{apotheosisCount > 0
|
||||||
&& <div className="apotheosis-badge">
|
&& <div className="apotheosis-badge">
|
||||||
{"✨ Apotheosis "}
|
{"✨ Apotheosis "}
|
||||||
@@ -167,34 +272,7 @@ const ResourceBar = ({
|
|||||||
{prestigeCount}
|
{prestigeCount}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="profile-buttons">
|
<div className="resource-bar-actions">
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://donate.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Support the developer"
|
|
||||||
>
|
|
||||||
{"💜"} <span className="btn-label">{"Donate"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://chat.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Join our Discord"
|
|
||||||
>
|
|
||||||
{"💬"} <span className="btn-label">{"Discord"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://support.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Get support on our forum"
|
|
||||||
>
|
|
||||||
{"🆘"} <span className="btn-label">{"Support"}</span>
|
|
||||||
</a>
|
|
||||||
{syncError === null
|
{syncError === null
|
||||||
? null
|
? null
|
||||||
: <span className="save-status save-error" title={syncError}>
|
: <span className="save-status save-error" title={syncError}>
|
||||||
@@ -221,23 +299,69 @@ const ResourceBar = ({
|
|||||||
? "⏳"
|
? "⏳"
|
||||||
: "💾"}
|
: "💾"}
|
||||||
</button>
|
</button>
|
||||||
<a
|
{avatarUrl === null
|
||||||
className="profile-link-button"
|
? null
|
||||||
href={profileUrl}
|
: <div
|
||||||
rel="noreferrer"
|
className="profile-menu"
|
||||||
target="_blank"
|
onBlur={handleProfileBlur}
|
||||||
title="View your public profile"
|
>
|
||||||
>
|
<button
|
||||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
className="profile-avatar-button"
|
||||||
</a>
|
onClick={handleToggleProfile}
|
||||||
<button
|
title="Account"
|
||||||
className="profile-edit-button"
|
type="button"
|
||||||
onClick={onEditProfile}
|
>
|
||||||
title="Edit your profile"
|
<img
|
||||||
type="button"
|
alt="Profile"
|
||||||
>
|
className="profile-avatar-img"
|
||||||
{"✏️"}
|
src={avatarUrl}
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
{isProfileOpen
|
||||||
|
? <div className="profile-dropdown">
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href={profileUrl}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"👤 View Profile"}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"✏️ Edit Profile"}
|
||||||
|
</button>
|
||||||
|
<hr className="profile-dropdown-divider" />
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://donate.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💜 Donate"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://chat.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💬 Discord"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://support.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"🆘 Support"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{anyFull
|
{anyFull
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
craftRecipe as craftRecipeApi,
|
craftRecipe as craftRecipeApi,
|
||||||
debugHardReset as debugHardResetApi,
|
debugHardReset as debugHardResetApi,
|
||||||
forceUnlocks as forceUnlocksApi,
|
forceUnlocks as forceUnlocksApi,
|
||||||
|
syncNewContent as syncNewContentApi,
|
||||||
loadGame,
|
loadGame,
|
||||||
prestige as prestigeApi,
|
prestige as prestigeApi,
|
||||||
resetProgress as resetProgressApi,
|
resetProgress as resetProgressApi,
|
||||||
@@ -52,11 +53,13 @@ import {
|
|||||||
transcend as transcendApi,
|
transcend as transcendApi,
|
||||||
} from "../api/client.js";
|
} from "../api/client.js";
|
||||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||||
|
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||||
import { RECIPES } from "../data/recipes.js";
|
import { RECIPES } from "../data/recipes.js";
|
||||||
import {
|
import {
|
||||||
RESOURCE_CAP,
|
RESOURCE_CAP,
|
||||||
applyTick,
|
applyTick,
|
||||||
calculateClickPower,
|
calculateClickPower,
|
||||||
|
computePartyCombatPower,
|
||||||
} from "../engine/tick.js";
|
} from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||||
@@ -114,6 +117,9 @@ const applyBossResult = (
|
|||||||
}).
|
}).
|
||||||
filter(Boolean),
|
filter(Boolean),
|
||||||
);
|
);
|
||||||
|
const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => {
|
||||||
|
return z.id;
|
||||||
|
}));
|
||||||
|
|
||||||
const challengeUpdate
|
const challengeUpdate
|
||||||
= previous.dailyChallenges === undefined
|
= previous.dailyChallenges === undefined
|
||||||
@@ -214,6 +220,23 @@ const applyBossResult = (
|
|||||||
? { ...u, unlocked: true }
|
? { ...u, unlocked: true }
|
||||||
: u;
|
: 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;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +265,11 @@ interface GameContextValue {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player is currently a member of the NHCarrigan Discord server.
|
||||||
|
*/
|
||||||
|
inGuild: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click the crystal to earn gold.
|
* Click the crystal to earn gold.
|
||||||
*/
|
*/
|
||||||
@@ -282,6 +310,12 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
reload: ()=> Promise<void>;
|
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).
|
* Unix timestamp of the last successful cloud save (null until first save response).
|
||||||
*/
|
*/
|
||||||
@@ -558,9 +592,13 @@ interface GameContextValue {
|
|||||||
* @returns Counts of what was corrected.
|
* @returns Counts of what was corrected.
|
||||||
*/
|
*/
|
||||||
forceUnlocks: ()=> Promise<{
|
forceUnlocks: ()=> Promise<{
|
||||||
|
adventurersUnlocked: number;
|
||||||
bossesUnlocked: number;
|
bossesUnlocked: number;
|
||||||
|
equipmentUnlocked: number;
|
||||||
explorationUnlocked: number;
|
explorationUnlocked: number;
|
||||||
questsUnlocked: number;
|
questsUnlocked: number;
|
||||||
|
storyUnlocked: number;
|
||||||
|
upgradesUnlocked: number;
|
||||||
zonesUnlocked: number;
|
zonesUnlocked: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -570,6 +608,31 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
debugHardReset: ()=> Promise<void>;
|
debugHardReset: ()=> Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs any content added to the game after the player's save was created.
|
||||||
|
* @returns Counts of what was added per content type.
|
||||||
|
*/
|
||||||
|
syncNewContent: ()=> Promise<{
|
||||||
|
achievementsAdded: number;
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last auto-boss fight result — null until the first auto fight completes or
|
* Last auto-boss fight result — null until the first auto fight completes or
|
||||||
* when auto-boss is toggled off.
|
* when auto-boss is toggled off.
|
||||||
@@ -661,9 +724,14 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
/* No-op placeholder */
|
/* No-op placeholder */
|
||||||
});
|
});
|
||||||
|
const reloadSilentReference = useRef<()=> Promise<void>>(async() => {
|
||||||
|
|
||||||
|
/* No-op placeholder */
|
||||||
|
});
|
||||||
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
|
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
|
||||||
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
|
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
|
||||||
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
|
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
|
||||||
|
const [ inGuild, setInGuild ] = useState(false);
|
||||||
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
|
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
|
||||||
Array<string>
|
Array<string>
|
||||||
>([]);
|
>([]);
|
||||||
@@ -701,6 +769,7 @@ export const GameProvider = ({
|
|||||||
setSchemaOutdated(data.schemaOutdated);
|
setSchemaOutdated(data.schemaOutdated);
|
||||||
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||||
setCurrentSchemaVersion(data.currentSchemaVersion);
|
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||||
|
setInGuild(data.inGuild);
|
||||||
|
|
||||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||||
void fetch(`/api/profile/${data.state.player.discordId}`).
|
void fetch(`/api/profile/${data.state.player.discordId}`).
|
||||||
@@ -746,6 +815,32 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
reloadReference.current = reload;
|
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(() => {
|
useEffect(() => {
|
||||||
enableSoundsReference.current = enableSounds;
|
enableSoundsReference.current = enableSounds;
|
||||||
}, [ enableSounds ]);
|
}, [ enableSounds ]);
|
||||||
@@ -1041,11 +1136,7 @@ export const GameProvider = ({
|
|||||||
return q.status === "active";
|
return q.status === "active";
|
||||||
});
|
});
|
||||||
if (!hasActiveQuest) {
|
if (!hasActiveQuest) {
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
const partyCombatPower = computePartyCombatPower(next);
|
||||||
const partyCombatPower = next.adventurers.reduce((total, a) => {
|
|
||||||
const power = total + a.combatPower;
|
|
||||||
return power * a.count;
|
|
||||||
}, 0);
|
|
||||||
const zoneOrder = new Map(
|
const zoneOrder = new Map(
|
||||||
next.zones.map((z, index) => {
|
next.zones.map((z, index) => {
|
||||||
return [ z.id, index ];
|
return [ z.id, index ];
|
||||||
@@ -1083,18 +1174,31 @@ export const GameProvider = ({
|
|||||||
next.autoAdventurer === true
|
next.autoAdventurer === true
|
||||||
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
|
&& 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.
|
const [ bestAdventurer ] = next.adventurers.
|
||||||
filter((adventurer) => {
|
filter((adventurer) => {
|
||||||
const cost
|
const cost
|
||||||
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
|
= 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) => {
|
sort((adventurerA, adventurerB) => {
|
||||||
const costA
|
return adventurerB.level - adventurerA.level;
|
||||||
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
|
|
||||||
const costB
|
|
||||||
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
|
|
||||||
return costB - costA;
|
|
||||||
});
|
});
|
||||||
if (bestAdventurer !== undefined) {
|
if (bestAdventurer !== undefined) {
|
||||||
const purchaseCost
|
const purchaseCost
|
||||||
@@ -1144,14 +1248,6 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Quest failure — turn off auto-quest so the player can reassess
|
|
||||||
if (
|
|
||||||
newlyFailedQuestsReference.current.length > 0
|
|
||||||
&& next.autoQuest === true
|
|
||||||
) {
|
|
||||||
next = { ...next, autoQuest: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1255,7 +1351,7 @@ export const GameProvider = ({
|
|||||||
if (enableNotificationsReference.current) {
|
if (enableNotificationsReference.current) {
|
||||||
sendNotification("⭐ Prestige!", "You have ascended!");
|
sendNotification("⭐ Prestige!", "You have ascended!");
|
||||||
}
|
}
|
||||||
await reloadReference.current();
|
await reloadSilentReference.current();
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch(() => {
|
||||||
|
|
||||||
@@ -1289,7 +1385,26 @@ export const GameProvider = ({
|
|||||||
if (availableBoss !== undefined) {
|
if (availableBoss !== undefined) {
|
||||||
const { id: bossId, name: bossName } = availableBoss;
|
const { id: bossId, name: bossName } = availableBoss;
|
||||||
isAutoBossingReference.current = true;
|
isAutoBossingReference.current = true;
|
||||||
void challengeBossApi({ bossId }).
|
const syncBeforeBoss
|
||||||
|
= stateReference.current !== null && !isSyncingReference.current
|
||||||
|
? saveGame({
|
||||||
|
state: stateReference.current,
|
||||||
|
...signatureReference.current === null
|
||||||
|
? {}
|
||||||
|
: { signature: signatureReference.current },
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.signature !== undefined) {
|
||||||
|
signatureReference.current = response.signature;
|
||||||
|
localStorage.setItem(
|
||||||
|
"elysium_save_signature",
|
||||||
|
response.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: Promise.resolve();
|
||||||
|
void syncBeforeBoss.then(async() => {
|
||||||
|
return await challengeBossApi({ bossId });
|
||||||
|
}).
|
||||||
then((result) => {
|
then((result) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -1302,6 +1417,13 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
return afterBoss;
|
return afterBoss;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Boss fight modifies server state; clear stale signature so
|
||||||
|
* the next pre-save or auto-save does not send a mismatched one.
|
||||||
|
*/
|
||||||
|
signatureReference.current = null;
|
||||||
|
localStorage.removeItem("elysium_save_signature");
|
||||||
setAutoBossLastResult({
|
setAutoBossLastResult({
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
bossName: bossName,
|
bossName: bossName,
|
||||||
@@ -1316,11 +1438,13 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* "Boss is not currently available" is an expected race condition
|
* "Boss is not currently available" is an expected race condition
|
||||||
* in the tick loop — suppress telemetry for this case only
|
* when the client is ahead of the server save — silently skip and
|
||||||
|
* let the next tick retry rather than halting automation.
|
||||||
*/
|
*/
|
||||||
if (message !== "Boss is not currently available") {
|
if (message === "Boss is not currently available") {
|
||||||
logError("auto_boss", error_);
|
return;
|
||||||
}
|
}
|
||||||
|
logError("auto_boss", error_);
|
||||||
setAutoBossError(message);
|
setAutoBossError(message);
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -1743,7 +1867,18 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
const collectExploration = useCallback(
|
const collectExploration = useCallback(
|
||||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||||
|
isSyncingReference.current = true;
|
||||||
const result = await collectExplorationApi({ areaId });
|
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) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -1833,14 +1968,6 @@ export const GameProvider = ({
|
|||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
let materials = [ ...previous.exploration.materials ];
|
|
||||||
for (const request of recipe.requiredMaterials) {
|
|
||||||
materials = materials.map((mat) => {
|
|
||||||
return mat.materialId === request.materialId
|
|
||||||
? { ...mat, quantity: mat.quantity - request.quantity }
|
|
||||||
: mat;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
exploration: {
|
exploration: {
|
||||||
@@ -1853,7 +1980,7 @@ export const GameProvider = ({
|
|||||||
...previous.exploration.craftedRecipeIds,
|
...previous.exploration.craftedRecipeIds,
|
||||||
recipeId,
|
recipeId,
|
||||||
],
|
],
|
||||||
materials: materials,
|
materials: result.materials,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2110,9 +2237,13 @@ export const GameProvider = ({
|
|||||||
localStorage.setItem("elysium_save_signature", data.signature);
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
adventurersUnlocked: data.adventurersUnlocked,
|
||||||
bossesUnlocked: data.bossesUnlocked,
|
bossesUnlocked: data.bossesUnlocked,
|
||||||
|
equipmentUnlocked: data.equipmentUnlocked,
|
||||||
explorationUnlocked: data.explorationUnlocked,
|
explorationUnlocked: data.explorationUnlocked,
|
||||||
questsUnlocked: data.questsUnlocked,
|
questsUnlocked: data.questsUnlocked,
|
||||||
|
storyUnlocked: data.storyUnlocked,
|
||||||
|
upgradesUnlocked: data.upgradesUnlocked,
|
||||||
zonesUnlocked: data.zonesUnlocked,
|
zonesUnlocked: data.zonesUnlocked,
|
||||||
};
|
};
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
@@ -2122,14 +2253,75 @@ export const GameProvider = ({
|
|||||||
: "Failed to force unlocks",
|
: "Failed to force unlocks",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
adventurersUnlocked: 0,
|
||||||
bossesUnlocked: 0,
|
bossesUnlocked: 0,
|
||||||
|
equipmentUnlocked: 0,
|
||||||
explorationUnlocked: 0,
|
explorationUnlocked: 0,
|
||||||
questsUnlocked: 0,
|
questsUnlocked: 0,
|
||||||
|
storyUnlocked: 0,
|
||||||
|
upgradesUnlocked: 0,
|
||||||
zonesUnlocked: 0,
|
zonesUnlocked: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const syncNewContent = useCallback(async() => {
|
||||||
|
try {
|
||||||
|
const data = await syncNewContentApi();
|
||||||
|
setState(data.state);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
achievementsAdded: data.achievementsAdded,
|
||||||
|
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(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to sync new content",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const debugHardReset = useCallback(async() => {
|
const debugHardReset = useCallback(async() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -2207,6 +2399,7 @@ export const GameProvider = ({
|
|||||||
forceUnlocks,
|
forceUnlocks,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
handleClick,
|
handleClick,
|
||||||
|
inGuild,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
@@ -2216,6 +2409,7 @@ export const GameProvider = ({
|
|||||||
offlineEssence,
|
offlineEssence,
|
||||||
offlineGold,
|
offlineGold,
|
||||||
reload,
|
reload,
|
||||||
|
reloadSilent,
|
||||||
resetProgress,
|
resetProgress,
|
||||||
saveSchemaVersion,
|
saveSchemaVersion,
|
||||||
schemaOutdated,
|
schemaOutdated,
|
||||||
@@ -2230,6 +2424,7 @@ export const GameProvider = ({
|
|||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
syncNewContent,
|
||||||
toggleAutoAdventurer,
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
@@ -2277,6 +2472,7 @@ export const GameProvider = ({
|
|||||||
error,
|
error,
|
||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
forceSync,
|
forceSync,
|
||||||
|
inGuild,
|
||||||
forceUnlocks,
|
forceUnlocks,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -2302,6 +2498,7 @@ export const GameProvider = ({
|
|||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
syncNewContent,
|
||||||
toggleAutoAdventurer,
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
|
|||||||
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
|
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
|
||||||
id: "upgrade_mage_1",
|
id: "upgrade_apprentice_1",
|
||||||
sourceId: "mage_1",
|
sourceId: "apprentice_1",
|
||||||
sourceType: "upgrade",
|
sourceType: "upgrade",
|
||||||
title: "Arcane Tomes: The Written Knowledge",
|
title: "Arcane Tomes: The Written Knowledge",
|
||||||
zoneId: "guild_library",
|
zoneId: "guild_library",
|
||||||
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
|
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
|
||||||
id: "upgrade_cleric_1",
|
id: "upgrade_acolyte_1",
|
||||||
sourceId: "cleric_1",
|
sourceId: "acolyte_1",
|
||||||
sourceType: "upgrade",
|
sourceType: "upgrade",
|
||||||
title: "Holy Rites: The Sacred Routine",
|
title: "Holy Rites: The Sacred Routine",
|
||||||
zoneId: "guild_library",
|
zoneId: "guild_library",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.08 },
|
bonus: { type: "combat_power", value: 1.2 },
|
||||||
description:
|
description:
|
||||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
"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",
|
id: "elder_bark_shield",
|
||||||
@@ -102,7 +102,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.1 },
|
bonus: { type: "combat_power", value: 1.15 },
|
||||||
description:
|
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.",
|
"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",
|
id: "cursed_focus",
|
||||||
@@ -128,7 +128,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.12 },
|
bonus: { type: "combat_power", value: 1.2 },
|
||||||
description:
|
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.",
|
"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",
|
id: "elemental_ore_ingot",
|
||||||
@@ -194,7 +194,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 8: abyssal_trench
|
// Zone 8: abyssal_trench
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.15 },
|
bonus: { type: "combat_power", value: 1.25 },
|
||||||
description:
|
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.",
|
"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",
|
id: "pressure_forged_core",
|
||||||
@@ -272,7 +272,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 11: void_sanctum
|
// Zone 11: void_sanctum
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.18 },
|
bonus: { type: "combat_power", value: 1.28 },
|
||||||
description:
|
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.",
|
"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",
|
id: "null_field_generator",
|
||||||
@@ -310,7 +310,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.2 },
|
bonus: { type: "combat_power", value: 1.3 },
|
||||||
description:
|
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.",
|
"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",
|
id: "eternity_bound_ring",
|
||||||
@@ -376,7 +376,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 15: reality_forge
|
// Zone 15: reality_forge
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.22 },
|
bonus: { type: "combat_power", value: 1.35 },
|
||||||
description:
|
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.",
|
"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",
|
id: "reality_ingot",
|
||||||
@@ -428,7 +428,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 17: primeval_sanctum
|
// Zone 17: primeval_sanctum
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.25 },
|
bonus: { type: "combat_power", value: 1.4 },
|
||||||
description:
|
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.",
|
"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",
|
id: "ancient_memory_array",
|
||||||
@@ -466,7 +466,7 @@ export const RECIPES: Array<CraftingRecipe> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.3 },
|
bonus: { type: "combat_power", value: 1.55 },
|
||||||
description:
|
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.",
|
"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",
|
id: "omega_convergence",
|
||||||
|
|||||||
+407
-14
@@ -11,7 +11,6 @@
|
|||||||
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
|
/* 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/group-exports -- Exports appear alongside their definitions for readability */
|
||||||
/* eslint-disable import/exports-last -- 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 */
|
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
|
||||||
import {
|
import {
|
||||||
type Achievement,
|
type Achievement,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||||
|
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +83,12 @@ 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.
|
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
|
||||||
*/
|
*/
|
||||||
@@ -123,6 +129,357 @@ const capResource = (value: number): number => {
|
|||||||
return Math.min(value, RESOURCE_CAP);
|
return Math.min(value, RESOURCE_CAP);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function — applies one game tick to the state.
|
||||||
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
|
* Returns a new GameState (does not mutate the original).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
||||||
|
* @returns A new GameState with the tick applied.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Computes the effective gold earned per second across all adventurers,
|
||||||
|
* including all active multipliers (upgrades, prestige, equipment, etc.).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns Gold per second as a number.
|
||||||
|
*/
|
||||||
|
export const computeGoldPerSecond = (state: GameState): number => {
|
||||||
|
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
||||||
|
return item.equipped;
|
||||||
|
});
|
||||||
|
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||||
|
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||||
|
}, 1);
|
||||||
|
const setGoldMultiplier = computeSetBonuses(
|
||||||
|
equippedItems.map((item) => {
|
||||||
|
return item.id;
|
||||||
|
}),
|
||||||
|
EQUIPMENT_SETS,
|
||||||
|
).goldMultiplier;
|
||||||
|
|
||||||
|
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||||
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||||
|
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||||
|
const companionBonus = getActiveCompanionBonus(
|
||||||
|
state.companions?.activeCompanionId,
|
||||||
|
state.companions?.unlockedCompanionIds ?? [],
|
||||||
|
);
|
||||||
|
const companionGoldMult
|
||||||
|
= companionBonus?.type === "passiveGold"
|
||||||
|
? 1 + companionBonus.value
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
let goldPerSecond = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const upgradeMultiplier = state.upgrades.
|
||||||
|
filter((upgrade) => {
|
||||||
|
const isGlobal = upgrade.target === "global";
|
||||||
|
const isThisAdventurer
|
||||||
|
= upgrade.target === "adventurer"
|
||||||
|
&& upgrade.adventurerId === adventurer.id;
|
||||||
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||||
|
}).
|
||||||
|
reduce((mult, upgrade) => {
|
||||||
|
return mult * upgrade.multiplier;
|
||||||
|
}, 1);
|
||||||
|
const contribution
|
||||||
|
= adventurer.goldPerSecond
|
||||||
|
* adventurer.count
|
||||||
|
* upgradeMultiplier
|
||||||
|
* state.prestige.productionMultiplier
|
||||||
|
* runestonesIncome
|
||||||
|
* echoIncome
|
||||||
|
* equipmentGoldMultiplier
|
||||||
|
* setGoldMultiplier
|
||||||
|
* craftedGoldMultiplier
|
||||||
|
* companionGoldMult;
|
||||||
|
goldPerSecond = goldPerSecond + contribution;
|
||||||
|
}
|
||||||
|
return goldPerSecond;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = 15;
|
||||||
|
const maxBaseRunestones = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the projected runestone reward if the player were to prestige right now.
|
||||||
|
* Mirrors the server-side calculateRunestones formula exactly.
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns The number of runestones the player would earn from a prestige now.
|
||||||
|
*/
|
||||||
|
export const computeProjectedRunestones = (state: GameState): number => {
|
||||||
|
const { count, purchasedUpgradeIds } = state.prestige;
|
||||||
|
const threshold = basePrestigeThreshold * Math.pow(count + 1, 2);
|
||||||
|
const base = Math.min(
|
||||||
|
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
|
||||||
|
* runestonesPerPrestigeLevelClient,
|
||||||
|
maxBaseRunestones,
|
||||||
|
);
|
||||||
|
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
|
||||||
|
? 1.25
|
||||||
|
: 1;
|
||||||
|
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
|
||||||
|
? 1.5
|
||||||
|
: 1;
|
||||||
|
const runestoneMult = gain1Mult * gain2Mult;
|
||||||
|
const echoMult: number
|
||||||
|
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
|
||||||
|
return Math.floor(base * runestoneMult * echoMult);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function — applies one game tick to the state.
|
* Pure function — applies one game tick to the state.
|
||||||
* DeltaSeconds: time elapsed since last tick.
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
@@ -397,6 +754,19 @@ export const applyTick = (
|
|||||||
challengeCrystals = result.crystalsAwarded;
|
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 goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||||
const essenceValue = capResource(
|
const essenceValue = capResource(
|
||||||
state.resources.essence + essenceGained + questEssence,
|
state.resources.essence + essenceGained + questEssence,
|
||||||
@@ -417,6 +787,23 @@ export const applyTick = (
|
|||||||
...updatedDailyChallenges === undefined
|
...updatedDailyChallenges === undefined
|
||||||
? {}
|
? {}
|
||||||
: { dailyChallenges: updatedDailyChallenges },
|
: { 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,
|
adventurers: updatedAdventurers,
|
||||||
bosses: updatedBosses,
|
bosses: updatedBosses,
|
||||||
equipment: updatedEquipmentReference,
|
equipment: updatedEquipmentReference,
|
||||||
@@ -430,24 +817,30 @@ export const applyTick = (
|
|||||||
zones: updatedZones,
|
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 updatedAchievements = checkAchievements(partialState);
|
||||||
const crystalsFromAchievements = updatedAchievements.reduce(
|
let crystalsFromAchievements = 0;
|
||||||
(sum, achievement, index) => {
|
let runestonesFromAchievements = 0;
|
||||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
for (const [ index, achievement ] of updatedAchievements.entries()) {
|
||||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||||
if (wasLocked && isNowUnlocked) {
|
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||||
return sum + (achievement.reward?.crystals ?? 0);
|
if (wasLocked && isNowUnlocked) {
|
||||||
}
|
crystalsFromAchievements
|
||||||
return sum;
|
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
|
||||||
},
|
runestonesFromAchievements
|
||||||
0,
|
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...partialState,
|
...partialState,
|
||||||
achievements: updatedAchievements,
|
achievements: updatedAchievements,
|
||||||
resources: {
|
prestige: {
|
||||||
|
...partialState.prestige,
|
||||||
|
runestones:
|
||||||
|
partialState.prestige.runestones + runestonesFromAchievements,
|
||||||
|
},
|
||||||
|
resources: {
|
||||||
...partialState.resources,
|
...partialState.resources,
|
||||||
crystals: capResource(
|
crystals: capResource(
|
||||||
partialState.resources.crystals + crystalsFromAchievements,
|
partialState.resources.crystals + crystalsFromAchievements,
|
||||||
|
|||||||
+124
-43
@@ -116,6 +116,66 @@ body::before {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Resource toggle + dropdown ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.resource-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-toggle {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-toggle:hover {
|
||||||
|
background: rgba(147, 51, 234, 0.2);
|
||||||
|
border-color: var(--colour-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-alert-dot {
|
||||||
|
background: var(--colour-warning, #f59e0b);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 0.45rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-dropdown {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-dropdown .resource {
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-dropdown .resource:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== GAME LAYOUT ===================== */
|
/* ===================== GAME LAYOUT ===================== */
|
||||||
.game-layout {
|
.game-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1492,57 +1552,87 @@ body::before {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */
|
/* ── Resource bar actions (save + profile menu) ─────────────────────────── */
|
||||||
|
|
||||||
.profile-buttons {
|
.resource-bar-actions {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-link-button {
|
.profile-menu {
|
||||||
align-items: center;
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(147, 51, 234, 0.4);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: var(--colour-text-muted);
|
|
||||||
display: flex;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-link-button:hover {
|
.profile-avatar-button {
|
||||||
background: rgba(147, 51, 234, 0.2);
|
background: none;
|
||||||
border-color: var(--colour-primary);
|
border: 2px solid rgba(147, 51, 234, 0.4);
|
||||||
color: var(--colour-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-edit-button {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(147, 51, 234, 0.4);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: var(--colour-text-muted);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
display: flex;
|
||||||
font-size: 0.85rem;
|
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
line-height: 1;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: all 0.2s;
|
transition: border-color 0.2s;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-edit-button:hover {
|
.profile-avatar-button:hover {
|
||||||
background: rgba(147, 51, 234, 0.2);
|
|
||||||
border-color: var(--colour-primary);
|
border-color: var(--colour-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-img {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dropdown {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dropdown-item {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dropdown-item:hover {
|
||||||
|
background: rgba(147, 51, 234, 0.15);
|
||||||
color: var(--colour-text);
|
color: var(--colour-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-dropdown-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(147, 51, 234, 0.2);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.save-status {
|
.save-status {
|
||||||
color: var(--colour-text-muted);
|
color: var(--colour-text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -3167,10 +3257,10 @@ body::before {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Profile buttons fill their own row, aligned right */
|
/* Resource bar actions fill their own row, aligned right */
|
||||||
.profile-buttons {
|
.resource-bar-actions {
|
||||||
margin-left: 0;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3240,15 +3330,6 @@ body::before {
|
|||||||
|
|
||||||
/* --- Small mobile (≤ 480px) --------------------------- */
|
/* --- Small mobile (≤ 480px) --------------------------- */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* Icon-only profile link buttons to save horizontal space */
|
|
||||||
.btn-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-link-button {
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly smaller tab buttons */
|
/* Slightly smaller tab buttons */
|
||||||
.tab-button {
|
.tab-button {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.1.2",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.1.2",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
@@ -72,6 +73,7 @@ export type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ interface AchievementCondition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AchievementReward {
|
interface AchievementReward {
|
||||||
crystals?: number;
|
crystals?: number;
|
||||||
|
runestones?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Achievement {
|
interface Achievement {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- API types file grows with each new endpoint */
|
||||||
import type {
|
import type {
|
||||||
EquipmentBonus,
|
EquipmentBonus,
|
||||||
EquipmentRarity,
|
EquipmentRarity,
|
||||||
@@ -69,6 +70,11 @@ interface LoginBonusResult {
|
|||||||
interface LoadResponse {
|
interface LoadResponse {
|
||||||
state: GameState;
|
state: GameState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player is currently a member of the NHCarrigan Discord server.
|
||||||
|
*/
|
||||||
|
inGuild: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offline gold earned since last save (server-calculated).
|
* Offline gold earned since last save (server-calculated).
|
||||||
*/
|
*/
|
||||||
@@ -384,6 +390,10 @@ interface ExploreCollectResponse {
|
|||||||
event: ExploreCollectEventResult | null;
|
event: ExploreCollectEventResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExploreClaimableResponse {
|
||||||
|
claimable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CraftRecipeRequest {
|
interface CraftRecipeRequest {
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
}
|
}
|
||||||
@@ -396,6 +406,7 @@ interface CraftRecipeResponse {
|
|||||||
craftedEssenceMultiplier: number;
|
craftedEssenceMultiplier: number;
|
||||||
craftedClickMultiplier: number;
|
craftedClickMultiplier: number;
|
||||||
craftedCombatMultiplier: number;
|
craftedCombatMultiplier: number;
|
||||||
|
materials: Array<{ materialId: string; quantity: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForceUnlocksResponse {
|
interface ForceUnlocksResponse {
|
||||||
@@ -425,12 +436,135 @@ interface ForceUnlocksResponse {
|
|||||||
*/
|
*/
|
||||||
explorationUnlocked: number;
|
explorationUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of adventurer tiers that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
adventurersUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrades that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
upgradesUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of equipment items that were marked as owned by this operation.
|
||||||
|
*/
|
||||||
|
equipmentUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of story chapters that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
storyUnlocked: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
|
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
|
||||||
*/
|
*/
|
||||||
signature?: string;
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyncNewContentResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated game state after injecting all missing content entries.
|
||||||
|
*/
|
||||||
|
state: GameState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of adventurer tiers added to the save.
|
||||||
|
*/
|
||||||
|
adventurersAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||||
|
*/
|
||||||
|
adventurerStatsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrades added to the save.
|
||||||
|
*/
|
||||||
|
upgradesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of rewards patched onto existing quests.
|
||||||
|
*/
|
||||||
|
questRewardsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of quests added to the save.
|
||||||
|
*/
|
||||||
|
questsAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of bosses added to the save.
|
||||||
|
*/
|
||||||
|
bossesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrade reward IDs patched onto existing bosses.
|
||||||
|
*/
|
||||||
|
bossRewardsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of equipment items added to the save.
|
||||||
|
*/
|
||||||
|
equipmentAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of achievements added to the save.
|
||||||
|
*/
|
||||||
|
achievementsAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of zones added to the save.
|
||||||
|
*/
|
||||||
|
zonesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of exploration areas added to the save.
|
||||||
|
*/
|
||||||
|
explorationAreasAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AboutResponse,
|
AboutResponse,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -445,6 +579,7 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
@@ -462,6 +597,7 @@ export type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
|||||||
@@ -48,11 +48,17 @@ interface ProfileSettings {
|
|||||||
* Whether browser system notifications are enabled.
|
* Whether browser system notifications are enabled.
|
||||||
*/
|
*/
|
||||||
enableNotifications: boolean;
|
enableNotifications: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether prestige milestones are announced in the Discord server.
|
||||||
|
*/
|
||||||
|
enablePrestigeAnnouncements: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
||||||
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||||
enableNotifications: false,
|
enableNotifications: false,
|
||||||
|
enablePrestigeAnnouncements: true,
|
||||||
enableSounds: false,
|
enableSounds: false,
|
||||||
numberFormat: "suffix",
|
numberFormat: "suffix",
|
||||||
showAchievementsUnlocked: true,
|
showAchievementsUnlocked: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user