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
+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 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", () => {