diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5ff21c1..0eb67c8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -35,6 +35,7 @@ model Player { lifetimeAchievementsUnlocked Float @default(0) lastLoginDate String? loginStreak Int @default(1) + inGuild Boolean @default(false) } model GameState { diff --git a/apps/api/prod.env b/apps/api/prod.env index d3843dc..a746725 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -10,4 +10,5 @@ DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord mi 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" +DISCORD_ELYSIAN_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord elysian role id" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e579b94..e04bda5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; import { transcendenceRouter } from "./routes/transcendence.js"; +import { connectGateway } from "./services/gateway.js"; import { logger } from "./services/logger.js"; const app = new Hono(); @@ -68,6 +69,7 @@ const port = Number(process.env.PORT ?? 3001); try { serve({ fetch: app.fetch, port: port }, () => { process.stdout.write(`Elysium API running on port ${String(port)}\n`); + connectGateway(); }); } catch (error) { void logger.error( diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index d974509..eeb07cd 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -16,6 +16,7 @@ import { } from "../services/discord.js"; import { signToken } from "../services/jwt.js"; import { logger } from "../services/logger.js"; +import { grantElysianRole } from "../services/webhook.js"; import type { Player } from "@elysium/types"; const authRouter = new Hono(); @@ -92,6 +93,12 @@ 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); void logger.log("info", `New player registered: ${player.discordId}`); void logger.metric("user_registered", 1, { discordId: player.discordId }); @@ -104,10 +111,12 @@ authRouter.get("/callback", async(context) => { ); } + const inGuild = await grantElysianRole(discordUser.id); const updated = await prisma.player.update({ data: { avatar: discordUser.avatar, discriminator: discordUser.discriminator, + inGuild: inGuild, username: discordUser.username, }, where: { discordId: discordUser.id }, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index f6affe1..f74e31e 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -760,6 +760,7 @@ gameRouter.get("/load", async(context) => { : computeHmac(JSON.stringify(freshState), secret); return context.json({ currentSchemaVersion: currentSchemaVersion, + inGuild: playerRecord.inGuild, loginBonus: null, loginStreak: playerRecord.loginStreak, offlineEssence: 0, @@ -898,8 +899,10 @@ gameRouter.get("/load", async(context) => { const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(state), secret); + const inGuild = playerRecord?.inGuild ?? false; return context.json({ currentSchemaVersion, + inGuild, loginBonus, loginStreak, offlineEssence, diff --git a/apps/api/src/services/gateway.ts b/apps/api/src/services/gateway.ts new file mode 100644 index 0000000..c973e49 --- /dev/null +++ b/apps/api/src/services/gateway.ts @@ -0,0 +1,182 @@ +/** + * @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"; + +/** + * 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 => { + const configuredGuildId = process.env.DISCORD_GUILD_ID; + if (guildId !== configuredGuildId) { + 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 => { + const configuredGuildId = process.env.DISCORD_GUILD_ID; + if (guildId !== configuredGuildId) { + 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 | 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 }; diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index b7ef8cd..37649ed 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -15,6 +15,48 @@ const discordApi = "https://discord.com/api/v10"; */ const suppressNotifications = 4096; +/** + * 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 => { + const botToken = process.env.DISCORD_BOT_TOKEN; + const guildId = process.env.DISCORD_GUILD_ID; + const roleId = process.env.DISCORD_ELYSIAN_ROLE_ID; + + if ( + botToken === undefined || botToken === "" + || guildId === undefined || guildId === "" + || roleId === undefined || roleId === "" + ) { + return false; + } + + try { + const response = await fetch( + `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, + { + 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. * Fails silently so role grant errors do not affect the game action. @@ -38,8 +80,11 @@ const grantApotheosisRole = async(discordId: string): Promise => { await fetch( `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, { - headers: { Authorization: `Bot ${botToken}` }, - method: "PUT", + headers: { + "Authorization": `Bot ${botToken}`, + "User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)", + }, + method: "PUT", }, ); } catch (error) { @@ -109,4 +154,4 @@ const postMilestoneWebhook = async( } }; -export { grantApotheosisRole, postMilestoneWebhook }; +export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook }; diff --git a/apps/api/test/services/gateway.spec.ts b/apps/api/test/services/gateway.spec.ts new file mode 100644 index 0000000..67387b6 --- /dev/null +++ b/apps/api/test/services/gateway.spec.ts @@ -0,0 +1,128 @@ +/* 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"; + +describe("gateway service", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.resetAllMocks(); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + 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"); + 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"; + 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"); + 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"); + expect(logger.error).toHaveBeenCalledWith( + "gateway_member_add", + new Error("raw error"), + ); + }); + }); + + 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"); + 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"; + 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"); + 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"); + expect(logger.error).toHaveBeenCalledWith( + "gateway_member_remove", + new Error("raw error"), + ); + }); + }); +}); diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index a68a90a..5c74d8f 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -80,6 +80,95 @@ 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(); + expect(result).toBe(false); + }); + + 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_ELYSIAN_ROLE_ID"]; + 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"; + process.env["DISCORD_GUILD_ID"] = "guild123"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role456"; + 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/role456", + 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"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + 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"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + 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"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + 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"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + }); + describe("postMilestoneWebhook", () => { const counts = { prestige: 1, transcendence: 0, apotheosis: 0 }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 37162da..90aaabc 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js"; import { EditProfileModal } from "./editProfileModal.js"; import { EquipmentPanel } from "./equipmentPanel.js"; import { ExplorationPanel } from "./explorationPanel.js"; +import { JoinCommunityModal } from "./joinCommunityModal.js"; import { LoginBonusModal } from "./loginBonusModal.js"; import { MilestoneToast } from "./milestoneToast.js"; import { OfflineModal } from "./offlineModal.js"; @@ -164,6 +165,7 @@ const GameLayout = (): JSX.Element => { transcendenceCount={state.transcendence?.count ?? 0} /> + {schemaOutdated && !dismissedOutdatedWarning ? : null} diff --git a/apps/web/src/components/game/joinCommunityModal.tsx b/apps/web/src/components/game/joinCommunityModal.tsx new file mode 100644 index 0000000..8bf95cd --- /dev/null +++ b/apps/web/src/components/game/joinCommunityModal.tsx @@ -0,0 +1,70 @@ +/** + * @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 ( +
+
+

{"Join Our Community!"}

+

+ {"Did you know Elysium has an active Discord community? "} + {"Join to chat with other players, get updates, and earn "} + {"the exclusive Elysian role!"} +

+

+ {"You already earn the Elysian role just by playing — "} + {"joining lets us show it off in the server!"} +

+
+ + {"Join Discord"} + + +
+
+
+ ); +}; + +export { JoinCommunityModal }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index f6c23fb..24f39db 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -243,6 +243,11 @@ interface GameContextValue { isLoading: boolean; error: string | null; + /** + * Whether the player is currently a member of the NHCarrigan Discord server. + */ + inGuild: boolean; + /** * Click the crystal to earn gold. */ @@ -694,6 +699,7 @@ export const GameProvider = ({ const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); + const [ inGuild, setInGuild ] = useState(false); const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< Array >([]); @@ -731,6 +737,7 @@ export const GameProvider = ({ setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); + setInGuild(data.inGuild === true); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`). @@ -2303,6 +2310,7 @@ export const GameProvider = ({ forceUnlocks, formatNumber, handleClick, + inGuild, isLoading, isSyncing, lastSavedAt, @@ -2374,6 +2382,7 @@ export const GameProvider = ({ error, flushBossLoreToasts, forceSync, + inGuild, forceUnlocks, handleClick, isLoading, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 378cd73..976a301 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -70,6 +70,11 @@ interface LoginBonusResult { interface LoadResponse { state: GameState; + /** + * Whether the player is currently a member of the NHCarrigan Discord server. + */ + inGuild: boolean; + /** * Offline gold earned since last save (server-calculated). */