diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 299135d..20a0aba 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -128,18 +128,6 @@ export const defaultAdventurers: Array = [ name: "Dragon Rider", 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, class: "mage", @@ -148,10 +136,34 @@ export const defaultAdventurers: Array = [ essencePerSecond: 15, goldPerSecond: 14_000, id: "arcane_scholar", - level: 12, + level: 11, name: "Arcane Scholar", 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, class: "rogue", @@ -160,7 +172,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 35, goldPerSecond: 40_000, id: "void_walker", - level: 13, + level: 14, name: "Void Walker", unlocked: false, }, @@ -172,7 +184,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 100, goldPerSecond: 120_000, id: "celestial_guard", - level: 14, + level: 15, name: "Celestial Guard", unlocked: false, }, @@ -184,7 +196,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 300, goldPerSecond: 400_000, id: "divine_champion", - level: 15, + level: 16, name: "Divine Champion", unlocked: false, }, @@ -196,7 +208,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 800, goldPerSecond: 1_200_000, id: "seraph_knight", - level: 16, + level: 17, name: "Seraph Knight", unlocked: false, }, @@ -208,7 +220,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 2000, goldPerSecond: 3_500_000, id: "abyss_diver", - level: 17, + level: 18, name: "Abyss Diver", unlocked: false, }, @@ -220,7 +232,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 5000, goldPerSecond: 10_000_000, id: "infernal_warden", - level: 18, + level: 19, name: "Infernal Warden", unlocked: false, }, @@ -232,7 +244,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 12_000, goldPerSecond: 30_000_000, id: "crystal_sage", - level: 19, + level: 20, name: "Crystal Sage", unlocked: false, }, @@ -244,7 +256,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 30_000, goldPerSecond: 90_000_000, id: "void_sentinel", - level: 20, + level: 21, name: "Void Sentinel", unlocked: false, }, @@ -256,7 +268,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 80_000, goldPerSecond: 270_000_000, id: "eternal_champion", - level: 21, + level: 22, name: "Eternal Champion", unlocked: false, }, @@ -268,7 +280,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 220_000, goldPerSecond: 800_000_000, id: "aether_weaver", - level: 22, + level: 23, name: "Aether Weaver", unlocked: false, }, @@ -280,7 +292,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 600_000, goldPerSecond: 2_500_000_000, id: "titan_warrior", - level: 23, + level: 24, name: "Titan Warrior", unlocked: false, }, @@ -292,7 +304,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 1_600_000, goldPerSecond: 7_500_000_000, id: "nexus_sage", - level: 24, + level: 25, name: "Nexus Sage", unlocked: false, }, @@ -304,7 +316,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 4_500_000, goldPerSecond: 22_000_000_000, id: "cosmos_knight", - level: 25, + level: 26, name: "Cosmos Knight", unlocked: false, }, @@ -316,7 +328,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 12_000_000, goldPerSecond: 65_000_000_000, id: "astral_sovereign", - level: 26, + level: 27, name: "Astral Sovereign", unlocked: false, }, @@ -328,7 +340,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 35_000_000, goldPerSecond: 200_000_000_000, id: "primordial_mage", - level: 27, + level: 28, name: "Primordial Mage", unlocked: false, }, @@ -340,7 +352,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 100_000_000, goldPerSecond: 600_000_000_000, id: "reality_warden", - level: 28, + level: 29, name: "Reality Warden", unlocked: false, }, @@ -352,7 +364,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 300_000_000, goldPerSecond: 1_800_000_000_000, id: "infinity_ranger", - level: 29, + level: 30, name: "Infinity Ranger", unlocked: false, }, @@ -364,7 +376,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 850_000_000, goldPerSecond: 5_500_000_000_000, id: "oblivion_paladin", - level: 30, + level: 31, name: "Oblivion Paladin", unlocked: false, }, @@ -376,7 +388,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 2_500_000_000, goldPerSecond: 16_000_000_000_000, id: "transcendent_rogue", - level: 31, + level: 32, name: "Transcendent Rogue", unlocked: false, }, @@ -388,7 +400,7 @@ export const defaultAdventurers: Array = [ essencePerSecond: 7_000_000_000, goldPerSecond: 50_000_000_000_000, id: "omniversal_champion", - level: 32, + level: 33, name: "Omniversal Champion", unlocked: false, }, diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 94d00d1..324554e 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -245,7 +245,7 @@ export const defaultBosses: Array = [ name: "The Ancient Fire Elemental", prestigeRequirement: 0, status: "locked", - upgradeRewards: [ "celestial_guard_1" ], + upgradeRewards: [ "dark_templar_1" ], zoneId: "volcanic_depths", }, { @@ -263,7 +263,7 @@ export const defaultBosses: Array = [ name: "The Magma Titan", prestigeRequirement: 0, status: "locked", - upgradeRewards: [ "crystal_resonance" ], + upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ], zoneId: "volcanic_depths", }, { diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 47a497c..b33b3a0 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -183,6 +183,7 @@ export const defaultQuests: Array = [ { amount: 1500, type: "essence" }, { amount: 75, type: "crystals" }, { targetId: "knight_1", type: "upgrade" }, + { targetId: "peasant_2", type: "upgrade" }, ], status: "locked", zoneId: "shadow_marshes", @@ -199,6 +200,7 @@ export const defaultQuests: Array = [ { amount: 8_000_000, type: "gold" }, { amount: 2000, type: "essence" }, { amount: 150, type: "crystals" }, + { targetId: "dark_templar", type: "adventurer" }, ], status: "locked", zoneId: "shadow_marshes", @@ -281,6 +283,7 @@ export const defaultQuests: Array = [ { amount: 40_000_000, type: "gold" }, { amount: 12_000, type: "essence" }, { amount: 300, type: "crystals" }, + { targetId: "peasant_3", type: "upgrade" }, ], status: "locked", zoneId: "volcanic_depths", diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 17fc9bc..1f06343 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -162,6 +162,34 @@ export const defaultUpgrades: Array = [ target: "adventurer", unlocked: false, }, + { + adventurerId: "peasant", + costCrystals: 0, + costEssence: 20, + costGold: 0, + description: + "Organised labour guilds and proper scheduling make peasants ten times more productive.", + id: "peasant_2", + multiplier: 10, + name: "Guild Organisation", + purchased: false, + target: "adventurer", + unlocked: false, + }, + { + adventurerId: "peasant", + costCrystals: 50, + costEssence: 0, + costGold: 0, + description: + "Magical augmentation through crystalline resonance supercharges even the humblest worker.", + id: "peasant_3", + multiplier: 50, + name: "Crystal Augmentation", + purchased: false, + target: "adventurer", + unlocked: false, + }, { adventurerId: "militia", costCrystals: 0, @@ -272,7 +300,7 @@ export const defaultUpgrades: Array = [ { adventurerId: "shadow_assassin", costCrystals: 0, - costEssence: 50, + costEssence: 175, costGold: 0, description: "Mastery of the shadow arts doubles assassin effectiveness.", id: "shadow_assassin_1", @@ -295,6 +323,20 @@ export const defaultUpgrades: Array = [ target: "adventurer", 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", costCrystals: 0, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index d83e046..f6affe1 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; +import { fetchDiscordUserById } from "../services/discord.js"; import { logger } from "../services/logger.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { @@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => { try { const discordId = context.get("discordId"); - const [ record, playerRecord ] = await Promise.all([ - prisma.gameState.findUnique({ where: { discordId } }), - prisma.player.findUnique({ where: { discordId } }), + const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([ + Promise.all([ + prisma.gameState.findUnique({ where: { discordId } }), + prisma.player.findUnique({ where: { discordId } }), + ]), + fetchDiscordUserById(discordId), ]); + // Refresh avatar in DB when Discord returns an updated hash + if ( + freshDiscordUser !== null + && playerRecord !== null + && freshDiscordUser.avatar !== playerRecord.avatar + ) { + playerRecord.avatar = freshDiscordUser.avatar; + void prisma.player.update({ + data: { avatar: freshDiscordUser.avatar }, + where: { discordId }, + }).catch((error: unknown) => { + void logger.error( + "avatar_refresh", + error instanceof Error + ? error + : new Error(String(error)), + ); + }); + } + if (!record) { // No save found — create a fresh state (handles nuked DB or first-time load race) if (!playerRecord) { @@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => { */ if (playerRecord !== null) { state.player.characterName = playerRecord.characterName; + state.player.avatar = playerRecord.avatar; } const now = Date.now(); diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index ac37348..8b82ae8 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -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 => { + 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); + } catch (error) { + void logger.error( + "discord_fetch_user_by_id", + error instanceof Error + ? error + : new Error(String(error)), + ); + return null; + } +}; + /** * Builds the Discord OAuth authorisation URL. * @returns The full OAuth URL to redirect the user to. @@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => { }; export type { DiscordTokenResponse, DiscordUser }; -export { buildOAuthUrl, exchangeCode, fetchDiscordUser }; +export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById }; diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index b469ecf..5638328 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({ }), })); +vi.mock("../../src/services/discord.js", () => ({ + fetchDiscordUserById: vi.fn().mockResolvedValue(null), +})); + const DISCORD_ID = "test_discord_id"; const CURRENT_SCHEMA_VERSION = 1; @@ -200,6 +204,75 @@ describe("game route", () => { expect(body.offlineGold).toBeGreaterThan(0); expect(body.offlineEssence).toBeGreaterThan(0); }); + + it("syncs updated avatar from Discord into the returned state", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.player.avatar).toBe("new_hash"); + }); + + it("continues loading when the avatar DB update fails", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error")); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + }); + + it("continues loading when the avatar DB update fails with a non-Error value", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error"); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + }); + + it("keeps stored avatar when Discord returns null", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.player.avatar).toBe("stored_hash"); + }); }); describe("POST /save", () => { diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index 5ca4e97..cf924ae 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -104,4 +104,53 @@ describe("discord service", () => { await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); }); }); + + describe("fetchDiscordUserById", () => { + it("returns null when DISCORD_BOT_TOKEN is missing", async () => { + delete process.env["DISCORD_BOT_TOKEN"]; + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when DISCORD_BOT_TOKEN is empty", async () => { + process.env["DISCORD_BOT_TOKEN"] = ""; + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when response is not ok", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" }); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockRejectedValueOnce(new Error("network error")); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws a non-Error value", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns the user on success", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" }; + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) }); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toMatchObject({ id: "123456", avatar: "abc123" }); + }); + }); }); diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 23819e6..37162da 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -135,7 +135,6 @@ const GameLayout = (): JSX.Element => { ); } - const profileUrl = `/profile/${state.player.discordId}`; const codexBadgeCount = pendingCodexEntryIds.length; const storyBadgeCount = pendingStoryChapterIds.length; @@ -160,7 +159,6 @@ const GameLayout = (): JSX.Element => { onEditProfile={handleOpenEditProfile} onForceSync={forceSync} prestigeCount={state.prestige.count} - profileUrl={profileUrl} resources={state.resources} runestones={state.prestige.runestones} transcendenceCount={state.transcendence?.count ?? 0} diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index 0a20d27..cb76ba0 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -4,12 +4,14 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +/* eslint-disable max-lines -- Resource bar has many resource and action elements */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ +/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */ /* eslint-disable complexity -- Many conditional resource and badge render paths */ +import { useState, type FocusEvent, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; -import { RESOURCE_CAP } from "../../engine/tick.js"; +import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; import type { Resource } from "@elysium/types"; -import type { JSX } from "react"; interface ResourceBarProperties { readonly resources: Resource; @@ -17,7 +19,6 @@ interface ResourceBarProperties { readonly prestigeCount: number; readonly transcendenceCount: number; readonly apotheosisCount: number; - readonly profileUrl: string; readonly onEditProfile: ()=> void; readonly lastSavedAt: number | null; readonly isSyncing: boolean; @@ -58,7 +59,6 @@ const resourceFullTooltip = [ * @param props.prestigeCount - The number of prestiges completed. * @param props.transcendenceCount - The number of transcendences completed. * @param props.apotheosisCount - The number of apotheoses completed. - * @param props.profileUrl - The URL of the player's public profile. * @param props.onEditProfile - Callback to open the edit profile modal. * @param props.lastSavedAt - Timestamp of the last cloud save. * @param props.isSyncing - Whether a sync is currently in progress. @@ -71,84 +71,168 @@ const ResourceBar = ({ prestigeCount, transcendenceCount, apotheosisCount, - profileUrl, onEditProfile, lastSavedAt, isSyncing, onForceSync, }: ResourceBarProperties): JSX.Element => { const { formatNumber, syncError, state } = useGame(); + const [ isProfileOpen, setIsProfileOpen ] = useState(false); + const [ isResourcesOpen, setIsResourcesOpen ] = useState(false); + const { gold, essence, crystals } = resources; let partyCombatPower = 0; + let goldPerSecond = 0; if (state !== null) { for (const adventurer of state.adventurers) { const contribution = adventurer.combatPower * adventurer.count; partyCombatPower = partyCombatPower + contribution; } + goldPerSecond = computeGoldPerSecond(state); } - const resourceValues = [ gold, essence, crystals ]; - const anyFull = resourceValues.some((v) => { - return v >= RESOURCE_CAP; - }); + + let avatarUrl: string | null = null; + if (state !== null) { + avatarUrl = state.player.avatar === null + ? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png` + : `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`; + } + const profileUrl = state === null + ? "#" + : `/profile/${state.player.discordId}`; + const goldFull = gold >= RESOURCE_CAP; const essenceFull = essence >= RESOURCE_CAP; const crystalsFull = crystals >= RESOURCE_CAP; + const anyFull = goldFull || essenceFull || crystalsFull; + const hiddenResourcesFull = essenceFull || crystalsFull; function handleForceSync(): void { void onForceSync(); } + function handleToggleResources(): void { + setIsResourcesOpen((previous) => { + return !previous; + }); + } + + function handleResourceBlur(event: FocusEvent): void { + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsResourcesOpen(false); + } + } + + function handleToggleProfile(): void { + setIsProfileOpen((previous) => { + return !previous; + }); + } + + function handleProfileBlur(event: FocusEvent): void { + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsProfileOpen(false); + } + } + + function handleEditProfile(): void { + setIsProfileOpen(false); + onEditProfile(); + } + return ( <>
-
- {"🪙"} - {formatNumber(gold)} - {"Gold"} - {goldFull - ? - {"FULL"} - +
+ + {isResourcesOpen + ?
+
+ {"📈"} + + {formatNumber(goldPerSecond)} + + {"Gold/s"} +
+
+ {"✨"} + + {formatNumber(essence)} + + {"Essence"} + {essenceFull + ? + {"FULL"} + + : null} +
+
+ {"💎"} + + {formatNumber(crystals)} + + {"Crystals"} + {crystalsFull + ? + {"FULL"} + + : null} +
+
+ {"🔮"} + + {formatNumber(runestones)} + + {"Runestones"} +
+
+ {"⚔️"} + + {formatNumber(partyCombatPower)} + + {"Combat Power"} +
+
: null}
-
- {"✨"} - {formatNumber(essence)} - {"Essence"} - {essenceFull - ? - {"FULL"} - - : null} -
-
- {"💎"} - {formatNumber(crystals)} - {"Crystals"} - {crystalsFull - ? - {"FULL"} - - : null} -
-
- {"🔮"} - {formatNumber(runestones)} - {"Runestones"} -
-
- {"⚔️"} - - {formatNumber(partyCombatPower)} - - {"Combat Power"} -
{apotheosisCount > 0 &&
{"✨ Apotheosis "} @@ -167,34 +251,7 @@ const ResourceBar = ({ {prestigeCount}
} -
- - {"💜"} {"Donate"} - - - {"💬"} {"Discord"} - - - {"🆘"} {"Support"} - +
{syncError === null ? null : @@ -221,23 +278,69 @@ const ResourceBar = ({ ? "⏳" : "💾"} - - {"👤"} {"Profile"} - - + {avatarUrl === null + ? null + :
+ + {isProfileOpen + ? + : null} +
}
{anyFull diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 70a3001..0441ed0 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1094,11 +1094,7 @@ export const GameProvider = ({ return adventurer.unlocked && next.resources.gold >= cost; }). sort((adventurerA, adventurerB) => { - const costA - = adventurerA.baseCost * Math.pow(1.15, adventurerA.count); - const costB - = adventurerB.baseCost * Math.pow(1.15, adventurerB.count); - return costB - costA; + return adventurerB.combatPower - adventurerA.combatPower; }); if (bestAdventurer !== undefined) { const purchaseCost @@ -1285,7 +1281,26 @@ export const GameProvider = ({ if (availableBoss !== undefined) { const { id: bossId, name: bossName } = availableBoss; isAutoBossingReference.current = true; - void challengeBossApi({ bossId }). + const syncBeforeBoss + = stateReference.current !== null && !isSyncingReference.current + ? saveGame({ + state: stateReference.current, + ...signatureReference.current === null + ? {} + : { signature: signatureReference.current }, + }).then((response) => { + if (response.signature !== undefined) { + signatureReference.current = response.signature; + localStorage.setItem( + "elysium_save_signature", + response.signature, + ); + } + }) + : Promise.resolve(); + void syncBeforeBoss.then(async() => { + return await challengeBossApi({ bossId }); + }). then((result) => { setState((previous) => { if (previous === null) { diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 4bf4acf..c80f8e5 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -123,6 +123,78 @@ const capResource = (value: number): number => { return Math.min(value, RESOURCE_CAP); }; +/** + * Pure function — applies one game tick to the state. + * DeltaSeconds: time elapsed since last tick. + * Returns a new GameState (does not mutate the original). + * @param state - The current game state. + * @param deltaSeconds - Time elapsed since last tick in seconds. + * @returns A new GameState with the tick applied. + */ +/** + * Computes the effective gold earned per second across all adventurers, + * including all active multipliers (upgrades, prestige, equipment, etc.). + * @param state - The current game state. + * @returns Gold per second as a number. + */ +export const computeGoldPerSecond = (state: GameState): number => { + const equippedItems: Array = state.equipment.filter((item) => { + return item.equipped; + }); + const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => { + return mult * (item.bonus.goldMultiplier ?? 1); + }, 1); + const setGoldMultiplier = computeSetBonuses( + equippedItems.map((item) => { + return item.id; + }), + EQUIPMENT_SETS, + ).goldMultiplier; + + const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1; + const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1; + const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1; + const companionBonus = getActiveCompanionBonus( + state.companions?.activeCompanionId, + state.companions?.unlockedCompanionIds ?? [], + ); + const companionGoldMult + = companionBonus?.type === "passiveGold" + ? 1 + companionBonus.value + : 1; + + let goldPerSecond = 0; + for (const adventurer of state.adventurers) { + if (!adventurer.unlocked || adventurer.count === 0) { + continue; + } + const upgradeMultiplier = state.upgrades. + filter((upgrade) => { + const isGlobal = upgrade.target === "global"; + const isThisAdventurer + = upgrade.target === "adventurer" + && upgrade.adventurerId === adventurer.id; + return upgrade.purchased && (isGlobal || isThisAdventurer); + }). + reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); + const contribution + = adventurer.goldPerSecond + * adventurer.count + * upgradeMultiplier + * state.prestige.productionMultiplier + * runestonesIncome + * echoIncome + * equipmentGoldMultiplier + * setGoldMultiplier + * craftedGoldMultiplier + * companionGoldMult; + goldPerSecond = goldPerSecond + contribution; + } + return goldPerSecond; +}; + /** * Pure function — applies one game tick to the state. * DeltaSeconds: time elapsed since last tick. diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4d0ed2c..42fe2c5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -116,6 +116,66 @@ body::before { text-align: center; } +/* ── Resource toggle + dropdown ─────────────────────────────────────────── */ + +.resource-menu { + position: relative; +} + +.resource-toggle { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 0.5rem; + color: inherit; + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0.3rem 0.6rem; + position: relative; + transition: background 0.2s, border-color 0.2s; +} + +.resource-toggle:hover { + background: rgba(147, 51, 234, 0.2); + border-color: var(--colour-primary); +} + +.resource-alert-dot { + background: var(--colour-warning, #f59e0b); + border-radius: 50%; + height: 0.45rem; + position: absolute; + right: 0; + top: 0; + width: 0.45rem; +} + +.resources-dropdown { + background: var(--colour-surface); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: 0.1rem; + left: 0; + padding: 0.4rem; + position: absolute; + top: calc(100% + 0.4rem); + z-index: 100; +} + +.resources-dropdown .resource { + border-radius: 0.35rem; + gap: 0.5rem; + padding: 0.3rem 0.5rem; + white-space: nowrap; +} + +.resources-dropdown .resource:hover { + background: rgba(255, 255, 255, 0.04); +} + /* ===================== GAME LAYOUT ===================== */ .game-layout { display: flex; @@ -1492,57 +1552,87 @@ body::before { font-size: 0.85rem; } -/* ── Profile buttons in ResourceBar ────────────────────────────────────── */ +/* ── Resource bar actions (save + profile menu) ─────────────────────────── */ -.profile-buttons { +.resource-bar-actions { align-items: center; display: flex; gap: 0.35rem; margin-left: auto; } -.profile-link-button { - align-items: center; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(147, 51, 234, 0.4); - border-radius: 1rem; - color: var(--colour-text-muted); - display: flex; - font-size: 0.8rem; - gap: 0.3rem; - padding: 0.3rem 0.8rem; - text-decoration: none; - transition: all 0.2s; - white-space: nowrap; +.profile-menu { + position: relative; } -.profile-link-button:hover { - background: rgba(147, 51, 234, 0.2); - border-color: var(--colour-primary); - color: var(--colour-text); -} - -.profile-edit-button { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(147, 51, 234, 0.4); +.profile-avatar-button { + background: none; + border: 2px solid rgba(147, 51, 234, 0.4); border-radius: 50%; - color: var(--colour-text-muted); cursor: pointer; - font-family: inherit; - font-size: 0.85rem; + display: flex; height: 2rem; - line-height: 1; + overflow: hidden; padding: 0; - transition: all 0.2s; + transition: border-color 0.2s; width: 2rem; } -.profile-edit-button:hover { - background: rgba(147, 51, 234, 0.2); +.profile-avatar-button:hover { border-color: var(--colour-primary); +} + +.profile-avatar-img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.profile-dropdown { + background: var(--colour-surface); + border: 1px solid rgba(147, 51, 234, 0.4); + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + min-width: 10rem; + padding: 0.25rem; + position: absolute; + right: 0; + top: calc(100% + 0.4rem); + z-index: 100; +} + +.profile-dropdown-item { + align-items: center; + background: none; + border: none; + border-radius: 0.35rem; + color: var(--colour-text-muted); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: 0.85rem; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + text-align: left; + text-decoration: none; + transition: background 0.15s, color 0.15s; + white-space: nowrap; + width: 100%; +} + +.profile-dropdown-item:hover { + background: rgba(147, 51, 234, 0.15); color: var(--colour-text); } +.profile-dropdown-divider { + border: none; + border-top: 1px solid rgba(147, 51, 234, 0.2); + margin: 0.25rem 0; +} + .save-status { color: var(--colour-text-muted); font-size: 0.75rem; @@ -3167,10 +3257,10 @@ body::before { display: none; } - /* Profile buttons fill their own row, aligned right */ - .profile-buttons { - margin-left: 0; + /* Resource bar actions fill their own row, aligned right */ + .resource-bar-actions { justify-content: flex-end; + margin-left: 0; width: 100%; } @@ -3240,15 +3330,6 @@ body::before { /* --- Small mobile (≤ 480px) --------------------------- */ @media (max-width: 480px) { - /* Icon-only profile link buttons to save horizontal space */ - .btn-label { - display: none; - } - - .profile-link-button { - padding: 0.3rem 0.5rem; - } - /* Slightly smaller tab buttons */ .tab-button { font-size: 0.8rem;