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",
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
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<Adventurer> = [
|
||||
essencePerSecond: 7_000_000_000,
|
||||
goldPerSecond: 50_000_000_000_000,
|
||||
id: "omniversal_champion",
|
||||
level: 32,
|
||||
level: 33,
|
||||
name: "Omniversal Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
@@ -245,7 +245,7 @@ export const defaultBosses: Array<Boss> = [
|
||||
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<Boss> = [
|
||||
name: "The Magma Titan",
|
||||
prestigeRequirement: 0,
|
||||
status: "locked",
|
||||
upgradeRewards: [ "crystal_resonance" ],
|
||||
upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -183,6 +183,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
{ 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<Quest> = [
|
||||
{ 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<Quest> = [
|
||||
{ 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",
|
||||
|
||||
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
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<Upgrade> = [
|
||||
{
|
||||
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<Upgrade> = [
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
* @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 };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user