chore: community feedback fixes and UI improvements (#102)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s

## 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:
2026-03-23 16:07:25 -07:00
committed by Naomi Carrigan
parent 7bd6b2d3e3
commit 3ac1d566cb
13 changed files with 698 additions and 191 deletions
+45 -33
View File
@@ -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,
}, },
+2 -2
View File
@@ -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",
}, },
{ {
+3
View File
@@ -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",
+43 -1
View File
@@ -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,
+26 -1
View File
@@ -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();
+35 -1
View File
@@ -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 };
+73
View File
@@ -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", () => {
+49
View File
@@ -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}
+155 -52
View File
@@ -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
+21 -6
View File
@@ -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) {
+72
View File
@@ -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
View File
@@ -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;