generated from nhcarrigan/template
chore: community feedback fixes and UI improvements (#102)
## Summary Addresses all community feedback tickets from the last deploy, plus several UI improvements made during the same session. ### Bug fixes & balance - **#97** — Fix auto-adventurer tier priority: sort by combat power instead of current cost so the highest-tier affordable unit is always purchased - **#98** — Add Dark Templar adventurer (80k CP) to bridge the Volcanic Depths progression wall; rewire upgrade and quest rewards accordingly - **#99** — Reorder and buff Shadow Assassin (55k CP, level 12) so Witch Coven feels rewarding rather than a regression - **#100** — Display effective Gold/s (all multipliers applied) in the resource bar - **#101** — Add Peasant tier 2 (10x, essence) and tier 3 (50x, crystals) upgrades for meaningful late-game scaling ### Other fixes - Sync game state to server before auto-boss challenges (matching manual challenge behaviour) - Refresh Discord avatar hash on every game load via bot token so stale CDN URLs are corrected automatically ### UI improvements - Replace Donate / Discord / Support / View Profile / Edit Profile buttons with a single avatar dropdown menu - Collapse all resources except Gold into a click-to-toggle dropdown; orange alert dot appears when a hidden resource is capped ## Closes Closes #97 Closes #98 Closes #99 Closes #100 Closes #101 Reviewed-on: #102 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #102.
This commit is contained in:
@@ -128,18 +128,6 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
name: "Dragon Rider",
|
name: "Dragon Rider",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
baseCost: 4_000_000_000,
|
|
||||||
class: "rogue",
|
|
||||||
combatPower: 18_000,
|
|
||||||
count: 0,
|
|
||||||
essencePerSecond: 6,
|
|
||||||
goldPerSecond: 5000,
|
|
||||||
id: "shadow_assassin",
|
|
||||||
level: 11,
|
|
||||||
name: "Shadow Assassin",
|
|
||||||
unlocked: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
baseCost: 28_000_000_000,
|
baseCost: 28_000_000_000,
|
||||||
class: "mage",
|
class: "mage",
|
||||||
@@ -148,10 +136,34 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 15,
|
essencePerSecond: 15,
|
||||||
goldPerSecond: 14_000,
|
goldPerSecond: 14_000,
|
||||||
id: "arcane_scholar",
|
id: "arcane_scholar",
|
||||||
level: 12,
|
level: 11,
|
||||||
name: "Arcane Scholar",
|
name: "Arcane Scholar",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
baseCost: 45_000_000_000,
|
||||||
|
class: "rogue",
|
||||||
|
combatPower: 55_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 20,
|
||||||
|
goldPerSecond: 18_000,
|
||||||
|
id: "shadow_assassin",
|
||||||
|
level: 12,
|
||||||
|
name: "Shadow Assassin",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseCost: 70_000_000_000,
|
||||||
|
class: "paladin",
|
||||||
|
combatPower: 80_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 22,
|
||||||
|
goldPerSecond: 22_000,
|
||||||
|
id: "dark_templar",
|
||||||
|
level: 13,
|
||||||
|
name: "Dark Templar",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
baseCost: 200_000_000_000,
|
baseCost: 200_000_000_000,
|
||||||
class: "rogue",
|
class: "rogue",
|
||||||
@@ -160,7 +172,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 35,
|
essencePerSecond: 35,
|
||||||
goldPerSecond: 40_000,
|
goldPerSecond: 40_000,
|
||||||
id: "void_walker",
|
id: "void_walker",
|
||||||
level: 13,
|
level: 14,
|
||||||
name: "Void Walker",
|
name: "Void Walker",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -172,7 +184,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 100,
|
essencePerSecond: 100,
|
||||||
goldPerSecond: 120_000,
|
goldPerSecond: 120_000,
|
||||||
id: "celestial_guard",
|
id: "celestial_guard",
|
||||||
level: 14,
|
level: 15,
|
||||||
name: "Celestial Guard",
|
name: "Celestial Guard",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300,
|
essencePerSecond: 300,
|
||||||
goldPerSecond: 400_000,
|
goldPerSecond: 400_000,
|
||||||
id: "divine_champion",
|
id: "divine_champion",
|
||||||
level: 15,
|
level: 16,
|
||||||
name: "Divine Champion",
|
name: "Divine Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 800,
|
essencePerSecond: 800,
|
||||||
goldPerSecond: 1_200_000,
|
goldPerSecond: 1_200_000,
|
||||||
id: "seraph_knight",
|
id: "seraph_knight",
|
||||||
level: 16,
|
level: 17,
|
||||||
name: "Seraph Knight",
|
name: "Seraph Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2000,
|
essencePerSecond: 2000,
|
||||||
goldPerSecond: 3_500_000,
|
goldPerSecond: 3_500_000,
|
||||||
id: "abyss_diver",
|
id: "abyss_diver",
|
||||||
level: 17,
|
level: 18,
|
||||||
name: "Abyss Diver",
|
name: "Abyss Diver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 5000,
|
essencePerSecond: 5000,
|
||||||
goldPerSecond: 10_000_000,
|
goldPerSecond: 10_000_000,
|
||||||
id: "infernal_warden",
|
id: "infernal_warden",
|
||||||
level: 18,
|
level: 19,
|
||||||
name: "Infernal Warden",
|
name: "Infernal Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000,
|
essencePerSecond: 12_000,
|
||||||
goldPerSecond: 30_000_000,
|
goldPerSecond: 30_000_000,
|
||||||
id: "crystal_sage",
|
id: "crystal_sage",
|
||||||
level: 19,
|
level: 20,
|
||||||
name: "Crystal Sage",
|
name: "Crystal Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 30_000,
|
essencePerSecond: 30_000,
|
||||||
goldPerSecond: 90_000_000,
|
goldPerSecond: 90_000_000,
|
||||||
id: "void_sentinel",
|
id: "void_sentinel",
|
||||||
level: 20,
|
level: 21,
|
||||||
name: "Void Sentinel",
|
name: "Void Sentinel",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 80_000,
|
essencePerSecond: 80_000,
|
||||||
goldPerSecond: 270_000_000,
|
goldPerSecond: 270_000_000,
|
||||||
id: "eternal_champion",
|
id: "eternal_champion",
|
||||||
level: 21,
|
level: 22,
|
||||||
name: "Eternal Champion",
|
name: "Eternal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 220_000,
|
essencePerSecond: 220_000,
|
||||||
goldPerSecond: 800_000_000,
|
goldPerSecond: 800_000_000,
|
||||||
id: "aether_weaver",
|
id: "aether_weaver",
|
||||||
level: 22,
|
level: 23,
|
||||||
name: "Aether Weaver",
|
name: "Aether Weaver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 600_000,
|
essencePerSecond: 600_000,
|
||||||
goldPerSecond: 2_500_000_000,
|
goldPerSecond: 2_500_000_000,
|
||||||
id: "titan_warrior",
|
id: "titan_warrior",
|
||||||
level: 23,
|
level: 24,
|
||||||
name: "Titan Warrior",
|
name: "Titan Warrior",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 1_600_000,
|
essencePerSecond: 1_600_000,
|
||||||
goldPerSecond: 7_500_000_000,
|
goldPerSecond: 7_500_000_000,
|
||||||
id: "nexus_sage",
|
id: "nexus_sage",
|
||||||
level: 24,
|
level: 25,
|
||||||
name: "Nexus Sage",
|
name: "Nexus Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 4_500_000,
|
essencePerSecond: 4_500_000,
|
||||||
goldPerSecond: 22_000_000_000,
|
goldPerSecond: 22_000_000_000,
|
||||||
id: "cosmos_knight",
|
id: "cosmos_knight",
|
||||||
level: 25,
|
level: 26,
|
||||||
name: "Cosmos Knight",
|
name: "Cosmos Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000_000,
|
essencePerSecond: 12_000_000,
|
||||||
goldPerSecond: 65_000_000_000,
|
goldPerSecond: 65_000_000_000,
|
||||||
id: "astral_sovereign",
|
id: "astral_sovereign",
|
||||||
level: 26,
|
level: 27,
|
||||||
name: "Astral Sovereign",
|
name: "Astral Sovereign",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 35_000_000,
|
essencePerSecond: 35_000_000,
|
||||||
goldPerSecond: 200_000_000_000,
|
goldPerSecond: 200_000_000_000,
|
||||||
id: "primordial_mage",
|
id: "primordial_mage",
|
||||||
level: 27,
|
level: 28,
|
||||||
name: "Primordial Mage",
|
name: "Primordial Mage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 100_000_000,
|
essencePerSecond: 100_000_000,
|
||||||
goldPerSecond: 600_000_000_000,
|
goldPerSecond: 600_000_000_000,
|
||||||
id: "reality_warden",
|
id: "reality_warden",
|
||||||
level: 28,
|
level: 29,
|
||||||
name: "Reality Warden",
|
name: "Reality Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300_000_000,
|
essencePerSecond: 300_000_000,
|
||||||
goldPerSecond: 1_800_000_000_000,
|
goldPerSecond: 1_800_000_000_000,
|
||||||
id: "infinity_ranger",
|
id: "infinity_ranger",
|
||||||
level: 29,
|
level: 30,
|
||||||
name: "Infinity Ranger",
|
name: "Infinity Ranger",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 850_000_000,
|
essencePerSecond: 850_000_000,
|
||||||
goldPerSecond: 5_500_000_000_000,
|
goldPerSecond: 5_500_000_000_000,
|
||||||
id: "oblivion_paladin",
|
id: "oblivion_paladin",
|
||||||
level: 30,
|
level: 31,
|
||||||
name: "Oblivion Paladin",
|
name: "Oblivion Paladin",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2_500_000_000,
|
essencePerSecond: 2_500_000_000,
|
||||||
goldPerSecond: 16_000_000_000_000,
|
goldPerSecond: 16_000_000_000_000,
|
||||||
id: "transcendent_rogue",
|
id: "transcendent_rogue",
|
||||||
level: 31,
|
level: 32,
|
||||||
name: "Transcendent Rogue",
|
name: "Transcendent Rogue",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 7_000_000_000,
|
essencePerSecond: 7_000_000_000,
|
||||||
goldPerSecond: 50_000_000_000_000,
|
goldPerSecond: 50_000_000_000_000,
|
||||||
id: "omniversal_champion",
|
id: "omniversal_champion",
|
||||||
level: 32,
|
level: 33,
|
||||||
name: "Omniversal Champion",
|
name: "Omniversal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
name: "The Ancient Fire Elemental",
|
name: "The Ancient Fire Elemental",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
upgradeRewards: [ "celestial_guard_1" ],
|
upgradeRewards: [ "dark_templar_1" ],
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,7 +263,7 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
name: "The Magma Titan",
|
name: "The Magma Titan",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
upgradeRewards: [ "crystal_resonance" ],
|
upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
{ amount: 1500, type: "essence" },
|
{ amount: 1500, type: "essence" },
|
||||||
{ amount: 75, type: "crystals" },
|
{ amount: 75, type: "crystals" },
|
||||||
{ targetId: "knight_1", type: "upgrade" },
|
{ targetId: "knight_1", type: "upgrade" },
|
||||||
|
{ targetId: "peasant_2", type: "upgrade" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
@@ -199,6 +200,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
{ amount: 8_000_000, type: "gold" },
|
{ amount: 8_000_000, type: "gold" },
|
||||||
{ amount: 2000, type: "essence" },
|
{ amount: 2000, type: "essence" },
|
||||||
{ amount: 150, type: "crystals" },
|
{ amount: 150, type: "crystals" },
|
||||||
|
{ targetId: "dark_templar", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
@@ -281,6 +283,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
{ amount: 40_000_000, type: "gold" },
|
{ amount: 40_000_000, type: "gold" },
|
||||||
{ amount: 12_000, type: "essence" },
|
{ amount: 12_000, type: "essence" },
|
||||||
{ amount: 300, type: "crystals" },
|
{ amount: 300, type: "crystals" },
|
||||||
|
{ targetId: "peasant_3", type: "upgrade" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -272,7 +300,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "shadow_assassin",
|
adventurerId: "shadow_assassin",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 50,
|
costEssence: 175,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
||||||
id: "shadow_assassin_1",
|
id: "shadow_assassin_1",
|
||||||
@@ -295,6 +323,20 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "dark_templar",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 200,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { fetchDiscordUserById } from "../services/discord.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
|
|||||||
try {
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
|
||||||
|
Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
|
]),
|
||||||
|
fetchDiscordUserById(discordId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Refresh avatar in DB when Discord returns an updated hash
|
||||||
|
if (
|
||||||
|
freshDiscordUser !== null
|
||||||
|
&& playerRecord !== null
|
||||||
|
&& freshDiscordUser.avatar !== playerRecord.avatar
|
||||||
|
) {
|
||||||
|
playerRecord.avatar = freshDiscordUser.avatar;
|
||||||
|
void prisma.player.update({
|
||||||
|
data: { avatar: freshDiscordUser.avatar },
|
||||||
|
where: { discordId },
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
void logger.error(
|
||||||
|
"avatar_refresh",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
@@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => {
|
|||||||
*/
|
*/
|
||||||
if (playerRecord !== null) {
|
if (playerRecord !== null) {
|
||||||
state.player.characterName = playerRecord.characterName;
|
state.player.characterName = playerRecord.characterName;
|
||||||
|
state.player.avatar = playerRecord.avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -106,6 +106,40 @@ const fetchDiscordUser = async(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a Discord user's profile by their Discord ID using the bot token.
|
||||||
|
* Returns null on any failure so callers are never blocked by Discord API issues.
|
||||||
|
* @param discordId - The Discord user ID to look up.
|
||||||
|
* @returns The Discord user object, or null if the fetch fails.
|
||||||
|
*/
|
||||||
|
const fetchDiscordUserById = async(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<DiscordUser | null> => {
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
if (botToken === undefined || botToken === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://discord.com/api/v10/users/${discordId}`,
|
||||||
|
{ headers: { Authorization: `Bot ${botToken}` } },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user_by_id",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the Discord OAuth authorisation URL.
|
* Builds the Discord OAuth authorisation URL.
|
||||||
* @returns The full OAuth URL to redirect the user to.
|
* @returns The full OAuth URL to redirect the user to.
|
||||||
@@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type { DiscordTokenResponse, DiscordUser };
|
export type { DiscordTokenResponse, DiscordUser };
|
||||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -104,4 +104,53 @@ describe("discord service", () => {
|
|||||||
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchDiscordUserById", () => {
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is missing", async () => {
|
||||||
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is empty", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "";
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when response is not ok", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user on success", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" };
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toMatchObject({ id: "123456", avatar: "abc123" });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ const GameLayout = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileUrl = `/profile/${state.player.discordId}`;
|
|
||||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||||
|
|
||||||
@@ -160,7 +159,6 @@ const GameLayout = (): JSX.Element => {
|
|||||||
onEditProfile={handleOpenEditProfile}
|
onEditProfile={handleOpenEditProfile}
|
||||||
onForceSync={forceSync}
|
onForceSync={forceSync}
|
||||||
prestigeCount={state.prestige.count}
|
prestigeCount={state.prestige.count}
|
||||||
profileUrl={profileUrl}
|
|
||||||
resources={state.resources}
|
resources={state.resources}
|
||||||
runestones={state.prestige.runestones}
|
runestones={state.prestige.runestones}
|
||||||
transcendenceCount={state.transcendence?.count ?? 0}
|
transcendenceCount={state.transcendence?.count ?? 0}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- Resource bar has many resource and action elements */
|
||||||
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
||||||
|
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
|
||||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||||
|
import { useState, type FocusEvent, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
|
||||||
import type { Resource } from "@elysium/types";
|
import type { Resource } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
|
||||||
|
|
||||||
interface ResourceBarProperties {
|
interface ResourceBarProperties {
|
||||||
readonly resources: Resource;
|
readonly resources: Resource;
|
||||||
@@ -17,7 +19,6 @@ interface ResourceBarProperties {
|
|||||||
readonly prestigeCount: number;
|
readonly prestigeCount: number;
|
||||||
readonly transcendenceCount: number;
|
readonly transcendenceCount: number;
|
||||||
readonly apotheosisCount: number;
|
readonly apotheosisCount: number;
|
||||||
readonly profileUrl: string;
|
|
||||||
readonly onEditProfile: ()=> void;
|
readonly onEditProfile: ()=> void;
|
||||||
readonly lastSavedAt: number | null;
|
readonly lastSavedAt: number | null;
|
||||||
readonly isSyncing: boolean;
|
readonly isSyncing: boolean;
|
||||||
@@ -58,7 +59,6 @@ const resourceFullTooltip = [
|
|||||||
* @param props.prestigeCount - The number of prestiges completed.
|
* @param props.prestigeCount - The number of prestiges completed.
|
||||||
* @param props.transcendenceCount - The number of transcendences completed.
|
* @param props.transcendenceCount - The number of transcendences completed.
|
||||||
* @param props.apotheosisCount - The number of apotheoses completed.
|
* @param props.apotheosisCount - The number of apotheoses completed.
|
||||||
* @param props.profileUrl - The URL of the player's public profile.
|
|
||||||
* @param props.onEditProfile - Callback to open the edit profile modal.
|
* @param props.onEditProfile - Callback to open the edit profile modal.
|
||||||
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
||||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||||
@@ -71,56 +71,130 @@ const ResourceBar = ({
|
|||||||
prestigeCount,
|
prestigeCount,
|
||||||
transcendenceCount,
|
transcendenceCount,
|
||||||
apotheosisCount,
|
apotheosisCount,
|
||||||
profileUrl,
|
|
||||||
onEditProfile,
|
onEditProfile,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
onForceSync,
|
onForceSync,
|
||||||
}: ResourceBarProperties): JSX.Element => {
|
}: ResourceBarProperties): JSX.Element => {
|
||||||
const { formatNumber, syncError, state } = useGame();
|
const { formatNumber, syncError, state } = useGame();
|
||||||
|
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||||
|
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||||
|
|
||||||
const { gold, essence, crystals } = resources;
|
const { gold, essence, crystals } = resources;
|
||||||
let partyCombatPower = 0;
|
let partyCombatPower = 0;
|
||||||
|
let goldPerSecond = 0;
|
||||||
if (state !== null) {
|
if (state !== null) {
|
||||||
for (const adventurer of state.adventurers) {
|
for (const adventurer of state.adventurers) {
|
||||||
const contribution = adventurer.combatPower * adventurer.count;
|
const contribution = adventurer.combatPower * adventurer.count;
|
||||||
partyCombatPower = partyCombatPower + contribution;
|
partyCombatPower = partyCombatPower + contribution;
|
||||||
}
|
}
|
||||||
|
goldPerSecond = computeGoldPerSecond(state);
|
||||||
}
|
}
|
||||||
const resourceValues = [ gold, essence, crystals ];
|
|
||||||
const anyFull = resourceValues.some((v) => {
|
let avatarUrl: string | null = null;
|
||||||
return v >= RESOURCE_CAP;
|
if (state !== null) {
|
||||||
});
|
avatarUrl = state.player.avatar === null
|
||||||
|
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
|
||||||
|
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
|
||||||
|
}
|
||||||
|
const profileUrl = state === null
|
||||||
|
? "#"
|
||||||
|
: `/profile/${state.player.discordId}`;
|
||||||
|
|
||||||
const goldFull = gold >= RESOURCE_CAP;
|
const goldFull = gold >= RESOURCE_CAP;
|
||||||
const essenceFull = essence >= RESOURCE_CAP;
|
const essenceFull = essence >= RESOURCE_CAP;
|
||||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||||
|
const anyFull = goldFull || essenceFull || crystalsFull;
|
||||||
|
const hiddenResourcesFull = essenceFull || crystalsFull;
|
||||||
|
|
||||||
function handleForceSync(): void {
|
function handleForceSync(): void {
|
||||||
void onForceSync();
|
void onForceSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleResources(): void {
|
||||||
|
setIsResourcesOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResourceBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsResourcesOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleProfile(): void {
|
||||||
|
setIsProfileOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditProfile(): void {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
onEditProfile();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="resource-bar">
|
<header className="resource-bar">
|
||||||
<div className={`resource${goldFull
|
<div
|
||||||
|
className="resource-menu"
|
||||||
|
onBlur={handleResourceBlur}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`resource resource-toggle${goldFull
|
||||||
? " resource-full"
|
? " resource-full"
|
||||||
: ""}`}>
|
: ""}`}
|
||||||
|
onClick={handleToggleResources}
|
||||||
|
title="Click to see all resources"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span className="resource-icon">{"🪙"}</span>
|
<span className="resource-icon">{"🪙"}</span>
|
||||||
<span className="resource-value">{formatNumber(gold)}</span>
|
<span className="resource-value">{formatNumber(gold)}</span>
|
||||||
<span className="resource-label">{"Gold"}</span>
|
<span className="resource-label">{"Gold"}</span>
|
||||||
{goldFull
|
{goldFull
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
{"FULL"}
|
{"FULL"}
|
||||||
</span>
|
</span>
|
||||||
: null}
|
: null}
|
||||||
|
{hiddenResourcesFull
|
||||||
|
? <span
|
||||||
|
className="resource-alert-dot"
|
||||||
|
title={"One or more resources are full!"}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
</button>
|
||||||
|
{isResourcesOpen
|
||||||
|
? <div className="resources-dropdown">
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"📈"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(goldPerSecond)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Gold/s"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`resource${essenceFull
|
<div className={`resource${essenceFull
|
||||||
? " resource-full"
|
? " resource-full"
|
||||||
: ""}`}>
|
: ""}`}>
|
||||||
<span className="resource-icon">{"✨"}</span>
|
<span className="resource-icon">{"✨"}</span>
|
||||||
<span className="resource-value">{formatNumber(essence)}</span>
|
<span className="resource-value">
|
||||||
|
{formatNumber(essence)}
|
||||||
|
</span>
|
||||||
<span className="resource-label">{"Essence"}</span>
|
<span className="resource-label">{"Essence"}</span>
|
||||||
{essenceFull
|
{essenceFull
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
{"FULL"}
|
{"FULL"}
|
||||||
</span>
|
</span>
|
||||||
: null}
|
: null}
|
||||||
@@ -129,17 +203,24 @@ const ResourceBar = ({
|
|||||||
? " resource-full"
|
? " resource-full"
|
||||||
: ""}`}>
|
: ""}`}>
|
||||||
<span className="resource-icon">{"💎"}</span>
|
<span className="resource-icon">{"💎"}</span>
|
||||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
<span className="resource-value">
|
||||||
|
{formatNumber(crystals)}
|
||||||
|
</span>
|
||||||
<span className="resource-label">{"Crystals"}</span>
|
<span className="resource-label">{"Crystals"}</span>
|
||||||
{crystalsFull
|
{crystalsFull
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
{"FULL"}
|
{"FULL"}
|
||||||
</span>
|
</span>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="resource">
|
<div className="resource">
|
||||||
<span className="resource-icon">{"🔮"}</span>
|
<span className="resource-icon">{"🔮"}</span>
|
||||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
<span className="resource-value">
|
||||||
|
{formatNumber(runestones)}
|
||||||
|
</span>
|
||||||
<span className="resource-label">{"Runestones"}</span>
|
<span className="resource-label">{"Runestones"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="resource">
|
<div className="resource">
|
||||||
@@ -149,6 +230,9 @@ const ResourceBar = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="resource-label">{"Combat Power"}</span>
|
<span className="resource-label">{"Combat Power"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
{apotheosisCount > 0
|
{apotheosisCount > 0
|
||||||
&& <div className="apotheosis-badge">
|
&& <div className="apotheosis-badge">
|
||||||
{"✨ Apotheosis "}
|
{"✨ Apotheosis "}
|
||||||
@@ -167,34 +251,7 @@ const ResourceBar = ({
|
|||||||
{prestigeCount}
|
{prestigeCount}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="profile-buttons">
|
<div className="resource-bar-actions">
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://donate.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Support the developer"
|
|
||||||
>
|
|
||||||
{"💜"} <span className="btn-label">{"Donate"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://chat.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Join our Discord"
|
|
||||||
>
|
|
||||||
{"💬"} <span className="btn-label">{"Discord"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://support.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Get support on our forum"
|
|
||||||
>
|
|
||||||
{"🆘"} <span className="btn-label">{"Support"}</span>
|
|
||||||
</a>
|
|
||||||
{syncError === null
|
{syncError === null
|
||||||
? null
|
? null
|
||||||
: <span className="save-status save-error" title={syncError}>
|
: <span className="save-status save-error" title={syncError}>
|
||||||
@@ -221,23 +278,69 @@ const ResourceBar = ({
|
|||||||
? "⏳"
|
? "⏳"
|
||||||
: "💾"}
|
: "💾"}
|
||||||
</button>
|
</button>
|
||||||
|
{avatarUrl === null
|
||||||
|
? null
|
||||||
|
: <div
|
||||||
|
className="profile-menu"
|
||||||
|
onBlur={handleProfileBlur}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="profile-avatar-button"
|
||||||
|
onClick={handleToggleProfile}
|
||||||
|
title="Account"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Profile"
|
||||||
|
className="profile-avatar-img"
|
||||||
|
src={avatarUrl}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isProfileOpen
|
||||||
|
? <div className="profile-dropdown">
|
||||||
<a
|
<a
|
||||||
className="profile-link-button"
|
className="profile-dropdown-item"
|
||||||
href={profileUrl}
|
href={profileUrl}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="View your public profile"
|
|
||||||
>
|
>
|
||||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
{"👤 View Profile"}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
className="profile-edit-button"
|
className="profile-dropdown-item"
|
||||||
onClick={onEditProfile}
|
onClick={handleEditProfile}
|
||||||
title="Edit your profile"
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{"✏️"}
|
{"✏️ Edit Profile"}
|
||||||
</button>
|
</button>
|
||||||
|
<hr className="profile-dropdown-divider" />
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://donate.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💜 Donate"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://chat.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💬 Discord"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://support.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"🆘 Support"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{anyFull
|
{anyFull
|
||||||
|
|||||||
@@ -1094,11 +1094,7 @@ export const GameProvider = ({
|
|||||||
return adventurer.unlocked && next.resources.gold >= cost;
|
return adventurer.unlocked && next.resources.gold >= cost;
|
||||||
}).
|
}).
|
||||||
sort((adventurerA, adventurerB) => {
|
sort((adventurerA, adventurerB) => {
|
||||||
const costA
|
return adventurerB.combatPower - adventurerA.combatPower;
|
||||||
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
|
|
||||||
const costB
|
|
||||||
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
|
|
||||||
return costB - costA;
|
|
||||||
});
|
});
|
||||||
if (bestAdventurer !== undefined) {
|
if (bestAdventurer !== undefined) {
|
||||||
const purchaseCost
|
const purchaseCost
|
||||||
@@ -1285,7 +1281,26 @@ export const GameProvider = ({
|
|||||||
if (availableBoss !== undefined) {
|
if (availableBoss !== undefined) {
|
||||||
const { id: bossId, name: bossName } = availableBoss;
|
const { id: bossId, name: bossName } = availableBoss;
|
||||||
isAutoBossingReference.current = true;
|
isAutoBossingReference.current = true;
|
||||||
void challengeBossApi({ bossId }).
|
const syncBeforeBoss
|
||||||
|
= stateReference.current !== null && !isSyncingReference.current
|
||||||
|
? saveGame({
|
||||||
|
state: stateReference.current,
|
||||||
|
...signatureReference.current === null
|
||||||
|
? {}
|
||||||
|
: { signature: signatureReference.current },
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.signature !== undefined) {
|
||||||
|
signatureReference.current = response.signature;
|
||||||
|
localStorage.setItem(
|
||||||
|
"elysium_save_signature",
|
||||||
|
response.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: Promise.resolve();
|
||||||
|
void syncBeforeBoss.then(async() => {
|
||||||
|
return await challengeBossApi({ bossId });
|
||||||
|
}).
|
||||||
then((result) => {
|
then((result) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
|
|||||||
@@ -123,6 +123,78 @@ const capResource = (value: number): number => {
|
|||||||
return Math.min(value, RESOURCE_CAP);
|
return Math.min(value, RESOURCE_CAP);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function — applies one game tick to the state.
|
||||||
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
|
* Returns a new GameState (does not mutate the original).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
||||||
|
* @returns A new GameState with the tick applied.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Computes the effective gold earned per second across all adventurers,
|
||||||
|
* including all active multipliers (upgrades, prestige, equipment, etc.).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns Gold per second as a number.
|
||||||
|
*/
|
||||||
|
export const computeGoldPerSecond = (state: GameState): number => {
|
||||||
|
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
||||||
|
return item.equipped;
|
||||||
|
});
|
||||||
|
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||||
|
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||||
|
}, 1);
|
||||||
|
const setGoldMultiplier = computeSetBonuses(
|
||||||
|
equippedItems.map((item) => {
|
||||||
|
return item.id;
|
||||||
|
}),
|
||||||
|
EQUIPMENT_SETS,
|
||||||
|
).goldMultiplier;
|
||||||
|
|
||||||
|
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||||
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||||
|
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||||
|
const companionBonus = getActiveCompanionBonus(
|
||||||
|
state.companions?.activeCompanionId,
|
||||||
|
state.companions?.unlockedCompanionIds ?? [],
|
||||||
|
);
|
||||||
|
const companionGoldMult
|
||||||
|
= companionBonus?.type === "passiveGold"
|
||||||
|
? 1 + companionBonus.value
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
let goldPerSecond = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const upgradeMultiplier = state.upgrades.
|
||||||
|
filter((upgrade) => {
|
||||||
|
const isGlobal = upgrade.target === "global";
|
||||||
|
const isThisAdventurer
|
||||||
|
= upgrade.target === "adventurer"
|
||||||
|
&& upgrade.adventurerId === adventurer.id;
|
||||||
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||||
|
}).
|
||||||
|
reduce((mult, upgrade) => {
|
||||||
|
return mult * upgrade.multiplier;
|
||||||
|
}, 1);
|
||||||
|
const contribution
|
||||||
|
= adventurer.goldPerSecond
|
||||||
|
* adventurer.count
|
||||||
|
* upgradeMultiplier
|
||||||
|
* state.prestige.productionMultiplier
|
||||||
|
* runestonesIncome
|
||||||
|
* echoIncome
|
||||||
|
* equipmentGoldMultiplier
|
||||||
|
* setGoldMultiplier
|
||||||
|
* craftedGoldMultiplier
|
||||||
|
* companionGoldMult;
|
||||||
|
goldPerSecond = goldPerSecond + contribution;
|
||||||
|
}
|
||||||
|
return goldPerSecond;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function — applies one game tick to the state.
|
* Pure function — applies one game tick to the state.
|
||||||
* DeltaSeconds: time elapsed since last tick.
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
|
|||||||
+124
-43
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user