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
24 changed files with 212 additions and 883 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.3.2",
"version": "0.3.1",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
-1
View File
@@ -35,7 +35,6 @@ model Player {
lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String?
loginStreak Int @default(1)
inGuild Boolean @default(false)
}
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_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"
@@ -6,4 +8,6 @@ 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"
-2
View File
@@ -21,7 +21,6 @@ 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();
@@ -69,7 +68,6 @@ 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(
-9
View File
@@ -16,7 +16,6 @@ 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();
@@ -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);
void logger.log("info", `New player registered: ${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({
data: {
avatar: discordUser.avatar,
discriminator: discordUser.discriminator,
inGuild: inGuild,
username: discordUser.username,
},
where: { discordId: discordUser.id },
+7 -92
View File
@@ -642,14 +642,6 @@ const patchAdventurerStats = (state: GameState): number => {
if (defaultAdventurer === undefined) {
continue;
}
const hasChanged
= savedAdventurer.baseCost !== defaultAdventurer.baseCost
|| savedAdventurer.class !== defaultAdventurer.class
|| savedAdventurer.combatPower !== defaultAdventurer.combatPower
|| savedAdventurer.essencePerSecond !== defaultAdventurer.essencePerSecond
|| savedAdventurer.goldPerSecond !== defaultAdventurer.goldPerSecond
|| savedAdventurer.level !== defaultAdventurer.level
|| savedAdventurer.name !== defaultAdventurer.name;
savedAdventurer.baseCost = defaultAdventurer.baseCost;
savedAdventurer.class = defaultAdventurer.class;
savedAdventurer.combatPower = defaultAdventurer.combatPower;
@@ -657,9 +649,7 @@ const patchAdventurerStats = (state: GameState): number => {
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
savedAdventurer.level = defaultAdventurer.level;
savedAdventurer.name = defaultAdventurer.name;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -680,15 +670,6 @@ const patchQuestStats = (state: GameState): number => {
if (defaultQuest === undefined) {
continue;
}
const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds);
const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds);
const hasChanged
= savedQuest.name !== defaultQuest.name
|| savedQuest.description !== defaultQuest.description
|| savedQuest.durationSeconds !== defaultQuest.durationSeconds
|| savedPrereqs !== defaultPrereqs
|| savedQuest.zoneId !== defaultQuest.zoneId
|| savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired;
savedQuest.name = defaultQuest.name;
savedQuest.description = defaultQuest.description;
savedQuest.durationSeconds = defaultQuest.durationSeconds;
@@ -697,9 +678,7 @@ const patchQuestStats = (state: GameState): number => {
if (defaultQuest.combatPowerRequired !== undefined) {
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
}
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -710,7 +689,6 @@ const patchQuestStats = (state: GameState): number => {
* @param state - The player's current game state (mutated in place).
* @returns The number of boss entries whose stats were updated.
*/
/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */
const patchBossStats = (state: GameState): number => {
const defaultBossMap = new Map(defaultBosses.map((boss) => {
return [ boss.id, boss ] as const;
@@ -721,20 +699,6 @@ const patchBossStats = (state: GameState): number => {
if (defaultBoss === undefined) {
continue;
}
const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
const hasChanged
= savedBoss.name !== defaultBoss.name
|| savedBoss.description !== defaultBoss.description
|| savedBoss.maxHp !== defaultBoss.maxHp
|| savedBoss.damagePerSecond !== defaultBoss.damagePerSecond
|| savedBoss.goldReward !== defaultBoss.goldReward
|| savedBoss.essenceReward !== defaultBoss.essenceReward
|| savedBoss.crystalReward !== defaultBoss.crystalReward
|| savedRewards !== defaultRewards
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|| savedBoss.zoneId !== defaultBoss.zoneId
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones;
savedBoss.name = defaultBoss.name;
savedBoss.description = defaultBoss.description;
savedBoss.maxHp = defaultBoss.maxHp;
@@ -746,9 +710,7 @@ const patchBossStats = (state: GameState): number => {
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -769,20 +731,12 @@ const patchZoneStats = (state: GameState): number => {
if (defaultZone === undefined) {
continue;
}
const hasChanged
= savedZone.name !== defaultZone.name
|| savedZone.description !== defaultZone.description
|| savedZone.emoji !== defaultZone.emoji
|| savedZone.unlockBossId !== defaultZone.unlockBossId
|| savedZone.unlockQuestId !== defaultZone.unlockQuestId;
savedZone.name = defaultZone.name;
savedZone.description = defaultZone.description;
savedZone.emoji = defaultZone.emoji;
savedZone.unlockBossId = defaultZone.unlockBossId;
savedZone.unlockQuestId = defaultZone.unlockQuestId;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -793,7 +747,6 @@ const patchZoneStats = (state: GameState): number => {
* @param state - The player's current game state (mutated in place).
* @returns The number of upgrade entries whose stats were updated.
*/
/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */
const patchUpgradeStats = (state: GameState): number => {
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
return [ upgrade.id, upgrade ] as const;
@@ -804,15 +757,6 @@ const patchUpgradeStats = (state: GameState): number => {
if (defaultUpgrade === undefined) {
continue;
}
const hasChanged
= savedUpgrade.name !== defaultUpgrade.name
|| savedUpgrade.description !== defaultUpgrade.description
|| savedUpgrade.target !== defaultUpgrade.target
|| savedUpgrade.adventurerId !== defaultUpgrade.adventurerId
|| savedUpgrade.multiplier !== defaultUpgrade.multiplier
|| savedUpgrade.costGold !== defaultUpgrade.costGold
|| savedUpgrade.costEssence !== defaultUpgrade.costEssence
|| savedUpgrade.costCrystals !== defaultUpgrade.costCrystals;
savedUpgrade.name = defaultUpgrade.name;
savedUpgrade.description = defaultUpgrade.description;
savedUpgrade.target = defaultUpgrade.target;
@@ -823,9 +767,7 @@ const patchUpgradeStats = (state: GameState): number => {
savedUpgrade.costGold = defaultUpgrade.costGold;
savedUpgrade.costEssence = defaultUpgrade.costEssence;
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -836,7 +778,6 @@ const patchUpgradeStats = (state: GameState): number => {
* @param state - The player's current game state (mutated in place).
* @returns The number of equipment entries whose stats were updated.
*/
/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */
const patchEquipmentStats = (state: GameState): number => {
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
return [ item.id, item ] as const;
@@ -847,18 +788,6 @@ const patchEquipmentStats = (state: GameState): number => {
if (defaultItem === undefined) {
continue;
}
const savedBonus = JSON.stringify(savedItem.bonus);
const defaultBonus = JSON.stringify(defaultItem.bonus);
const savedCost = JSON.stringify(savedItem.cost);
const defaultCost = JSON.stringify(defaultItem.cost);
const hasChanged
= savedItem.name !== defaultItem.name
|| savedItem.description !== defaultItem.description
|| savedItem.type !== defaultItem.type
|| savedItem.rarity !== defaultItem.rarity
|| savedBonus !== defaultBonus
|| savedCost !== defaultCost
|| savedItem.setId !== defaultItem.setId;
savedItem.name = defaultItem.name;
savedItem.description = defaultItem.description;
savedItem.type = defaultItem.type;
@@ -870,9 +799,7 @@ const patchEquipmentStats = (state: GameState): number => {
if (defaultItem.setId !== undefined) {
savedItem.setId = defaultItem.setId;
}
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
@@ -893,16 +820,6 @@ const patchAchievementStats = (state: GameState): number => {
if (defaultAchievement === undefined) {
continue;
}
const savedCondition = JSON.stringify(savedAchievement.condition);
const defaultCondition = JSON.stringify(defaultAchievement.condition);
const savedReward = JSON.stringify(savedAchievement.reward);
const defaultReward = JSON.stringify(defaultAchievement.reward);
const hasChanged
= savedAchievement.name !== defaultAchievement.name
|| savedAchievement.description !== defaultAchievement.description
|| savedAchievement.icon !== defaultAchievement.icon
|| savedCondition !== defaultCondition
|| savedReward !== defaultReward;
savedAchievement.name = defaultAchievement.name;
savedAchievement.description = defaultAchievement.description;
savedAchievement.icon = defaultAchievement.icon;
@@ -910,9 +827,7 @@ const patchAchievementStats = (state: GameState): number => {
if (defaultAchievement.reward !== undefined) {
savedAchievement.reward = { ...defaultAchievement.reward };
}
if (hasChanged) {
patched = patched + 1;
}
patched = patched + 1;
}
return patched;
};
-3
View File
@@ -760,7 +760,6 @@ 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,
@@ -899,10 +898,8 @@ 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,
+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 */
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;
@@ -34,18 +31,24 @@ 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 (clientSecret === undefined || clientSecret === "") {
if (
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({
client_id: discordClientId,
client_id: clientId,
client_secret: clientSecret,
code: code,
grant_type: "authorization_code",
redirect_uri: discordRedirectUri,
redirect_uri: redirectUri,
});
try {
@@ -143,9 +146,19 @@ 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: discordClientId,
redirect_uri: discordRedirectUri,
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
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;
/**
* 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.
* 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 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;
}
try {
await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
headers: { Authorization: `Bot ${botToken}` },
method: "PUT",
},
);
} 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", () => {
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=1479551654264049908");
expect(url).toContain("client_id=client123");
expect(url).toContain("response_type=code");
expect(url).toContain("scope=identify");
});
});
describe("exchangeCode", () => {
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
delete process.env["DISCORD_CLIENT_SECRET"];
it("throws when env vars are missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
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");
@@ -76,7 +96,9 @@ 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");
-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", () => {
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("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_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/1354624415861833870/members/user789/roles/1479966598210129991",
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
expect.objectContaining({
method: "PUT",
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
@@ -41,6 +63,8 @@ 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();
@@ -48,69 +72,14 @@ 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();
});
});
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", () => {
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.3.2",
"version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
+69 -16
View File
@@ -11,11 +11,10 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { computePartyCombatPower } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { Boss } from "@elysium/types";
import type { Boss, GameState } from "@elysium/types";
interface BossCardProperties {
readonly boss: Boss;
@@ -158,6 +157,72 @@ const BossCard = ({
);
};
/**
* Computes party DPS and HP from the current game state.
* @param state - The full game state.
* @returns The computed party DPS and HP values.
*/
const computePartyStats = (
state: GameState,
): {
partyDps: number;
partyHp: number;
} => {
const { upgrades, adventurers, equipment, prestige } = state;
let globalMultiplier = 1;
for (const upgrade of upgrades) {
const { purchased, target, multiplier } = upgrade;
if (purchased && target === "global") {
globalMultiplier = globalMultiplier * multiplier;
}
}
const prestigeBonus = prestige.count * 0.1;
const prestigeMultiplier = 1 + prestigeBonus;
const equipmentCombatMultiplier = equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((multiplier, item) => {
return multiplier * (item.bonus.combatMultiplier ?? 1);
}, 1);
let partyDps = 0;
let partyHp = 0;
for (const adventurer of adventurers) {
const { count, id: adventurerId, combatPower, level } = adventurer;
if (count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of upgrades) {
const {
purchased,
target,
multiplier,
adventurerId: upgradeAdventurerId,
} = upgrade;
if (
purchased
&& target === "adventurer"
&& upgradeAdventurerId === adventurerId
) {
adventurerMultiplier = adventurerMultiplier * multiplier;
}
}
const dps
= combatPower
* count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyDps = partyDps + dps;
const hp = level * 50 * count;
partyHp = partyHp + hp;
}
partyDps = partyDps * equipmentCombatMultiplier;
return { partyDps, partyHp };
};
/**
* Renders the boss panel with zone selection and boss list.
* @returns The JSX element.
@@ -201,14 +266,7 @@ const BossPanel = (): JSX.Element => {
void handleChallenge(bossId);
}
const {
adventurers,
autoBoss,
bosses,
prestige: playerPrestige,
quests,
zones,
} = state;
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
@@ -291,12 +349,7 @@ const BossPanel = (): JSX.Element => {
}
const autoBossOn = autoBoss === true;
const partyDps = computePartyCombatPower(state);
let partyHp = 0;
for (const { level, count } of adventurers) {
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
partyHp = partyHp + level * 50 * count;
}
const { partyDps, partyHp } = computePartyStats(state);
const { count: prestigeCount } = playerPrestige;
return (
@@ -27,7 +27,6 @@ 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";
@@ -165,7 +164,6 @@ const GameLayout = (): JSX.Element => {
transcendenceCount={state.transcendence?.count ?? 0}
/>
<OfflineModal />
<JoinCommunityModal />
{schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: 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 };
@@ -7,8 +7,6 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */
/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
@@ -240,22 +238,6 @@ const UpgradePanel = (): JSX.Element => {
}
}
}
for (const upgrade of locked) {
if (
!upgradeUnlockHints.has(upgrade.id)
&& upgrade.adventurerId !== undefined
) {
const adventurerForHint = adventurers.find((a) => {
return a.id === upgrade.adventurerId;
});
if (adventurerForHint !== undefined) {
upgradeUnlockHints.set(
upgrade.id,
`🗡️ Recruit: ${adventurerForHint.name}`,
);
}
}
}
function handleToggle(): void {
setShowLocked((current) => {
+5 -16
View File
@@ -10,12 +10,7 @@
/* eslint-disable complexity -- Many conditional resource and badge render paths */
import { useState, type FocusEvent, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
import {
RESOURCE_CAP,
computeEssencePerSecond,
computeGoldPerSecond,
computePartyCombatPower,
} from "../../engine/tick.js";
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
import type { Resource } from "@elysium/types";
interface ResourceBarProperties {
@@ -88,11 +83,12 @@ const ResourceBar = ({
const { gold, essence, crystals } = resources;
let partyCombatPower = 0;
let goldPerSecond = 0;
let essencePerSecond = 0;
if (state !== null) {
partyCombatPower = computePartyCombatPower(state);
for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state);
}
let avatarUrl: string | null = null;
@@ -186,13 +182,6 @@ const ResourceBar = ({
</span>
<span className="resource-label">{"Gold/s"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚡"}</span>
<span className="resource-value">
{formatNumber(essencePerSecond)}
</span>
<span className="resource-label">{"Essence/s"}</span>
</div>
<div className={`resource${essenceFull
? " resource-full"
: ""}`}>
+37 -91
View File
@@ -58,7 +58,6 @@ import {
RESOURCE_CAP,
applyTick,
calculateClickPower,
computePartyCombatPower,
} from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
@@ -244,11 +243,6 @@ 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.
*/
@@ -586,24 +580,16 @@ interface GameContextValue {
* @returns Counts of what was added per content type.
*/
syncNewContent: ()=> Promise<{
achievementsAdded: number;
achievementsPatched: number;
adventurerStatsPatched: number;
adventurersAdded: number;
bossRewardsPatched: number;
bossesAdded: number;
bossesPatched: number;
craftingRecipesReapplied: number;
equipmentAdded: number;
equipmentPatched: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
achievementsAdded: number;
adventurersAdded: number;
bossesAdded: number;
bossRewardsPatched: number;
equipmentAdded: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
upgradesAdded: number;
zonesAdded: number;
}>;
/**
@@ -700,7 +686,6 @@ 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<string>
>([]);
@@ -738,7 +723,6 @@ export const GameProvider = ({
setSchemaOutdated(data.schemaOutdated);
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild);
// Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -1079,7 +1063,11 @@ export const GameProvider = ({
return q.status === "active";
});
if (!hasActiveQuest) {
const partyCombatPower = computePartyCombatPower(next);
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
const partyCombatPower = next.adventurers.reduce((total, a) => {
const power = total + a.combatPower;
return power * a.count;
}, 0);
const zoneOrder = new Map(
next.zones.map((z, index) => {
return [ z.id, index ];
@@ -1117,31 +1105,14 @@ export const GameProvider = ({
next.autoAdventurer === true
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
) {
const maxAdventurerLevel = Math.max(
...next.adventurers.
filter((a) => {
return a.unlocked;
}).
map((a) => {
return a.level;
}),
);
const autoBuyCap = 100;
const [ bestAdventurer ] = next.adventurers.
filter((adventurer) => {
const cost
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
const isMaxTier = adventurer.level === maxAdventurerLevel;
const withinCap
= isMaxTier || adventurer.count < autoBuyCap;
return (
adventurer.unlocked
&& next.resources.gold >= cost
&& withinCap
);
return adventurer.unlocked && next.resources.gold >= cost;
}).
sort((adventurerA, adventurerB) => {
return adventurerB.level - adventurerA.level;
return adventurerB.combatPower - adventurerA.combatPower;
});
if (bestAdventurer !== undefined) {
const purchaseCost
@@ -1360,13 +1331,6 @@ export const GameProvider = ({
}
return afterBoss;
});
/*
* Boss fight modifies server state; clear stale signature so
* the next pre-save or auto-save does not send a mismatched one.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
setAutoBossLastResult({
at: Date.now(),
bossName: bossName,
@@ -2206,24 +2170,16 @@ export const GameProvider = ({
localStorage.setItem("elysium_save_signature", data.signature);
}
return {
achievementsAdded: data.achievementsAdded,
achievementsPatched: data.achievementsPatched,
adventurerStatsPatched: data.adventurerStatsPatched,
adventurersAdded: data.adventurersAdded,
bossRewardsPatched: data.bossRewardsPatched,
bossesAdded: data.bossesAdded,
bossesPatched: data.bossesPatched,
craftingRecipesReapplied: data.craftingRecipesReapplied,
equipmentAdded: data.equipmentAdded,
equipmentPatched: data.equipmentPatched,
explorationAreasAdded: data.explorationAreasAdded,
questRewardsPatched: data.questRewardsPatched,
questsAdded: data.questsAdded,
questsPatched: data.questsPatched,
upgradesAdded: data.upgradesAdded,
upgradesPatched: data.upgradesPatched,
zonesAdded: data.zonesAdded,
zonesPatched: data.zonesPatched,
achievementsAdded: data.achievementsAdded,
adventurersAdded: data.adventurersAdded,
bossRewardsPatched: data.bossRewardsPatched,
bossesAdded: data.bossesAdded,
equipmentAdded: data.equipmentAdded,
explorationAreasAdded: data.explorationAreasAdded,
questRewardsPatched: data.questRewardsPatched,
questsAdded: data.questsAdded,
upgradesAdded: data.upgradesAdded,
zonesAdded: data.zonesAdded,
};
} catch (error_: unknown) {
setError(
@@ -2232,24 +2188,16 @@ export const GameProvider = ({
: "Failed to sync new content",
);
return {
achievementsAdded: 0,
achievementsPatched: 0,
adventurerStatsPatched: 0,
adventurersAdded: 0,
bossRewardsPatched: 0,
bossesAdded: 0,
bossesPatched: 0,
craftingRecipesReapplied: 0,
equipmentAdded: 0,
equipmentPatched: 0,
explorationAreasAdded: 0,
questRewardsPatched: 0,
questsAdded: 0,
questsPatched: 0,
upgradesAdded: 0,
upgradesPatched: 0,
zonesAdded: 0,
zonesPatched: 0,
achievementsAdded: 0,
adventurersAdded: 0,
bossRewardsPatched: 0,
bossesAdded: 0,
equipmentAdded: 0,
explorationAreasAdded: 0,
questRewardsPatched: 0,
questsAdded: 0,
upgradesAdded: 0,
zonesAdded: 0,
};
}
}, []);
@@ -2331,7 +2279,6 @@ export const GameProvider = ({
forceUnlocks,
formatNumber,
handleClick,
inGuild,
isLoading,
isSyncing,
lastSavedAt,
@@ -2403,7 +2350,6 @@ export const GameProvider = ({
error,
flushBossLoreToasts,
forceSync,
inGuild,
forceUnlocks,
handleClick,
isLoading,
-145
View File
@@ -195,138 +195,6 @@ export const computeGoldPerSecond = (state: GameState): number => {
return goldPerSecond;
};
/**
* Computes the current essence per second for the given game state,
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
* @param state - The current game state.
* @returns The total essence per second.
*/
export const computeEssencePerSecond = (state: GameState): number => {
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
let essencePerSecond = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const contribution
= adventurer.essencePerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
essencePerSecond = essencePerSecond + contribution;
}
return essencePerSecond;
};
/**
* Computes the party's total combat power, applying all active multipliers
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
* This mirrors the server-side calculatePartyStats in boss.ts.
* @param state - The current game state.
* @returns The total party combat power.
*/
export const computePartyCombatPower = (state: GameState): number => {
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier = globalMultiplier * upgrade.multiplier;
}
}
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const equipmentCombatMultiplier = state.equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
const equippedItemIds = state.equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
});
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
equippedItemIds,
EQUIPMENT_SETS,
);
const echoCombatMultiplier
= state.transcendence?.echoCombatMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
let partyCombatPower = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased
&& upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
}
}
const contribution
= adventurer.combatPower
* adventurer.count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyCombatPower = partyCombatPower + contribution;
}
return partyCombatPower
* equipmentCombatMultiplier
* setCombatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
};
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
@@ -601,19 +469,6 @@ export const applyTick = (
challengeCrystals = result.crystalsAwarded;
}
// Auto-unlock adventurer-specific upgrades when their adventurer is recruited
updatedUpgrades = updatedUpgrades.map((upgrade) => {
if (upgrade.unlocked || upgrade.adventurerId === undefined) {
return upgrade;
}
const adventurer = updatedAdventurers.find((a) => {
return a.id === upgrade.adventurerId;
});
return adventurer !== undefined && adventurer.count > 0
? { ...upgrade, unlocked: true }
: upgrade;
});
const goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "elysium",
"version": "0.3.2",
"version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.3.2",
"version": "0.3.1",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
-5
View File
@@ -70,11 +70,6 @@ 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).
*/