2 Commits

Author SHA1 Message Date
hikari e7f0a9d537 feat: patch all content stats on sync to keep saves up to date
CI / Lint, Build & Test (pull_request) Failing after 1m3s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
Sync New Content now updates canonical fields on all existing entries
to match current defaults: quests (duration, prerequisites, combat
requirement), bosses (HP, damage, rewards, prestige requirement), zones
(unlock conditions), upgrades (costs, multiplier), equipment (bonus,
cost, set), and achievements (condition, reward). Crafting multipliers
are also recomputed from craftedRecipeIds so recipe balance changes
apply to existing saves.
2026-03-24 15:14:09 -07:00
hikari b275b7878c fix: patch adventurer stats on sync so rebalances apply to existing saves
Sync New Content now updates baseCost, class, combatPower, essencePerSecond,
goldPerSecond, level, and name for all existing adventurer entries to match
the current defaults, while preserving count and unlocked state.

Closes #126
2026-03-24 15:14:09 -07:00
19 changed files with 124 additions and 568 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
-1
View File
@@ -35,7 +35,6 @@ model Player {
lifetimeAchievementsUnlocked Float @default(0) lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String? lastLoginDate String?
loginStreak Int @default(1) loginStreak Int @default(1)
inGuild Boolean @default(false)
} }
model GameState { model GameState {
+4
View File
@@ -1,4 +1,6 @@
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret" DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret" JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url" DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret" ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
@@ -6,4 +8,6 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
-2
View File
@@ -21,7 +21,6 @@ import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js"; import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js"; import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js"; import { transcendenceRouter } from "./routes/transcendence.js";
import { connectGateway } from "./services/gateway.js";
import { logger } from "./services/logger.js"; import { logger } from "./services/logger.js";
const app = new Hono(); const app = new Hono();
@@ -69,7 +68,6 @@ const port = Number(process.env.PORT ?? 3001);
try { try {
serve({ fetch: app.fetch, port: port }, () => { serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`); process.stdout.write(`Elysium API running on port ${String(port)}\n`);
connectGateway();
}); });
} catch (error) { } catch (error) {
void logger.error( void logger.error(
-9
View File
@@ -16,7 +16,6 @@ import {
} from "../services/discord.js"; } from "../services/discord.js";
import { signToken } from "../services/jwt.js"; import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import { grantElysianRole } from "../services/webhook.js";
import type { Player } from "@elysium/types"; import type { Player } from "@elysium/types";
const authRouter = new Hono(); const authRouter = new Hono();
@@ -93,12 +92,6 @@ authRouter.get("/callback", async(context) => {
}, },
}); });
const inGuild = await grantElysianRole(player.discordId);
await prisma.player.update({
data: { inGuild },
where: { discordId: player.discordId },
});
const jwtToken = signToken(player.discordId); const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`); void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId }); void logger.metric("user_registered", 1, { discordId: player.discordId });
@@ -111,12 +104,10 @@ authRouter.get("/callback", async(context) => {
); );
} }
const inGuild = await grantElysianRole(discordUser.id);
const updated = await prisma.player.update({ const updated = await prisma.player.update({
data: { data: {
avatar: discordUser.avatar, avatar: discordUser.avatar,
discriminator: discordUser.discriminator, discriminator: discordUser.discriminator,
inGuild: inGuild,
username: discordUser.username, username: discordUser.username,
}, },
where: { discordId: discordUser.id }, where: { discordId: discordUser.id },
-3
View File
@@ -760,7 +760,6 @@ gameRouter.get("/load", async(context) => {
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion: currentSchemaVersion,
inGuild: playerRecord.inGuild,
loginBonus: null, loginBonus: null,
loginStreak: playerRecord.loginStreak, loginStreak: playerRecord.loginStreak,
offlineEssence: 0, offlineEssence: 0,
@@ -899,10 +898,8 @@ gameRouter.get("/load", async(context) => {
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(state), secret); : computeHmac(JSON.stringify(state), secret);
const inGuild = playerRecord?.inGuild ?? false;
return context.json({ return context.json({
currentSchemaVersion, currentSchemaVersion,
inGuild,
loginBonus, loginBonus,
loginStreak, loginStreak,
offlineEssence, offlineEssence,
+21 -8
View File
@@ -7,9 +7,6 @@
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js"; import { logger } from "./logger.js";
const discordClientId = "1479551654264049908";
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
interface DiscordTokenResponse { interface DiscordTokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
@@ -34,18 +31,24 @@ interface DiscordUser {
const exchangeCode = async( const exchangeCode = async(
code: string, code: string,
): Promise<DiscordTokenResponse> => { ): Promise<DiscordTokenResponse> => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET; const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (clientSecret === undefined || clientSecret === "") { if (
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required"); throw new Error("Discord OAuth environment variables are required");
} }
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
client_id: discordClientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
code: code, code: code,
grant_type: "authorization_code", grant_type: "authorization_code",
redirect_uri: discordRedirectUri, redirect_uri: redirectUri,
}); });
try { try {
@@ -143,9 +146,19 @@ const fetchDiscordUserById = async(
* @throws {Error} If OAuth environment variables are missing. * @throws {Error} If OAuth environment variables are missing.
*/ */
const buildOAuthUrl = (): string => { const buildOAuthUrl = (): string => {
const clientId = process.env.DISCORD_CLIENT_ID;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (
clientId === undefined || clientId === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
client_id: discordClientId, client_id: clientId,
redirect_uri: discordRedirectUri, redirect_uri: redirectUri,
response_type: "code", response_type: "code",
scope: "identify", scope: "identify",
}); });
-182
View File
@@ -1,182 +0,0 @@
/**
* @file Discord Gateway WebSocket client for listening to guild member events.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */
import { prisma } from "../db/client.js";
import { logger } from "./logger.js";
const discordGuildId = "1354624415861833870";
/**
* Discord Gateway opcodes used by this client.
*/
const gatewayOpcodes = {
dispatch: 0,
heartbeat: 1,
heartbeatAck: 11,
hello: 10,
identify: 2,
} as const;
/**
* GUILD_MEMBERS privileged intent bitmask.
*/
/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */
const guildMembersIntent = 1 << 1;
/**
* Updates the inGuild flag for a player when they join the configured guild.
* No-ops silently if the Discord user has no player record.
* @param discordId - The Discord user ID of the member who joined.
* @param guildId - The ID of the guild they joined.
* @returns A promise that resolves when the update attempt completes.
*/
const handleGuildMemberAdd = async(
discordId: string,
guildId: string,
): Promise<void> => {
if (guildId !== discordGuildId) {
return;
}
try {
await prisma.player.updateMany({
data: { inGuild: true },
where: { discordId },
});
} catch (error) {
void logger.error(
"gateway_member_add",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
/**
* Updates the inGuild flag for a player when they leave the configured guild.
* No-ops silently if the Discord user has no player record.
* @param discordId - The Discord user ID of the member who left.
* @param guildId - The ID of the guild they left.
* @returns A promise that resolves when the update attempt completes.
*/
const handleGuildMemberRemove = async(
discordId: string,
guildId: string,
): Promise<void> => {
if (guildId !== discordGuildId) {
return;
}
try {
await prisma.player.updateMany({
data: { inGuild: false },
where: { discordId },
});
} catch (error) {
void logger.error(
"gateway_member_remove",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase
/* v8 ignore next 95 -- @preserve */
/**
* Connects to the Discord Gateway and listens for guild member events.
* Reconnects automatically on close or error.
* Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal.
*/
const connectGateway = (): void => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
void logger.log("info", "Gateway: no bot token configured, skipping");
return;
}
const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json");
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let lastSequence: number | null = null;
const stopHeartbeat = (): void => {
if (heartbeatInterval !== null) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
};
ws.addEventListener("message", (event) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */
const payload = JSON.parse(event.data as string) as {
op: number;
d: unknown;
s: number | null;
t: string | null;
};
if (payload.s !== null) {
lastSequence = payload.s;
}
if (payload.op === gatewayOpcodes.hello) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */
const helloData = payload.d as { heartbeat_interval: number };
const heartbeatMs = helloData.heartbeat_interval;
heartbeatInterval = setInterval(() => {
ws.send(JSON.stringify({
d: lastSequence,
op: gatewayOpcodes.heartbeat,
}));
}, heartbeatMs);
ws.send(JSON.stringify({
d: {
intents: guildMembersIntent,
properties: { browser: "elysium", device: "elysium", os: "linux" },
token: botToken,
},
op: gatewayOpcodes.identify,
}));
}
if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */
const data = payload.d as { user?: { id: string }; guild_id?: string };
const discordId = data.user?.id;
const guildId = data.guild_id;
if (discordId === undefined || guildId === undefined) {
return;
}
if (payload.t === "GUILD_MEMBER_ADD") {
void handleGuildMemberAdd(discordId, guildId);
} else if (payload.t === "GUILD_MEMBER_REMOVE") {
void handleGuildMemberRemove(discordId, guildId);
}
}
});
ws.addEventListener("close", () => {
stopHeartbeat();
void logger.log("info", "Gateway: connection closed, reconnecting in 5s");
setTimeout(connectGateway, 5000);
});
ws.addEventListener("error", (event) => {
const message
= event instanceof ErrorEvent
? event.message
: "WebSocket error";
void logger.error("gateway_error", new Error(message));
stopHeartbeat();
ws.close();
});
};
export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove };
+11 -51
View File
@@ -15,49 +15,6 @@ const discordApi = "https://discord.com/api/v10";
*/ */
const suppressNotifications = 4096; const suppressNotifications = 4096;
/**
* The Discord role ID for the Elysian role granted to all Elysium players.
*/
const discordGuildId = "1354624415861833870";
const elysianRoleId = "1486144823684628490";
const apotheosisRoleId = "1479966598210129991";
/**
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
* Fails silently so role grant errors do not affect the auth flow.
* @param discordId - The Discord user ID to grant the role to.
* @returns True if the player is in the guild and the role was granted, false otherwise.
*/
const grantElysianRole = async(discordId: string): Promise<boolean> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return false;
}
try {
const response = await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
return response.ok || response.status === 204;
} catch (error) {
void logger.error(
"webhook_elysian_role",
error instanceof Error
? error
: new Error(String(error)),
);
return false;
}
};
/** /**
* Grants the apotheosis Discord role to the given player if configured. * Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action. * Fails silently so role grant errors do not affect the game action.
@@ -66,20 +23,23 @@ const grantElysianRole = async(discordId: string): Promise<boolean> => {
*/ */
const grantApotheosisRole = async(discordId: string): Promise<void> => { const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN; const botToken = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
if (botToken === undefined || botToken === "") { if (
botToken === undefined || botToken === ""
|| guildId === undefined || guildId === ""
|| roleId === undefined || roleId === ""
) {
return; return;
} }
try { try {
await fetch( await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`, `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
{ {
headers: { headers: { Authorization: `Bot ${botToken}` },
"Authorization": `Bot ${botToken}`, method: "PUT",
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
}, },
); );
} catch (error) { } catch (error) {
@@ -149,4 +109,4 @@ const postMilestoneWebhook = async(
} }
}; };
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook }; export { grantApotheosisRole, postMilestoneWebhook };
+25 -3
View File
@@ -18,31 +18,51 @@ describe("discord service", () => {
}); });
describe("buildOAuthUrl", () => { describe("buildOAuthUrl", () => {
it("throws when DISCORD_CLIENT_ID is missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
delete process.env["DISCORD_REDIRECT_URI"];
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("returns a URL with correct query params", async () => { it("returns a URL with correct query params", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js"); const { buildOAuthUrl } = await import("../../src/services/discord.js");
const url = buildOAuthUrl(); const url = buildOAuthUrl();
expect(url).toContain("client_id=1479551654264049908"); expect(url).toContain("client_id=client123");
expect(url).toContain("response_type=code"); expect(url).toContain("response_type=code");
expect(url).toContain("scope=identify"); expect(url).toContain("scope=identify");
}); });
}); });
describe("exchangeCode", () => { describe("exchangeCode", () => {
it("throws when DISCORD_CLIENT_SECRET is missing", async () => { it("throws when env vars are missing", async () => {
delete process.env["DISCORD_CLIENT_SECRET"]; delete process.env["DISCORD_CLIENT_ID"];
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required"); await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
}); });
it("throws when response is not ok", async () => { it("throws when response is not ok", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" }); mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed"); await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
}); });
it("returns parsed body on success", async () => { it("returns parsed body on success", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" }; const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
@@ -76,7 +96,9 @@ describe("discord service", () => {
describe("exchangeCode non-Error throw", () => { describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => { it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockRejectedValueOnce("raw string error"); mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
-105
View File
@@ -1,105 +0,0 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { updateMany: vi.fn() },
},
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
log: vi.fn().mockResolvedValue(undefined),
},
}));
import { prisma } from "../../src/db/client.js";
const discordGuildId = "1354624415861833870";
describe("gateway service", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("handleGuildMemberAdd", () => {
it("sets inGuild to true for the matching guild", async () => {
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(prisma.player.updateMany).toHaveBeenCalledWith({
data: { inGuild: true },
where: { discordId: "user123" },
});
});
it("no-ops when guild id does not match the configured guild", async () => {
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
await handleGuildMemberAdd("user123", "other_guild");
expect(prisma.player.updateMany).not.toHaveBeenCalled();
});
it("logs error when prisma throws an Error", async () => {
const dbError = new Error("DB failure");
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
});
it("logs error when prisma throws a non-Error", async () => {
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith(
"gateway_member_add",
new Error("raw error"),
);
});
});
describe("handleGuildMemberRemove", () => {
it("sets inGuild to false for the matching guild", async () => {
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(prisma.player.updateMany).toHaveBeenCalledWith({
data: { inGuild: false },
where: { discordId: "user123" },
});
});
it("no-ops when guild id does not match the configured guild", async () => {
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
await handleGuildMemberRemove("user123", "other_guild");
expect(prisma.player.updateMany).not.toHaveBeenCalled();
});
it("logs error when prisma throws an Error", async () => {
const dbError = new Error("DB failure");
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
});
it("logs error when prisma throws a non-Error", async () => {
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith(
"gateway_member_remove",
new Error("raw error"),
);
});
});
});
+29 -60
View File
@@ -20,20 +20,42 @@ describe("webhook service", () => {
describe("grantApotheosisRole", () => { describe("grantApotheosisRole", () => {
it("does nothing when bot token is missing", async () => { it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"]; delete process.env["DISCORD_BOT_TOKEN"];
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123"); await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
it("calls Discord API with correct URL and auth when bot token is set", async () => { it("does nothing when guild id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
delete process.env["DISCORD_GUILD_ID"];
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when role id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
process.env["DISCORD_GUILD_ID"] = "guild123";
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("calls Discord API with correct URL and auth when env vars are set", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token"; process.env["DISCORD_BOT_TOKEN"] = "bot_token";
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
mockFetch.mockResolvedValueOnce({ ok: true }); mockFetch.mockResolvedValueOnce({ ok: true });
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user789"); await grantApotheosisRole("user789");
expect(mockFetch).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991", "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
expect.objectContaining({ expect.objectContaining({
method: "PUT", method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }), headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}), }),
); );
@@ -41,6 +63,8 @@ describe("webhook service", () => {
it("swallows fetch errors gracefully", async () => { it("swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce(new Error("Network error")); mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
@@ -48,69 +72,14 @@ describe("webhook service", () => {
it("swallows non-Error fetch rejections gracefully", async () => { it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error"); mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
}); });
}); });
describe("grantElysianRole", () => {
it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("returns true when Discord API responds with ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user789");
expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
expect(result).toBe(true);
});
it("returns true when Discord API responds with 204", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(true);
});
it("returns false when Discord API responds with an error status", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows non-Error fetch rejections", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
});
describe("postMilestoneWebhook", () => { describe("postMilestoneWebhook", () => {
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 }; const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -27,7 +27,6 @@ import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js"; import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
import { JoinCommunityModal } from "./joinCommunityModal.js";
import { LoginBonusModal } from "./loginBonusModal.js"; import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js"; import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
@@ -165,7 +164,6 @@ const GameLayout = (): JSX.Element => {
transcendenceCount={state.transcendence?.count ?? 0} transcendenceCount={state.transcendence?.count ?? 0}
/> />
<OfflineModal /> <OfflineModal />
<JoinCommunityModal />
{schemaOutdated && !dismissedOutdatedWarning {schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} /> ? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null} : null}
@@ -1,70 +0,0 @@
/**
* @file Modal prompting players to join the NHCarrigan Discord community.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { useCallback, useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
const sessionKey = "elysium_join_community_dismissed";
/**
* Renders a modal prompting the player to join the NHCarrigan Discord server.
* Shown once per session when the player is not already in the guild.
* @returns The JSX element or null if the player is in the guild or dismissed.
*/
const JoinCommunityModal = (): JSX.Element | null => {
const { inGuild } = useGame();
const [ dismissed, setDismissed ] = useState(
() => {
return sessionStorage.getItem(sessionKey) === "true";
},
);
const handleDismiss = useCallback((): void => {
sessionStorage.setItem(sessionKey, "true");
setDismissed(true);
}, []);
if (inGuild || dismissed) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal">
<h2>{"Join Our Community!"}</h2>
<p>
{"Did you know Elysium has an active Discord community? "}
{"Join to chat with other players, get updates, and earn "}
{"the exclusive Elysian role!"}
</p>
<p className="modal-note">
{"You already earn the Elysian role just by playing — "}
{"joining lets us show it off in the server!"}
</p>
<div className="modal-actions">
<a
className="modal-close-button"
href="https://discord.gg/KKe7BaEnQB"
onClick={handleDismiss}
rel="noreferrer"
target="_blank"
>
{"Join Discord"}
</a>
<button
className="modal-close-button"
onClick={handleDismiss}
type="button"
>
{"Maybe later"}
</button>
</div>
</div>
</div>
);
};
export { JoinCommunityModal };
+30 -63
View File
@@ -243,11 +243,6 @@ interface GameContextValue {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/** /**
* Click the crystal to earn gold. * Click the crystal to earn gold.
*/ */
@@ -585,24 +580,16 @@ interface GameContextValue {
* @returns Counts of what was added per content type. * @returns Counts of what was added per content type.
*/ */
syncNewContent: ()=> Promise<{ syncNewContent: ()=> Promise<{
achievementsAdded: number; achievementsAdded: number;
achievementsPatched: number; adventurersAdded: number;
adventurerStatsPatched: number; bossesAdded: number;
adventurersAdded: number; bossRewardsPatched: number;
bossRewardsPatched: number; equipmentAdded: number;
bossesAdded: number; explorationAreasAdded: number;
bossesPatched: number; questRewardsPatched: number;
craftingRecipesReapplied: number; questsAdded: number;
equipmentAdded: number; upgradesAdded: number;
equipmentPatched: number; zonesAdded: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
}>; }>;
/** /**
@@ -699,7 +686,6 @@ export const GameProvider = ({
const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ inGuild, setInGuild ] = useState(false);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string> Array<string>
>([]); >([]);
@@ -737,7 +723,6 @@ export const GameProvider = ({
setSchemaOutdated(data.schemaOutdated); setSchemaOutdated(data.schemaOutdated);
setSaveSchemaVersion(data.state.schemaVersion ?? 0); setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion); setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild);
// Fetch number format preference from profile (fire-and-forget, non-blocking) // Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`). void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -2185,24 +2170,16 @@ export const GameProvider = ({
localStorage.setItem("elysium_save_signature", data.signature); localStorage.setItem("elysium_save_signature", data.signature);
} }
return { return {
achievementsAdded: data.achievementsAdded, achievementsAdded: data.achievementsAdded,
achievementsPatched: data.achievementsPatched, adventurersAdded: data.adventurersAdded,
adventurerStatsPatched: data.adventurerStatsPatched, bossRewardsPatched: data.bossRewardsPatched,
adventurersAdded: data.adventurersAdded, bossesAdded: data.bossesAdded,
bossRewardsPatched: data.bossRewardsPatched, equipmentAdded: data.equipmentAdded,
bossesAdded: data.bossesAdded, explorationAreasAdded: data.explorationAreasAdded,
bossesPatched: data.bossesPatched, questRewardsPatched: data.questRewardsPatched,
craftingRecipesReapplied: data.craftingRecipesReapplied, questsAdded: data.questsAdded,
equipmentAdded: data.equipmentAdded, upgradesAdded: data.upgradesAdded,
equipmentPatched: data.equipmentPatched, zonesAdded: data.zonesAdded,
explorationAreasAdded: data.explorationAreasAdded,
questRewardsPatched: data.questRewardsPatched,
questsAdded: data.questsAdded,
questsPatched: data.questsPatched,
upgradesAdded: data.upgradesAdded,
upgradesPatched: data.upgradesPatched,
zonesAdded: data.zonesAdded,
zonesPatched: data.zonesPatched,
}; };
} catch (error_: unknown) { } catch (error_: unknown) {
setError( setError(
@@ -2211,24 +2188,16 @@ export const GameProvider = ({
: "Failed to sync new content", : "Failed to sync new content",
); );
return { return {
achievementsAdded: 0, achievementsAdded: 0,
achievementsPatched: 0, adventurersAdded: 0,
adventurerStatsPatched: 0, bossRewardsPatched: 0,
adventurersAdded: 0, bossesAdded: 0,
bossRewardsPatched: 0, equipmentAdded: 0,
bossesAdded: 0, explorationAreasAdded: 0,
bossesPatched: 0, questRewardsPatched: 0,
craftingRecipesReapplied: 0, questsAdded: 0,
equipmentAdded: 0, upgradesAdded: 0,
equipmentPatched: 0, zonesAdded: 0,
explorationAreasAdded: 0,
questRewardsPatched: 0,
questsAdded: 0,
questsPatched: 0,
upgradesAdded: 0,
upgradesPatched: 0,
zonesAdded: 0,
zonesPatched: 0,
}; };
} }
}, []); }, []);
@@ -2310,7 +2279,6 @@ export const GameProvider = ({
forceUnlocks, forceUnlocks,
formatNumber, formatNumber,
handleClick, handleClick,
inGuild,
isLoading, isLoading,
isSyncing, isSyncing,
lastSavedAt, lastSavedAt,
@@ -2382,7 +2350,6 @@ export const GameProvider = ({
error, error,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
inGuild,
forceUnlocks, forceUnlocks,
handleClick, handleClick,
isLoading, isLoading,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
-5
View File
@@ -70,11 +70,6 @@ interface LoginBonusResult {
interface LoadResponse { interface LoadResponse {
state: GameState; state: GameState;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/** /**
* Offline gold earned since last save (server-calculated). * Offline gold earned since last save (server-calculated).
*/ */