generated from nhcarrigan/template
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eec93e442b
|
|||
|
9926e7f639
|
|||
| 6bf1ac5e7d |
@@ -0,0 +1,135 @@
|
||||
# Vampire Expansion — Implementation TODO
|
||||
|
||||
Branch: `feat/expansions`
|
||||
|
||||
Thematic currency names:
|
||||
- Gold → **Blood**
|
||||
- Essence → **Ichor**
|
||||
- Crystals → **Soul Shards**
|
||||
- Runestones → **Bloodstones**
|
||||
- Echoes → **Whispers**
|
||||
- Click action → **Hunt**
|
||||
- Adventurers → **Thralls**
|
||||
- Prestige → **Siring** (working name)
|
||||
- Transcendence → **The Awakening** (working name)
|
||||
- Apotheosis → **Eternal Sovereignty** (role ID: 1486144657023959180)
|
||||
|
||||
CDN prefix for all vampire art: `https://cdn.nhcarrigan.com/elysium/vampire/<folder>/<id>.jpg`
|
||||
Local scratch dir (delete before committing): `img/vampire/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Types
|
||||
|
||||
- [ ] Add `VampireExpansionState` interface to `packages/types/src/interfaces/` mirroring full `GameState` structure (zones, bosses, quests, adventurers, upgrades, equipment, achievements, prestige, transcendence, apotheosis, exploration, resources, baseClickPower, lastTickAt, dailyChallenges, codex, autoQuest, autoBoss, autoAdventurer, companions, story)
|
||||
- [ ] Add `ExpansionsState` interface: `{ vampire?: VampireExpansionState }`
|
||||
- [ ] Add `expansions?: ExpansionsState` field to `GameState`
|
||||
- [ ] Export new types from `packages/types/src/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Data files (vampire content)
|
||||
|
||||
All data files go in `apps/api/src/data/vampire/`.
|
||||
Same content scale as base game; use vampire theming throughout.
|
||||
|
||||
- [ ] `zones.ts` — 18 vampire-themed zones (crypts, blood forests, cursed castles, etc.)
|
||||
- [ ] `bosses.ts` — 72 vampire-themed bosses (4 per zone)
|
||||
- [ ] `quests.ts` — match base game quest count (~95); vampire-themed names/descriptions
|
||||
- [ ] `adventurers.ts` — 32 thrall tiers with progressive stats
|
||||
- [ ] `upgrades.ts` — match base game upgrade count (~57); vampire-themed
|
||||
- [ ] `equipment.ts` — match base game equipment count (~53); vampire-themed sets
|
||||
- [ ] `equipmentSets.ts` — vampire equipment sets
|
||||
- [ ] `achievements.ts` — match base game count (~40); vampire-themed conditions
|
||||
- [ ] `explorations.ts` — 72 areas across 18 vampire lore zones
|
||||
- [ ] `materials.ts` — match base game material count (~54); vampire-themed
|
||||
- [ ] `recipes.ts` — match base game recipe count (~36); vampire-themed
|
||||
- [ ] `prestigeUpgrades.ts` — 25 "Siring" upgrades
|
||||
- [ ] `transcendenceUpgrades.ts` — 15 "Awakening" upgrades
|
||||
- [ ] `dailyChallenges.ts` — 10 vampire daily challenges
|
||||
- [ ] `initialState.ts` — `initialVampireState()` function mirroring `initialGameState` structure
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Art generation & CDN upload
|
||||
|
||||
For each category below, generate images via Gemini API (`gemini-3-pro-image-preview`),
|
||||
save locally to `img/vampire/<folder>/`, upload to R2, then delete local files.
|
||||
|
||||
Use soft-shaded anime style; vampire/gothic aesthetic; crimson/black/dark purple palette.
|
||||
|
||||
- [ ] Zone banners (18) → `img/vampire/zones/` → CDN `vampire/zones/`
|
||||
- [ ] Boss portraits (72) → `img/vampire/bosses/` → CDN `vampire/bosses/`
|
||||
- [ ] Quest banners (match count) → `img/vampire/quests/` → CDN `vampire/quests/`
|
||||
- [ ] Adventurer/thrall portraits (32) → `img/vampire/adventurers/` → CDN `vampire/adventurers/`
|
||||
- [ ] Equipment icons (match count) → `img/vampire/equipment/` → CDN `vampire/equipment/`
|
||||
- [ ] Achievement icons (match count) → `img/vampire/achievements/` → CDN `vampire/achievements/`
|
||||
- [ ] Exploration area art (72) → `img/vampire/explorations/` → CDN `vampire/explorations/`
|
||||
- [ ] Material icons (match count) → `img/vampire/materials/` → CDN `vampire/materials/`
|
||||
- [ ] Story chapter banners (match count) → `img/vampire/story-chapters/` → CDN `vampire/story-chapters/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — API changes
|
||||
|
||||
- [ ] Add `inGuild` to Prisma `Player` model → update `initialGameState` if needed (already done in #134 — verify migration)
|
||||
- [ ] Update Prisma schema: no DB changes needed (expansion state is inside the `GameState` JSON blob)
|
||||
- [ ] Update `initialState.ts` to include `expansions: {}` in `initialGameState`
|
||||
- [ ] Update `sync-new-content` debug route to inject/patch vampire expansion content when expansion is unlocked
|
||||
- [ ] Add vampire-specific unlock trigger: when base-game apotheosis count ≥ 1, set `expansions.vampire` to `initialVampireState()` and `unlocked: true`
|
||||
- [ ] Update the load endpoint to pass expansion state through to the client
|
||||
- [ ] Ensure prestige/transcendence/apotheosis routes only reset state for their own expansion (base game routes must NOT touch `expansions.*`)
|
||||
- [ ] Add vampire prestige, transcendence, and apotheosis routes (mirrors of base game routes, scoped to `expansions.vampire`)
|
||||
- [ ] Grant `Eternal Sovereignty` role (ID: `1486144657023959180`) on vampire apotheosis
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Frontend changes
|
||||
|
||||
### Expansion switcher
|
||||
- [ ] Add expansion toggle buttons below the Early Access warning in the sidebar
|
||||
- [ ] Always render all expansion buttons; disable any where `unlocked !== true`
|
||||
- [ ] Active expansion stored in React state (not game state); defaults to `"base"`
|
||||
- [ ] Switching expansion updates which data the UI panels display
|
||||
|
||||
### Resource bar
|
||||
- [ ] Show ALL currencies from ALL expansions as separate labelled lines
|
||||
- [ ] Vampire currencies use distinct icons/colours (crimson tint for blood, etc.)
|
||||
- [ ] The "expand" button label shows the gold-equivalent currency of the active expansion
|
||||
|
||||
### Thematic UI
|
||||
- [ ] When vampire expansion is active, swap labels: gold → Blood, essence → Ichor, etc.
|
||||
- [ ] Apply `.vampire-mode` CSS class to game container when vampire is active
|
||||
- [ ] Vampire colour palette: deep crimsons (`#5C0A1A`), rich crimson (`#C41E3A`), blacks, desaturated purples
|
||||
|
||||
### Tick engine
|
||||
- [ ] Update `apps/web/src/engine/tick.ts` to compute passive income for all unlocked expansions every tick (not just base game)
|
||||
- [ ] Offline income calculation must also cover all expansions
|
||||
|
||||
### Profile
|
||||
- [ ] Profile panel: tab stats by expansion (base game tab + one tab per unlocked expansion)
|
||||
- [ ] Show correct thematic prestige/transcendence/apotheosis badge names per expansion
|
||||
- [ ] Lifetime stats (gold earned, clicks, etc.) tracked separately per expansion
|
||||
|
||||
### About / How to Play
|
||||
- [ ] Update `aboutPanel.tsx` `HOW_TO_PLAY` array to document the expansion system
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Tests & CI
|
||||
|
||||
- [ ] Unit tests for all new data files (at minimum, validate structure/required fields)
|
||||
- [ ] Unit tests for `initialVampireState()`
|
||||
- [ ] Tests for vampire unlock trigger route
|
||||
- [ ] Tests for vampire prestige/transcendence/apotheosis routes
|
||||
- [ ] Tests for updated tick engine (expansion income)
|
||||
- [ ] Maintain 100% coverage on `apps/api` and `packages/types`
|
||||
- [ ] Full pipeline: lint → build → test passing before PR
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Final
|
||||
|
||||
- [ ] Delete `img/vampire/` directory before committing
|
||||
- [ ] Update `MEMORY.md` with new content counts
|
||||
- [ ] Open PR → request review
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/api",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
|
||||
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"
|
||||
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
||||
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
||||
@@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
|
||||
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||
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"
|
||||
@@ -7,6 +7,9 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const discordClientId = "1479551654264049908";
|
||||
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
||||
|
||||
interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
@@ -31,24 +34,18 @@ interface DiscordUser {
|
||||
const exchangeCode = async(
|
||||
code: string,
|
||||
): Promise<DiscordTokenResponse> => {
|
||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||
|
||||
if (
|
||||
clientId === undefined || clientId === ""
|
||||
|| clientSecret === undefined || clientSecret === ""
|
||||
|| redirectUri === undefined || redirectUri === ""
|
||||
) {
|
||||
if (clientSecret === undefined || clientSecret === "") {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_id: discordClientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri: discordRedirectUri,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -146,19 +143,9 @@ const fetchDiscordUserById = async(
|
||||
* @throws {Error} If OAuth environment variables are missing.
|
||||
*/
|
||||
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({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: discordClientId,
|
||||
redirect_uri: discordRedirectUri,
|
||||
response_type: "code",
|
||||
scope: "identify",
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const discordGuildId = "1354624415861833870";
|
||||
|
||||
/**
|
||||
* Discord Gateway opcodes used by this client.
|
||||
*/
|
||||
@@ -36,8 +38,7 @@ const handleGuildMemberAdd = async(
|
||||
discordId: string,
|
||||
guildId: string,
|
||||
): Promise<void> => {
|
||||
const configuredGuildId = process.env.DISCORD_GUILD_ID;
|
||||
if (guildId !== configuredGuildId) {
|
||||
if (guildId !== discordGuildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -66,8 +67,7 @@ const handleGuildMemberRemove = async(
|
||||
discordId: string,
|
||||
guildId: string,
|
||||
): Promise<void> => {
|
||||
const configuredGuildId = process.env.DISCORD_GUILD_ID;
|
||||
if (guildId !== configuredGuildId) {
|
||||
if (guildId !== discordGuildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,9 @@ 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.
|
||||
@@ -28,18 +30,14 @@ const elysianRoleId = "1486144823684628490";
|
||||
*/
|
||||
const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
|
||||
if (
|
||||
botToken === undefined || botToken === ""
|
||||
|| guildId === undefined || guildId === ""
|
||||
) {
|
||||
if (botToken === undefined || botToken === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${elysianRoleId}`,
|
||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bot ${botToken}`,
|
||||
@@ -68,20 +66,14 @@ const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
||||
*/
|
||||
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||
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 === ""
|
||||
|| guildId === undefined || guildId === ""
|
||||
|| roleId === undefined || roleId === ""
|
||||
) {
|
||||
if (botToken === undefined || botToken === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
|
||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bot ${botToken}`,
|
||||
|
||||
@@ -18,51 +18,31 @@ describe("discord service", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
const url = buildOAuthUrl();
|
||||
expect(url).toContain("client_id=client123");
|
||||
expect(url).toContain("client_id=1479551654264049908");
|
||||
expect(url).toContain("response_type=code");
|
||||
expect(url).toContain("scope=identify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeCode", () => {
|
||||
it("throws when env vars are missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_ID"];
|
||||
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_SECRET"];
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("throws when response is not ok", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||
});
|
||||
|
||||
it("returns parsed body on success", async () => {
|
||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||
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" };
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
@@ -96,9 +76,7 @@ describe("discord service", () => {
|
||||
|
||||
describe("exchangeCode non-Error throw", () => {
|
||||
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_REDIRECT_URI"] = "http://localhost/cb";
|
||||
mockFetch.mockRejectedValueOnce("raw string error");
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||
|
||||
@@ -16,60 +16,48 @@ vi.mock("../../src/services/logger.js", () => ({
|
||||
|
||||
import { prisma } from "../../src/db/client.js";
|
||||
|
||||
describe("gateway service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
const discordGuildId = "1354624415861833870";
|
||||
|
||||
describe("gateway service", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleGuildMemberAdd", () => {
|
||||
it("sets inGuild to true for the matching guild", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberAdd("user123", "guild123");
|
||||
await handleGuildMemberAdd("user123", discordGuildId);
|
||||
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||
data: { inGuild: true },
|
||||
where: { discordId: "user123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops when guild id does not match", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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("no-ops when DISCORD_GUILD_ID env var is missing and guild does not match undefined", async () => {
|
||||
delete process.env["DISCORD_GUILD_ID"];
|
||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberAdd("user123", "guild123");
|
||||
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error when prisma throws an Error", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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", "guild123");
|
||||
await handleGuildMemberAdd("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
|
||||
});
|
||||
|
||||
it("logs error when prisma throws a non-Error", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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", "guild123");
|
||||
await handleGuildMemberAdd("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"gateway_member_add",
|
||||
new Error("raw error"),
|
||||
@@ -79,46 +67,35 @@ describe("gateway service", () => {
|
||||
|
||||
describe("handleGuildMemberRemove", () => {
|
||||
it("sets inGuild to false for the matching guild", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberRemove("user123", "guild123");
|
||||
await handleGuildMemberRemove("user123", discordGuildId);
|
||||
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||
data: { inGuild: false },
|
||||
where: { discordId: "user123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops when guild id does not match", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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("no-ops when DISCORD_GUILD_ID env var is missing", async () => {
|
||||
delete process.env["DISCORD_GUILD_ID"];
|
||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||
await handleGuildMemberRemove("user123", "guild123");
|
||||
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error when prisma throws an Error", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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", "guild123");
|
||||
await handleGuildMemberRemove("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
|
||||
});
|
||||
|
||||
it("logs error when prisma throws a non-Error", async () => {
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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", "guild123");
|
||||
await handleGuildMemberRemove("user123", discordGuildId);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"gateway_member_remove",
|
||||
new Error("raw error"),
|
||||
|
||||
@@ -20,42 +20,20 @@ describe("webhook service", () => {
|
||||
describe("grantApotheosisRole", () => {
|
||||
it("does nothing when bot token is missing", async () => {
|
||||
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");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it("calls Discord API with correct URL and auth when bot token is set", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user789");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
|
||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||
}),
|
||||
);
|
||||
@@ -63,8 +41,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("swallows fetch errors gracefully", async () => {
|
||||
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"));
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||
@@ -72,8 +48,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||
mockFetch.mockRejectedValueOnce("raw string error");
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||
@@ -83,18 +57,6 @@ describe("webhook service", () => {
|
||||
describe("grantElysianRole", () => {
|
||||
it("does nothing when bot token is missing", async () => {
|
||||
delete process.env["DISCORD_BOT_TOKEN"];
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123";
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when guild id is missing", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||
delete process.env["DISCORD_GUILD_ID"];
|
||||
process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123";
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
@@ -103,12 +65,11 @@ describe("webhook service", () => {
|
||||
|
||||
it("returns true when Discord API responds with ok", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||
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/guild123/members/user789/roles/1486144823684628490",
|
||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||
@@ -119,7 +80,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("returns true when Discord API responds with 204", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
@@ -128,7 +88,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("returns false when Discord API responds with an error status", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
@@ -137,7 +96,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("returns false and swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
@@ -146,7 +104,6 @@ describe("webhook service", () => {
|
||||
|
||||
it("returns false and swallows non-Error fetch rejections", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
process.env["DISCORD_GUILD_ID"] = "g";
|
||||
mockFetch.mockRejectedValueOnce("raw string error");
|
||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||
const result = await grantElysianRole("user");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/web",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -737,7 +737,7 @@ export const GameProvider = ({
|
||||
setSchemaOutdated(data.schemaOutdated);
|
||||
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||
setInGuild(data.inGuild === true);
|
||||
setInGuild(data.inGuild);
|
||||
|
||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||
void fetch(`/api/profile/${data.state.player.discordId}`).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "elysium",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/types",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
Reference in New Issue
Block a user