10 Commits

Author SHA1 Message Date
hikari 7b81f6cb33 fix: resolve auto-boss signature mismatch, expose full CP, cap auto-buy, show unlock hints
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
CI / Lint, Build & Test (pull_request) Failing after 1m9s
Closes #148: clear stale signature after each boss fight so subsequent
auto-boss pre-saves don't send a mismatched HMAC.

Closes #151: auto-buy skips non-max-tier adventurers once they reach 100,
keeping gold flowing to the highest-unlocked tier.

Closes #152: introduce computePartyCombatPower() in tick.ts mirroring the
server-side formula (global upgrades, prestige, equipment, set bonuses,
echo, crafted, companion). Resource bar, auto-quest gate, and boss panel
all now use the same multiplier-accurate value.

Closes #146: tick engine auto-unlocks adventurer-specific upgrades when
their adventurer is first recruited; upgrade panel shows a recruit hint
for locked entries with no boss/quest source.
2026-03-25 16:38:42 -07:00
hikari ad4fcc2811 fix: resolve sync count inflation, add essence/s display, sort auto-buy by level
Closes #147: patch functions now detect actual changes before incrementing
the patched counter, preventing inflated sync reports.

Closes #149: computeEssencePerSecond exported from tick engine and shown
in the resource bar dropdown alongside Gold/s.

Closes #150: auto-buy now sorts adventurers by level descending for
semantic clarity, ensuring highest-tier units are purchased first.
2026-03-25 16:16:21 -07:00
naomi 9926e7f639 release: v0.3.2
CI / Lint, Build & Test (push) Successful in 1m13s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m17s
2026-03-24 18:50:37 -07:00
hikari 6bf1ac5e7d feat: grant Elysian role on auth and prompt non-members to join (#134)
CI / Lint, Build & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

- Grants the Elysian Discord role to players on login/registration and persists an `inGuild` flag on the Player record
- Connects to the Discord Gateway via WebSocket to keep `inGuild` in sync as players join or leave the server
- Shows a dismissible "Join our community" modal to players who are not yet in the guild
- Hardens `inGuild` exposure through the load endpoint and game context
- Moves all non-secret Discord IDs (guild, role, client, redirect URI) out of env vars and into hardcoded constants; removes them from `prod.env`

## Test plan

- [ ] Lint, build, and test pipeline passes (100% coverage maintained)
- [ ] New player auth grants Elysian role and sets `inGuild: true`
- [ ] Existing player auth re-attempts role grant and updates `inGuild`
- [ ] Join community modal appears for players not in the guild
- [ ] Modal does not reappear within the same browser session after dismissal
- [ ] Gateway correctly sets `inGuild: true/false` on member add/remove events

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #134
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 18:49:51 -07:00
hikari b48beef474 feat: sync and patch all content stats on existing saves (#130)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

- Sync New Content now **injects** missing entries AND **patches canonical fields** on all existing entries to match current defaults
- Adventurers: stats (baseCost, combatPower, goldPerSecond, essencePerSecond, name, class, level)
- Quests: duration, prerequisites, combat requirement, rewards
- Bosses: HP, damage, rewards, prestige requirement, upgrade rewards
- Zones: unlock conditions (boss/quest required)
- Upgrades: multiplier, costs
- Equipment: bonus, cost, set membership
- Achievements: condition, reward
- Crafting: multipliers recomputed from `craftedRecipeIds` so recipe balance changes apply retroactively

Closes #126

## Test plan

- [ ] On an existing save, click Sync New Content and verify the notification reports patched counts for all content types
- [ ] Verify that rebalanced adventurer/boss/upgrade stats are reflected in the UI after syncing
- [ ] Verify that player-owned state (counts, unlock status, boss HP, quest status) is preserved after syncing
- [ ] Verify crafting multipliers are correct after syncing if any recipes were previously crafted

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #130
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 16:01:48 -07:00
hikari 6e573bea14 chore: more feedback fixes (#129)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

- Fix `NaN` displayed in Sync New Content / Force Unlock notifications by guarding against undefined counts
- Poll server for exploration claimability before showing Collect button to prevent client/server desync
- Return authoritative materials list from craft API to prevent client desync causing false affordability
- Add test coverage for `sync-new-content` and `explore/claimable` endpoints

Closes #125
Closes #127
Closes #128

## Test plan

- [ ] Trigger a sync with new content and verify the notification shows a real count instead of `NaN`
- [ ] Start an exploration, wait for it to complete, and verify the Collect button only appears after the server confirms claimable
- [ ] Attempt to craft a recipe and verify the material counts in the UI update to match the server's authoritative values

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #129
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 13:20:37 -07:00
hikari 790d35420f fix: patch quest and boss rewards on sync to restore unlock conditions
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Failing after 1m11s
2026-03-23 18:45:14 -07:00
naomi 9f9edae45e release: v0.3.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint, Build & Test (push) Failing after 1m11s
2026-03-23 18:32:15 -07:00
hikari a7a255dab6 fix: sort injected entries by canonical defaults order after sync
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Failing after 1m13s
2026-03-23 18:18:59 -07:00
hikari e92cf3c9a1 feat: add sync new content debug tool
CI / Lint, Build & Test (push) Failing after 51s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
Adds a new debug panel button that injects any adventurers, quests,
bosses, equipment, upgrades, achievements, zones, and exploration areas
that exist in the current game data but are missing from an existing
player save (e.g. content added after the save was first created).
2026-03-23 18:10:39 -07:00
32 changed files with 2382 additions and 206 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.3.0", "version": "0.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+1
View File
@@ -35,6 +35,7 @@ model Player {
lifetimeAchievementsUnlocked Float @default(0) lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String? lastLoginDate String?
loginStreak Int @default(1) loginStreak Int @default(1)
inGuild Boolean @default(false)
} }
model GameState { model GameState {
-4
View File
@@ -1,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_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret" JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url" DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret" ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
@@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+2
View File
@@ -21,6 +21,7 @@ import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js"; import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js"; import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js"; import { transcendenceRouter } from "./routes/transcendence.js";
import { connectGateway } from "./services/gateway.js";
import { logger } from "./services/logger.js"; import { logger } from "./services/logger.js";
const app = new Hono(); const app = new Hono();
@@ -68,6 +69,7 @@ const port = Number(process.env.PORT ?? 3001);
try { try {
serve({ fetch: app.fetch, port: port }, () => { serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`); process.stdout.write(`Elysium API running on port ${String(port)}\n`);
connectGateway();
}); });
} catch (error) { } catch (error) {
void logger.error( void logger.error(
+9
View File
@@ -16,6 +16,7 @@ import {
} from "../services/discord.js"; } from "../services/discord.js";
import { signToken } from "../services/jwt.js"; import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import { grantElysianRole } from "../services/webhook.js";
import type { Player } from "@elysium/types"; import type { Player } from "@elysium/types";
const authRouter = new Hono(); const authRouter = new Hono();
@@ -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); const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`); void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId }); void logger.metric("user_registered", 1, { discordId: player.discordId });
@@ -104,10 +111,12 @@ authRouter.get("/callback", async(context) => {
); );
} }
const inGuild = await grantElysianRole(discordUser.id);
const updated = await prisma.player.update({ const updated = await prisma.player.update({
data: { data: {
avatar: discordUser.avatar, avatar: discordUser.avatar,
discriminator: discordUser.discriminator, discriminator: discordUser.discriminator,
inGuild: inGuild,
username: discordUser.username, username: discordUser.username,
}, },
where: { discordId: discordUser.id }, where: { discordId: discordUser.id },
+12 -1
View File
@@ -148,11 +148,22 @@ craftRouter.post("/", async(context) => {
const bonusType = recipe.bonus.type; const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value; const bonusValue = recipe.bonus.value;
const { materials } = state.exploration;
const {
craftedGoldMultiplier,
craftedEssenceMultiplier,
craftedClickMultiplier,
craftedCombatMultiplier,
} = updatedMultipliers;
const response: CraftRecipeResponse = { const response: CraftRecipeResponse = {
bonusType, bonusType,
bonusValue, bonusValue,
craftedClickMultiplier,
craftedCombatMultiplier,
craftedEssenceMultiplier,
craftedGoldMultiplier,
materials,
recipeId, recipeId,
...updatedMultipliers,
}; };
return context.json(response); return context.json(response);
} catch (error) { } catch (error) {
+603
View File
@@ -13,11 +13,16 @@ import {
type GameState, type GameState,
} from "@elysium/types"; } from "@elysium/types";
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultAchievements } from "../data/achievements.js";
import { defaultAdventurers } from "../data/adventurers.js";
import { defaultBosses } from "../data/bosses.js"; import { defaultBosses } from "../data/bosses.js";
import { defaultEquipment } from "../data/equipment.js";
import { defaultExplorations } from "../data/explorations.js"; import { defaultExplorations } from "../data/explorations.js";
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { defaultQuests } from "../data/quests.js"; import { defaultQuests } from "../data/quests.js";
import { defaultRecipes } from "../data/recipes.js";
import { currentSchemaVersion } from "../data/schemaVersion.js"; import { currentSchemaVersion } from "../data/schemaVersion.js";
import { defaultUpgrades } from "../data/upgrades.js";
import { defaultZones } from "../data/zones.js"; import { defaultZones } from "../data/zones.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
@@ -508,6 +513,523 @@ const applyForceUnlocks = (
}; };
}; };
/**
* Injects any entries from a defaults array that are missing from an existing
* saved array (matched by `id`), cloning each new entry before pushing.
* @param existing - The player's saved array (mutated in place).
* @param defaults - The current default data array to compare against.
* @returns The number of entries that were added.
*/
const injectMissingEntries = <T extends { id: string }>(
existing: Array<T>,
defaults: Array<T>,
): number => {
const existingIds = new Set(existing.map((item) => {
return item.id;
}));
let added = 0;
for (const item of defaults) {
if (!existingIds.has(item.id)) {
existing.push(structuredClone(item));
added = added + 1;
}
}
const defaultOrder = new Map(defaults.map((item, index) => {
return [ item.id, index ] as const;
}));
existing.sort((itemA, itemB) => {
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
});
return added;
};
/**
* Injects any exploration areas from the defaults that are missing from the
* player's exploration state, seeding each new area as locked.
* @param state - The player's current game state (mutated in place).
* @returns The number of exploration areas that were added.
*/
const injectMissingExplorationAreas = (state: GameState): number => {
if (state.exploration === undefined) {
return 0;
}
const existingIds = new Set(state.exploration.areas.map((area) => {
return area.id;
}));
let added = 0;
for (const area of defaultExplorations) {
if (!existingIds.has(area.id)) {
state.exploration.areas.push({ id: area.id, status: "locked" });
added = added + 1;
}
}
return added;
};
/**
* Patches rewards on existing quests whose reward lists have grown since the
* save was created (e.g. A new upgrade added as a reward to an old quest).
* @param state - The player's current game state (mutated in place).
* @returns The total number of individual rewards that were added.
*/
const patchQuestRewards = (state: GameState): number => {
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
return [ quest.id, quest ] as const;
}));
let added = 0;
for (const savedQuest of state.quests) {
const defaultQuest = defaultQuestMap.get(savedQuest.id);
if (defaultQuest === undefined) {
continue;
}
const existingKeys = new Set(savedQuest.rewards.map((reward) => {
return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
}));
for (const reward of defaultQuest.rewards) {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
if (!existingKeys.has(key)) {
savedQuest.rewards.push(structuredClone(reward));
added = added + 1;
}
}
}
return added;
};
/**
* Patches upgradeRewards on existing bosses whose reward lists have grown
* since the save was created.
* @param state - The player's current game state (mutated in place).
* @returns The total number of upgrade reward IDs that were added.
*/
const patchBossUpgradeRewards = (state: GameState): number => {
const defaultBossMap = new Map(defaultBosses.map((boss) => {
return [ boss.id, boss ] as const;
}));
let added = 0;
for (const savedBoss of state.bosses) {
const defaultBoss = defaultBossMap.get(savedBoss.id);
if (defaultBoss === undefined) {
continue;
}
const existingIds = new Set(savedBoss.upgradeRewards);
for (const upgradeId of defaultBoss.upgradeRewards) {
if (!existingIds.has(upgradeId)) {
savedBoss.upgradeRewards.push(upgradeId);
added = added + 1;
}
}
}
return added;
};
/**
* Updates the stat fields of existing adventurers to match the current defaults,
* preserving only player-state fields (count and unlocked status).
* @param state - The player's current game state (mutated in place).
* @returns The number of adventurer entries whose stats were updated.
*/
const patchAdventurerStats = (state: GameState): number => {
const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => {
return [ adventurer.id, adventurer ] as const;
}));
let patched = 0;
for (const savedAdventurer of state.adventurers) {
const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id);
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;
savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond;
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
savedAdventurer.level = defaultAdventurer.level;
savedAdventurer.name = defaultAdventurer.name;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing quests to match the current defaults,
* preserving only player-state fields (status, startedAt, lastFailedAt, rewards).
* @param state - The player's current game state (mutated in place).
* @returns The number of quest entries whose stats were updated.
*/
const patchQuestStats = (state: GameState): number => {
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
return [ quest.id, quest ] as const;
}));
let patched = 0;
for (const savedQuest of state.quests) {
const defaultQuest = defaultQuestMap.get(savedQuest.id);
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;
savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds;
savedQuest.zoneId = defaultQuest.zoneId;
if (defaultQuest.combatPowerRequired !== undefined) {
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
}
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing bosses to match the current defaults,
* preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards).
* @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;
}));
let patched = 0;
for (const savedBoss of state.bosses) {
const defaultBoss = defaultBossMap.get(savedBoss.id);
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;
savedBoss.damagePerSecond = defaultBoss.damagePerSecond;
savedBoss.goldReward = defaultBoss.goldReward;
savedBoss.essenceReward = defaultBoss.essenceReward;
savedBoss.crystalReward = defaultBoss.crystalReward;
savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ];
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing zones to match the current defaults,
* preserving only player-state fields (status).
* @param state - The player's current game state (mutated in place).
* @returns The number of zone entries whose stats were updated.
*/
const patchZoneStats = (state: GameState): number => {
const defaultZoneMap = new Map(defaultZones.map((zone) => {
return [ zone.id, zone ] as const;
}));
let patched = 0;
for (const savedZone of state.zones) {
const defaultZone = defaultZoneMap.get(savedZone.id);
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;
}
}
return patched;
};
/**
* Updates the stat fields of existing upgrades to match the current defaults,
* preserving only player-state fields (purchased, unlocked).
* @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;
}));
let patched = 0;
for (const savedUpgrade of state.upgrades) {
const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id);
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;
if (defaultUpgrade.adventurerId !== undefined) {
savedUpgrade.adventurerId = defaultUpgrade.adventurerId;
}
savedUpgrade.multiplier = defaultUpgrade.multiplier;
savedUpgrade.costGold = defaultUpgrade.costGold;
savedUpgrade.costEssence = defaultUpgrade.costEssence;
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing equipment items to match the current defaults,
* preserving only player-state fields (owned, equipped).
* @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;
}));
let patched = 0;
for (const savedItem of state.equipment) {
const defaultItem = defaultEquipmentMap.get(savedItem.id);
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;
savedItem.rarity = defaultItem.rarity;
savedItem.bonus = structuredClone(defaultItem.bonus);
if (defaultItem.cost !== undefined) {
savedItem.cost = { ...defaultItem.cost };
}
if (defaultItem.setId !== undefined) {
savedItem.setId = defaultItem.setId;
}
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing achievements to match the current defaults,
* preserving only player-state fields (unlockedAt).
* @param state - The player's current game state (mutated in place).
* @returns The number of achievement entries whose stats were updated.
*/
const patchAchievementStats = (state: GameState): number => {
const defaultAchievementMap = new Map(defaultAchievements.map((a) => {
return [ a.id, a ] as const;
}));
let patched = 0;
for (const savedAchievement of state.achievements) {
const defaultAchievement = defaultAchievementMap.get(savedAchievement.id);
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;
savedAchievement.condition = structuredClone(defaultAchievement.condition);
if (defaultAchievement.reward !== undefined) {
savedAchievement.reward = { ...defaultAchievement.reward };
}
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */
/**
* Recomputes all four crafting multipliers from the player's craftedRecipeIds,
* replacing any stale cached values with the correct product of all crafted bonuses.
* @param state - The player's current game state (mutated in place).
* @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined.
*/
const recomputeCraftingMultipliers = (state: GameState): number => {
if (state.exploration === undefined) {
return 0;
}
const { craftedRecipeIds } = state.exploration;
state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
}).reduce((multiplier, recipe) => {
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
return craftedRecipeIds.length;
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
/**
* Syncs a player's save with the current game data, injecting any content
* entries that are missing because they were added after the save was created,
* and patching stat fields on existing entries to match the current defaults.
* @param state - The player's current game state (mutated in place).
* @returns Counts of how many entries were added or patched per content type.
*/
const syncNewContent = (
state: GameState,
): {
achievementsAdded: number;
achievementsPatched: number;
adventurersAdded: number;
adventurerStatsPatched: number;
bossesAdded: number;
bossesPatched: number;
bossRewardsPatched: number;
craftingRecipesReapplied: number;
equipmentAdded: number;
equipmentPatched: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
} => {
const adventurerStatsPatched = patchAdventurerStats(state);
const questsPatched = patchQuestStats(state);
const bossesPatched = patchBossStats(state);
const zonesPatched = patchZoneStats(state);
const upgradesPatched = patchUpgradeStats(state);
const equipmentPatched = patchEquipmentStats(state);
const achievementsPatched = patchAchievementStats(state);
const craftingRecipesReapplied = recomputeCraftingMultipliers(state);
const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
const bossRewardsPatched = patchBossUpgradeRewards(state);
const bossesAdded = injectMissingEntries(state.bosses, defaultBosses);
const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment);
const explorationAreasAdded = injectMissingExplorationAreas(state);
const questRewardsPatched = patchQuestRewards(state);
const questsAdded = injectMissingEntries(state.quests, defaultQuests);
const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades);
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
return {
achievementsAdded,
achievementsPatched,
adventurerStatsPatched,
adventurersAdded,
bossRewardsPatched,
bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
};
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
const debugRouter = new Hono<HonoEnvironment>(); const debugRouter = new Hono<HonoEnvironment>();
debugRouter.use(authMiddleware); debugRouter.use(authMiddleware);
@@ -572,6 +1094,87 @@ debugRouter.post("/force-unlocks", async(context) => {
} }
}); });
debugRouter.post("/sync-new-content", async(context) => {
try {
const discordId = context.get("discordId");
const gameStateRecord = await prisma.gameState.findUnique({
where: { discordId },
});
if (!gameStateRecord) {
return context.json({ error: "No game state found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
const state = gameStateRecord.state as unknown as GameState;
const {
achievementsAdded,
achievementsPatched,
adventurersAdded,
adventurerStatsPatched,
bossesAdded,
bossesPatched,
bossRewardsPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
} = syncNewContent(state);
const updatedAt = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: updatedAt },
where: { discordId },
});
const secret = process.env.ANTI_CHEAT_SECRET;
const signature
= secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
return context.json({
achievementsAdded,
achievementsPatched,
adventurerStatsPatched,
adventurersAdded,
bossRewardsPatched,
bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
signature,
state,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
});
} catch (error) {
void logger.error(
"debug_sync_new_content",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
debugRouter.post("/hard-reset", async(context) => { debugRouter.post("/hard-reset", async(context) => {
try { try {
const discordId = context.get("discordId"); const discordId = context.get("discordId");
+60
View File
@@ -7,6 +7,7 @@
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */ /* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable max-lines -- Route file requires multiple handlers */
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultExplorations } from "../data/explorations.js"; import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js"; import { initialExploration } from "../data/initialState.js";
@@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
ExploreClaimableResponse,
ExploreCollectEventResult, ExploreCollectEventResult,
ExploreCollectRequest, ExploreCollectRequest,
ExploreCollectResponse, ExploreCollectResponse,
@@ -49,6 +51,64 @@ const pickNothingMessage = (): string => {
return nothingMessages[index] ?? nothingMessages[0] ?? ""; return nothingMessages[index] ?? nothingMessages[0] ?? "";
}; };
exploreRouter.get("/claimable", async(context) => {
try {
const discordId = context.get("discordId");
const areaId = context.req.query("areaId");
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
const response: ExploreClaimableResponse = { claimable: false };
return context.json(response);
}
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area || area.status !== "in_progress") {
const response: ExploreClaimableResponse = { claimable: false };
return context.json(response);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
const claimable = Date.now() >= expiresAt;
const response: ExploreClaimableResponse = { claimable };
return context.json(response);
} catch (error) {
void logger.error(
"explore_claimable",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
exploreRouter.post("/start", async(context) => { exploreRouter.post("/start", async(context) => {
try { try {
const discordId = context.get("discordId"); const discordId = context.get("discordId");
+3
View File
@@ -760,6 +760,7 @@ gameRouter.get("/load", async(context) => {
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion: currentSchemaVersion,
inGuild: playerRecord.inGuild,
loginBonus: null, loginBonus: null,
loginStreak: playerRecord.loginStreak, loginStreak: playerRecord.loginStreak,
offlineEssence: 0, offlineEssence: 0,
@@ -898,8 +899,10 @@ gameRouter.get("/load", async(context) => {
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(state), secret); : computeHmac(JSON.stringify(state), secret);
const inGuild = playerRecord?.inGuild ?? false;
return context.json({ return context.json({
currentSchemaVersion, currentSchemaVersion,
inGuild,
loginBonus, loginBonus,
loginStreak, loginStreak,
offlineEssence, offlineEssence,
+8 -21
View File
@@ -7,6 +7,9 @@
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js"; import { logger } from "./logger.js";
const discordClientId = "1479551654264049908";
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
interface DiscordTokenResponse { interface DiscordTokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
@@ -31,24 +34,18 @@ interface DiscordUser {
const exchangeCode = async( const exchangeCode = async(
code: string, code: string,
): Promise<DiscordTokenResponse> => { ): Promise<DiscordTokenResponse> => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET; const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if ( if (clientSecret === undefined || clientSecret === "") {
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required"); throw new Error("Discord OAuth environment variables are required");
} }
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
client_id: clientId, client_id: discordClientId,
client_secret: clientSecret, client_secret: clientSecret,
code: code, code: code,
grant_type: "authorization_code", grant_type: "authorization_code",
redirect_uri: redirectUri, redirect_uri: discordRedirectUri,
}); });
try { try {
@@ -146,19 +143,9 @@ const fetchDiscordUserById = async(
* @throws {Error} If OAuth environment variables are missing. * @throws {Error} If OAuth environment variables are missing.
*/ */
const buildOAuthUrl = (): string => { const buildOAuthUrl = (): string => {
const clientId = process.env.DISCORD_CLIENT_ID;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (
clientId === undefined || clientId === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
client_id: clientId, client_id: discordClientId,
redirect_uri: redirectUri, redirect_uri: discordRedirectUri,
response_type: "code", response_type: "code",
scope: "identify", scope: "identify",
}); });
+182
View File
@@ -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";
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 };
+50 -10
View File
@@ -15,6 +15,49 @@ const discordApi = "https://discord.com/api/v10";
*/ */
const suppressNotifications = 4096; const suppressNotifications = 4096;
/**
* The Discord role ID for the Elysian role granted to all Elysium players.
*/
const discordGuildId = "1354624415861833870";
const elysianRoleId = "1486144823684628490";
const apotheosisRoleId = "1479966598210129991";
/**
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
* Fails silently so role grant errors do not affect the auth flow.
* @param discordId - The Discord user ID to grant the role to.
* @returns True if the player is in the guild and the role was granted, false otherwise.
*/
const grantElysianRole = async(discordId: string): Promise<boolean> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return false;
}
try {
const response = await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
return response.ok || response.status === 204;
} catch (error) {
void logger.error(
"webhook_elysian_role",
error instanceof Error
? error
: new Error(String(error)),
);
return false;
}
};
/** /**
* Grants the apotheosis Discord role to the given player if configured. * Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action. * Fails silently so role grant errors do not affect the game action.
@@ -23,22 +66,19 @@ const suppressNotifications = 4096;
*/ */
const grantApotheosisRole = async(discordId: string): Promise<void> => { const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN; const botToken = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
if ( if (botToken === undefined || botToken === "") {
botToken === undefined || botToken === ""
|| guildId === undefined || guildId === ""
|| roleId === undefined || roleId === ""
) {
return; return;
} }
try { try {
await fetch( await fetch(
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
{ {
headers: { Authorization: `Bot ${botToken}` }, headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT", method: "PUT",
}, },
); );
@@ -109,4 +149,4 @@ const postMilestoneWebhook = async(
} }
}; };
export { grantApotheosisRole, postMilestoneWebhook }; export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
+453
View File
@@ -557,6 +557,459 @@ describe("debug route", () => {
}); });
}); });
const syncNewContent = () =>
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
describe("POST /sync-new-content", () => {
it("returns 404 when no game state found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await syncNewContent();
expect(res.status).toBe(404);
});
it("returns 200 with zero added counts when state already has all content", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number; bossRewardsPatched: number; questRewardsPatched: number };
expect(body.adventurerStatsPatched).toBe(0);
expect(body.bossRewardsPatched).toBe(0);
expect(body.questRewardsPatched).toBe(0);
});
it("patches adventurer stats when saved adventurer has outdated stats", async () => {
const state = makeState({
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number; state: GameState };
expect(body.adventurerStatsPatched).toBe(1);
const adventurer = body.state.adventurers.find((a) => a.id === "militia");
expect(adventurer?.baseCost).not.toBe(1);
expect(adventurer?.count).toBe(5);
expect(adventurer?.unlocked).toBe(true);
});
it("skips adventurer stat patching for adventurers not in defaults", async () => {
const state = makeState({
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number };
expect(body.adventurerStatsPatched).toBe(0);
});
it("injects missing entries when arrays are empty", async () => {
const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], zones: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurersAdded: number; bossesAdded: number };
expect(body.adventurersAdded).toBeGreaterThan(0);
expect(body.bossesAdded).toBeGreaterThan(0);
});
it("injects missing exploration areas when exploration has no areas", async () => {
const state = makeState({ exploration: makeExploration([]) });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
expect(body.explorationAreasAdded).toBeGreaterThan(0);
});
it("skips existing exploration areas when building the id set", async () => {
const state = makeState({ exploration: makeExploration([
{ id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0],
]) });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
// One area already existed so total injected is one less than full count
expect(body.explorationAreasAdded).toBeGreaterThan(0);
});
it("returns explorationAreasAdded=0 when exploration state is undefined", async () => {
const state = makeState({ exploration: undefined as unknown as GameState["exploration"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
expect(body.explorationAreasAdded).toBe(0);
});
it("patches quest rewards when saved quest has fewer rewards than default", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "first_steps");
expect(quest?.rewards.length).toBeGreaterThan(0);
});
it("skips quest reward patching for quests not in defaults", async () => {
const state = makeState({
quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "nonexistent_quest");
expect(quest?.rewards).toStrictEqual([]);
});
it("does not re-add rewards that are already present in the saved quest", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "first_steps");
// Reward already present so count stays the same
expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1);
});
it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.upgradeRewards.length).toBeGreaterThan(0);
});
it("skips boss reward patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss");
expect(boss?.upgradeRewards).toStrictEqual([]);
});
it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => {
const state = makeState({
achievements: [
{ id: "legacy_achievement_a", status: "locked" },
{ id: "legacy_achievement_b", status: "locked" },
] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("uses amount field when building the reward key for quests with amount-based rewards", async () => {
const state = makeState({
quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("falls back to empty string when reward has neither targetId nor amount", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("patches upgrade adventurerId when default has it set", async () => {
const state = makeState({
upgrades: [{ id: "peasant_1", purchased: false, unlocked: false, multiplier: 0.1, name: "Old", description: "Old", target: "click", costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const upgrade = body.state.upgrades.find((u) => u.id === "peasant_1");
expect(upgrade?.adventurerId).toBe("peasant");
});
it("patches equipment cost when default has it set", async () => {
const state = makeState({
equipment: [{ id: "shadow_dagger", owned: false, equipped: false, name: "Old", description: "Old", type: "weapon", rarity: "common", bonus: {} }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const item = body.state.equipment.find((e) => e.id === "shadow_dagger");
expect(item?.cost).toBeDefined();
});
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("returns 500 when DB throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await syncNewContent();
expect(res.status).toBe(500);
});
it("returns 500 when DB throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
const res = await syncNewContent();
expect(res.status).toBe(500);
});
it("patches quest stats when saved quest has outdated fields", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [], durationSeconds: 1, name: "Old Name", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { questsPatched: number; state: GameState };
expect(body.questsPatched).toBe(1);
const quest = body.state.quests.find((q) => q.id === "first_steps");
expect(quest?.name).not.toBe("Old Name");
expect(quest?.durationSeconds).not.toBe(1);
expect(quest?.status).toBe("available");
});
it("skips quest stat patching for quests not in defaults", async () => {
const state = makeState({
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { questsPatched: number };
expect(body.questsPatched).toBe(0);
});
it("patches boss stats when saved boss has outdated fields", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Old Name", description: "Old" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number; state: GameState };
expect(body.bossesPatched).toBe(1);
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.maxHp).not.toBe(1);
expect(boss?.name).not.toBe("Old Name");
expect(boss?.status).toBe("available");
expect(boss?.currentHp).toBe(100);
});
it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number };
expect(body.bossesPatched).toBe(0);
});
it("patches zone stats when saved zone has outdated fields", async () => {
const state = makeState({
zones: [{ id: "verdant_vale", status: "unlocked", name: "Old Name", description: "Old", emoji: "❓", unlockBossId: "wrong_boss", unlockQuestId: "wrong_quest" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { zonesPatched: number; state: GameState };
expect(body.zonesPatched).toBe(1);
const zone = body.state.zones.find((z) => z.id === "verdant_vale");
expect(zone?.name).not.toBe("Old Name");
expect(zone?.status).toBe("unlocked");
});
it("skips zone stat patching for zones not in defaults", async () => {
const state = makeState({
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { zonesPatched: number };
expect(body.zonesPatched).toBe(0);
});
it("patches upgrade stats when saved upgrade has outdated fields", async () => {
const state = makeState({
upgrades: [{ id: "click_2", purchased: false, unlocked: true, multiplier: 0.1, name: "Old Name", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { upgradesPatched: number; state: GameState };
expect(body.upgradesPatched).toBe(1);
const upgrade = body.state.upgrades.find((u) => u.id === "click_2");
expect(upgrade?.multiplier).not.toBe(0.1);
expect(upgrade?.name).not.toBe("Old Name");
expect(upgrade?.purchased).toBe(false);
expect(upgrade?.unlocked).toBe(true);
});
it("skips upgrade stat patching for upgrades not in defaults", async () => {
const state = makeState({
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { upgradesPatched: number };
expect(body.upgradesPatched).toBe(0);
});
it("patches equipment stats when saved item has outdated fields", async () => {
const state = makeState({
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Rusty Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { equipmentPatched: number; state: GameState };
expect(body.equipmentPatched).toBe(1);
const item = body.state.equipment.find((e) => e.id === "iron_sword");
expect(item?.name).not.toBe("Rusty Sword");
expect(item?.owned).toBe(true);
expect(item?.equipped).toBe(false);
});
it("skips equipment stat patching for items not in defaults", async () => {
const state = makeState({
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { equipmentPatched: number };
expect(body.equipmentPatched).toBe(0);
});
it("patches achievement stats when saved achievement has outdated fields", async () => {
const state = makeState({
achievements: [{ id: "first_click", unlockedAt: null, name: "Old Name", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 999 }, reward: undefined }] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { achievementsPatched: number; state: GameState };
expect(body.achievementsPatched).toBe(1);
const achievement = body.state.achievements.find((a) => a.id === "first_click");
expect(achievement?.name).not.toBe("Old Name");
expect(achievement?.condition.amount).not.toBe(999);
expect(achievement?.unlockedAt).toBeNull();
});
it("skips achievement stat patching for achievements not in defaults", async () => {
const state = makeState({
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { achievementsPatched: number };
expect(body.achievementsPatched).toBe(0);
});
it("recomputes crafting multipliers from craftedRecipeIds", async () => {
const state = makeState({
exploration: { ...makeExploration(), craftedRecipeIds: ["heartwood_tincture"], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { craftingRecipesReapplied: number; state: GameState };
expect(body.craftingRecipesReapplied).toBe(1);
expect(body.state.exploration?.craftedGoldMultiplier).toBeGreaterThan(1);
});
it("returns 0 for crafting recompute when exploration is undefined", async () => {
const state = makeState({
exploration: undefined as unknown as GameState["exploration"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { craftingRecipesReapplied: number };
expect(body.craftingRecipesReapplied).toBe(0);
});
it("sets multipliers to 1 when craftedRecipeIds is empty", async () => {
const state = makeState({
exploration: { ...makeExploration(), craftedRecipeIds: [], craftedGoldMultiplier: 5, craftedEssenceMultiplier: 5, craftedClickMultiplier: 5, craftedCombatMultiplier: 5 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.exploration?.craftedGoldMultiplier).toBe(1);
expect(body.state.exploration?.craftedEssenceMultiplier).toBe(1);
expect(body.state.exploration?.craftedClickMultiplier).toBe(1);
expect(body.state.exploration?.craftedCombatMultiplier).toBe(1);
});
});
describe("POST /hard-reset", () => { describe("POST /hard-reset", () => {
it("returns 404 when no player found", async () => { it("returns 404 when no player found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null); vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
+93
View File
@@ -77,6 +77,99 @@ describe("explore route", () => {
body: JSON.stringify(body), body: JSON.stringify(body),
})); }));
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/explore/claimable"
: `http://localhost/explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("nonexistent_area");
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when no exploration state exists", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area is not in_progress", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when exploration is still in progress", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=true when exploration is complete", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
describe("POST /start", () => { describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => { it("returns 400 when areaId is missing", async () => {
const res = await postStart({}); const res = await postStart({});
+3 -25
View File
@@ -18,51 +18,31 @@ describe("discord service", () => {
}); });
describe("buildOAuthUrl", () => { describe("buildOAuthUrl", () => {
it("throws when DISCORD_CLIENT_ID is missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
delete process.env["DISCORD_REDIRECT_URI"];
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("returns a URL with correct query params", async () => { it("returns a URL with correct query params", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js"); const { buildOAuthUrl } = await import("../../src/services/discord.js");
const url = buildOAuthUrl(); const url = buildOAuthUrl();
expect(url).toContain("client_id=client123"); expect(url).toContain("client_id=1479551654264049908");
expect(url).toContain("response_type=code"); expect(url).toContain("response_type=code");
expect(url).toContain("scope=identify"); expect(url).toContain("scope=identify");
}); });
}); });
describe("exchangeCode", () => { describe("exchangeCode", () => {
it("throws when env vars are missing", async () => { it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
delete process.env["DISCORD_CLIENT_ID"]; delete process.env["DISCORD_CLIENT_SECRET"];
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required"); await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
}); });
it("throws when response is not ok", async () => { it("throws when response is not ok", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" }); mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed"); await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
}); });
it("returns parsed body on success", async () => { it("returns parsed body on success", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" }; const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
@@ -96,9 +76,7 @@ describe("discord service", () => {
describe("exchangeCode non-Error throw", () => { describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => { it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockRejectedValueOnce("raw string error"); mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
+105
View File
@@ -0,0 +1,105 @@
/* 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"),
);
});
});
});
+59 -28
View File
@@ -20,40 +20,18 @@ describe("webhook service", () => {
describe("grantApotheosisRole", () => { describe("grantApotheosisRole", () => {
it("does nothing when bot token is missing", async () => { it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"]; delete process.env["DISCORD_BOT_TOKEN"];
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123"); await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
it("does nothing when guild id is missing", async () => { it("calls Discord API with correct URL and auth when bot token is set", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
delete process.env["DISCORD_GUILD_ID"];
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when role id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
process.env["DISCORD_GUILD_ID"] = "guild123";
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("calls Discord API with correct URL and auth when env vars are set", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token"; process.env["DISCORD_BOT_TOKEN"] = "bot_token";
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
mockFetch.mockResolvedValueOnce({ ok: true }); mockFetch.mockResolvedValueOnce({ ok: true });
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user789"); await grantApotheosisRole("user789");
expect(mockFetch).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456", "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
expect.objectContaining({ expect.objectContaining({
method: "PUT", method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }), headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
@@ -63,8 +41,6 @@ describe("webhook service", () => {
it("swallows fetch errors gracefully", async () => { it("swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce(new Error("Network error")); mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
@@ -72,14 +48,69 @@ describe("webhook service", () => {
it("swallows non-Error fetch rejections gracefully", async () => { it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error"); mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
}); });
}); });
describe("grantElysianRole", () => {
it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("returns true when Discord API responds with ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user789");
expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
expect(result).toBe(true);
});
it("returns true when Discord API responds with 204", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(true);
});
it("returns false when Discord API responds with an error status", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows non-Error fetch rejections", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
});
describe("postMilestoneWebhook", () => { describe("postMilestoneWebhook", () => {
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 }; const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.3.0", "version": "0.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+27
View File
@@ -17,6 +17,7 @@ import type {
BuyPrestigeUpgradeResponse, BuyPrestigeUpgradeResponse,
CraftRecipeRequest, CraftRecipeRequest,
CraftRecipeResponse, CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectRequest, ExploreCollectRequest,
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
@@ -28,6 +29,7 @@ import type {
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,
@@ -243,6 +245,19 @@ const collectExploration = async(
}); });
}; };
/**
* Checks whether a given exploration area is ready to claim on the server.
* @param areaId - The area ID to check.
* @returns Whether the exploration is claimable.
*/
const checkExplorationClaimable = async(
areaId: string,
): Promise<ExploreClaimableResponse> => {
return await fetchJson<ExploreClaimableResponse>(
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
);
};
/** /**
* Crafts a recipe on the server. * Crafts a recipe on the server.
* @param body - The craft recipe request payload. * @param body - The craft recipe request payload.
@@ -267,6 +282,16 @@ const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
}); });
}; };
/**
* Syncs any content added after the player's save was created into their save.
* @returns The updated game state and counts of what was added per content type.
*/
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
method: "POST",
});
};
/** /**
* Performs a complete hard reset of the player's game state via the debug endpoint. * Performs a complete hard reset of the player's game state via the debug endpoint.
* @returns The fresh game state as a LoadResponse. * @returns The fresh game state as a LoadResponse.
@@ -305,10 +330,12 @@ export {
buyEchoUpgrade, buyEchoUpgrade,
buyPrestigeUpgrade, buyPrestigeUpgrade,
challengeBoss, challengeBoss,
checkExplorationClaimable,
collectExploration, collectExploration,
craftRecipe, craftRecipe,
debugHardReset, debugHardReset,
forceUnlocks, forceUnlocks,
syncNewContent,
getAbout, getAbout,
getAuthUrl, getAuthUrl,
getPublicProfile, getPublicProfile,
+16 -69
View File
@@ -11,10 +11,11 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { computePartyCombatPower } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Boss, GameState } from "@elysium/types"; import type { Boss } from "@elysium/types";
interface BossCardProperties { interface BossCardProperties {
readonly boss: Boss; readonly boss: Boss;
@@ -157,72 +158,6 @@ 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. * Renders the boss panel with zone selection and boss list.
* @returns The JSX element. * @returns The JSX element.
@@ -266,7 +201,14 @@ const BossPanel = (): JSX.Element => {
void handleChallenge(bossId); void handleChallenge(bossId);
} }
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; const {
adventurers,
autoBoss,
bosses,
prestige: playerPrestige,
quests,
zones,
} = state;
const activeZone = zones.find((zone) => { const activeZone = zones.find((zone) => {
return zone.id === activeZoneId; return zone.id === activeZoneId;
@@ -349,7 +291,12 @@ const BossPanel = (): JSX.Element => {
} }
const autoBossOn = autoBoss === true; const autoBossOn = autoBoss === true;
const { partyDps, partyHp } = computePartyStats(state); 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 { count: prestigeCount } = playerPrestige; const { count: prestigeCount } = playerPrestige;
return ( return (
+130 -18
View File
@@ -10,17 +10,84 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { ConfirmationModal } from "../ui/confirmationModal.js"; import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | null; type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
interface SyncNewContentResult {
achievementsAdded: number | undefined;
achievementsPatched: number | undefined;
adventurersAdded: number | undefined;
adventurerStatsPatched: number | undefined;
bossesAdded: number | undefined;
bossesPatched: number | undefined;
bossRewardsPatched: number | undefined;
craftingRecipesReapplied: number | undefined;
equipmentAdded: number | undefined;
equipmentPatched: number | undefined;
explorationAreasAdded: number | undefined;
questRewardsPatched: number | undefined;
questsAdded: number | undefined;
questsPatched: number | undefined;
upgradesAdded: number | undefined;
upgradesPatched: number | undefined;
zonesAdded: number | undefined;
zonesPatched: number | undefined;
}
const safeNumber = (value: number | undefined): number => {
return value ?? 0;
};
/**
* Builds a human-readable summary of what the sync-new-content operation added.
* @param result - The counts returned by the operation.
* @returns A message string describing what was added, or a confirmation nothing was needed.
*/
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
const entries: Array<[ number, string ]> = [
[ safeNumber(result.zonesAdded), "zone(s)" ],
[ safeNumber(result.questsAdded), "quest(s)" ],
[ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ],
[ safeNumber(result.bossesAdded), "boss(es)" ],
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
[ safeNumber(result.achievementsAdded), "achievement(s)" ],
[ safeNumber(result.questsPatched), "quest stat(s) patched" ],
[ safeNumber(result.bossesPatched), "boss stat(s) patched" ],
[ safeNumber(result.zonesPatched), "zone stat(s) patched" ],
[ safeNumber(result.upgradesPatched), "upgrade stat(s) patched" ],
[ safeNumber(result.equipmentPatched), "equipment stat(s) patched" ],
[ safeNumber(result.achievementsPatched), "achievement stat(s) patched" ],
[ safeNumber(result.craftingRecipesReapplied), "crafting recipe(s) reapplied" ],
];
const parts = entries.
filter(([ count ]) => {
return count > 0;
}).
map(([ count, label ]) => {
return `${String(count)} ${label}`;
});
if (parts.length === 0) {
return "Your save is already up to date — no new content was found.";
}
const total = entries.reduce((sum, [ count ]) => {
return sum + count;
}, 0);
return `Synced ${String(total)} item(s): ${parts.join(", ")}.`;
};
interface ForceUnlocksResult { interface ForceUnlocksResult {
adventurersUnlocked: number; adventurersUnlocked: number | undefined;
bossesUnlocked: number; bossesUnlocked: number | undefined;
equipmentUnlocked: number; equipmentUnlocked: number | undefined;
explorationUnlocked: number; explorationUnlocked: number | undefined;
questsUnlocked: number; questsUnlocked: number | undefined;
storyUnlocked: number; storyUnlocked: number | undefined;
upgradesUnlocked: number; upgradesUnlocked: number | undefined;
zonesUnlocked: number; zonesUnlocked: number | undefined;
} }
/** /**
@@ -30,14 +97,14 @@ interface ForceUnlocksResult {
*/ */
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => { const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
const entries: Array<[ number, string ]> = [ const entries: Array<[ number, string ]> = [
[ result.zonesUnlocked, "zone(s)" ], [ safeNumber(result.zonesUnlocked), "zone(s)" ],
[ result.questsUnlocked, "quest(s)" ], [ safeNumber(result.questsUnlocked), "quest(s)" ],
[ result.bossesUnlocked, "boss(es)" ], [ safeNumber(result.bossesUnlocked), "boss(es)" ],
[ result.explorationUnlocked, "exploration area(s)" ], [ safeNumber(result.explorationUnlocked), "exploration area(s)" ],
[ result.adventurersUnlocked, "adventurer tier(s)" ], [ safeNumber(result.adventurersUnlocked), "adventurer tier(s)" ],
[ result.upgradesUnlocked, "upgrade(s)" ], [ safeNumber(result.upgradesUnlocked), "upgrade(s)" ],
[ result.equipmentUnlocked, "equipment item(s)" ], [ safeNumber(result.equipmentUnlocked), "equipment item(s)" ],
[ result.storyUnlocked, "story chapter(s)" ], [ safeNumber(result.storyUnlocked), "story chapter(s)" ],
]; ];
const parts = entries. const parts = entries.
filter(([ count ]) => { filter(([ count ]) => {
@@ -60,15 +127,21 @@ const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
* @returns The JSX element. * @returns The JSX element.
*/ */
const DebugPanel = (): JSX.Element => { const DebugPanel = (): JSX.Element => {
const { forceUnlocks, debugHardReset, isLoading } = useGame(); const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null); const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null); const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
function handleOpenForceUnlocks(): void { function handleOpenForceUnlocks(): void {
setForceUnlocksResult(null); setForceUnlocksResult(null);
setActiveModal("force-unlocks"); setActiveModal("force-unlocks");
} }
function handleOpenSyncNewContent(): void {
setSyncNewContentResult(null);
setActiveModal("sync-new-content");
}
function handleOpenHardReset(): void { function handleOpenHardReset(): void {
setActiveModal("hard-reset"); setActiveModal("hard-reset");
} }
@@ -85,6 +158,14 @@ const DebugPanel = (): JSX.Element => {
})(); })();
} }
function handleConfirmSyncNewContent(): void {
setActiveModal(null);
void (async(): Promise<void> => {
const result = await syncNewContent();
setSyncNewContentResult(buildSyncNewContentMessage(result));
})();
}
function handleConfirmHardReset(): void { function handleConfirmHardReset(): void {
setActiveModal(null); setActiveModal(null);
void debugHardReset(); void debugHardReset();
@@ -120,6 +201,26 @@ const DebugPanel = (): JSX.Element => {
} }
</div> </div>
<div className="debug-action-card">
<h3>{"🔄 Sync New Content"}</h3>
<p>
{
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
}
</p>
<button
className="action-button"
disabled={isLoading}
onClick={handleOpenSyncNewContent}
type="button"
>
{"Sync New Content"}
</button>
{syncNewContentResult !== null
&& <p className="debug-result-message">{syncNewContentResult}</p>
}
</div>
<div className="debug-action-card"> <div className="debug-action-card">
<h3>{"💀 Hard Reset"}</h3> <h3>{"💀 Hard Reset"}</h3>
<p> <p>
@@ -149,6 +250,17 @@ const DebugPanel = (): JSX.Element => {
/> />
} }
{activeModal === "sync-new-content"
&& <ConfirmationModal
confirmLabel="Yes, Sync New Content"
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
isLoading={isLoading}
onCancel={handleCancel}
onConfirm={handleConfirmSyncNewContent}
title="Sync New Content"
/>
}
{activeModal === "hard-reset" {activeModal === "hard-reset"
&& <ConfirmationModal && <ConfirmationModal
confirmLabel="Yes, Wipe Everything" confirmLabel="Yes, Wipe Everything"
@@ -7,12 +7,17 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */ /* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
import { type JSX, useState } from "react"; /* eslint-disable max-statements -- Component function requires many state declarations and handlers */
import { type JSX, useEffect, useRef, useState } from "react";
import { checkExplorationClaimable } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js"; import { EXPLORATION_AREAS } from "../../data/explorations.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { ExploreCollectResponse } from "@elysium/types"; import type {
ExploreClaimableResponse,
ExploreCollectResponse,
} from "@elysium/types";
/** /**
* Formats a duration in seconds to a human-readable string. * Formats a duration in seconds to a human-readable string.
@@ -83,6 +88,61 @@ const ExplorationPanel = (): JSX.Element => {
}); });
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null); const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null); const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
const [ claimableAreaIds, setClaimableAreaIds ]
= useState<ReadonlySet<string>>(new Set());
const stateReference = useRef(state);
stateReference.current = state;
const claimableReference = useRef(claimableAreaIds);
claimableReference.current = claimableAreaIds;
useEffect(() => {
const pollClaimable = async(): Promise<void> => {
const currentState = stateReference.current;
if (currentState === null) {
return;
}
const inProgressArea = currentState.exploration?.areas.find((a) => {
return a.status === "in_progress";
});
if (inProgressArea === undefined) {
return;
}
if (claimableReference.current.has(inProgressArea.id)) {
return;
}
const areaData = EXPLORATION_AREAS.find((a) => {
return a.id === inProgressArea.id;
});
if (areaData === undefined) {
return;
}
const remaining = timeRemaining(
inProgressArea.endsAt,
inProgressArea.startedAt ?? 0,
areaData.durationSeconds,
);
if (remaining > 0) {
return;
}
const result: ExploreClaimableResponse
= await checkExplorationClaimable(inProgressArea.id);
if (result.claimable) {
setClaimableAreaIds((previous) => {
return new Set([ ...previous, inProgressArea.id ]);
});
}
};
const intervalId = setInterval(() => {
void pollClaimable();
}, 1000);
return (): void => {
clearInterval(intervalId);
};
}, []);
if (state === null) { if (state === null) {
return ( return (
@@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => {
try { try {
const result = await collectExploration(areaId); const result = await collectExploration(areaId);
setLastResult({ areaId: areaId, response: result }); setLastResult({ areaId: areaId, response: result });
setClaimableAreaIds((previous) => {
const next = new Set(previous);
next.delete(areaId);
return next;
});
} finally { } finally {
setPendingAreaId(null); setPendingAreaId(null);
} }
@@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => {
const endsAt = areaState?.endsAt; const endsAt = areaState?.endsAt;
const isReady const isReady
= status === "in_progress" = status === "in_progress"
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0; && claimableAreaIds.has(area.id);
const isPending = pendingAreaId === area.id; const isPending = pendingAreaId === area.id;
function handleStartClick(): void { function handleStartClick(): void {
@@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js"; import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
import { JoinCommunityModal } from "./joinCommunityModal.js";
import { LoginBonusModal } from "./loginBonusModal.js"; import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js"; import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
@@ -164,6 +165,7 @@ const GameLayout = (): JSX.Element => {
transcendenceCount={state.transcendence?.count ?? 0} transcendenceCount={state.transcendence?.count ?? 0}
/> />
<OfflineModal /> <OfflineModal />
<JoinCommunityModal />
{schemaOutdated && !dismissedOutdatedWarning {schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} /> ? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null} : null}
@@ -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 (
<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,6 +7,8 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* 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 max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ /* 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 { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -238,6 +240,22 @@ 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 { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
+16 -5
View File
@@ -10,7 +10,12 @@
/* eslint-disable complexity -- Many conditional resource and badge render paths */ /* eslint-disable complexity -- Many conditional resource and badge render paths */
import { useState, type FocusEvent, type JSX } from "react"; import { useState, type FocusEvent, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js"; import {
RESOURCE_CAP,
computeEssencePerSecond,
computeGoldPerSecond,
computePartyCombatPower,
} from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
interface ResourceBarProperties { interface ResourceBarProperties {
@@ -83,12 +88,11 @@ const ResourceBar = ({
const { gold, essence, crystals } = resources; const { gold, essence, crystals } = resources;
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0; let goldPerSecond = 0;
let essencePerSecond = 0;
if (state !== null) { if (state !== null) {
for (const adventurer of state.adventurers) { partyCombatPower = computePartyCombatPower(state);
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
goldPerSecond = computeGoldPerSecond(state); goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state);
} }
let avatarUrl: string | null = null; let avatarUrl: string | null = null;
@@ -182,6 +186,13 @@ const ResourceBar = ({
</span> </span>
<span className="resource-label">{"Gold/s"}</span> <span className="resource-label">{"Gold/s"}</span>
</div> </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 <div className={`resource${essenceFull
? " resource-full" ? " resource-full"
: ""}`}> : ""}`}>
+123 -16
View File
@@ -44,6 +44,7 @@ import {
craftRecipe as craftRecipeApi, craftRecipe as craftRecipeApi,
debugHardReset as debugHardResetApi, debugHardReset as debugHardResetApi,
forceUnlocks as forceUnlocksApi, forceUnlocks as forceUnlocksApi,
syncNewContent as syncNewContentApi,
loadGame, loadGame,
prestige as prestigeApi, prestige as prestigeApi,
resetProgress as resetProgressApi, resetProgress as resetProgressApi,
@@ -57,6 +58,7 @@ import {
RESOURCE_CAP, RESOURCE_CAP,
applyTick, applyTick,
calculateClickPower, calculateClickPower,
computePartyCombatPower,
} from "../engine/tick.js"; } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js";
@@ -242,6 +244,11 @@ interface GameContextValue {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/** /**
* Click the crystal to earn gold. * Click the crystal to earn gold.
*/ */
@@ -574,6 +581,31 @@ interface GameContextValue {
*/ */
debugHardReset: ()=> Promise<void>; debugHardReset: ()=> Promise<void>;
/**
* Syncs any content added to the game after the player's save was created.
* @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;
}>;
/** /**
* Last auto-boss fight result — null until the first auto fight completes or * Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off. * when auto-boss is toggled off.
@@ -668,6 +700,7 @@ export const GameProvider = ({
const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ inGuild, setInGuild ] = useState(false);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string> Array<string>
>([]); >([]);
@@ -705,6 +738,7 @@ export const GameProvider = ({
setSchemaOutdated(data.schemaOutdated); setSchemaOutdated(data.schemaOutdated);
setSaveSchemaVersion(data.state.schemaVersion ?? 0); setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion); setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild);
// Fetch number format preference from profile (fire-and-forget, non-blocking) // Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`). void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -1045,11 +1079,7 @@ export const GameProvider = ({
return q.status === "active"; return q.status === "active";
}); });
if (!hasActiveQuest) { if (!hasActiveQuest) {
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total! const partyCombatPower = computePartyCombatPower(next);
const partyCombatPower = next.adventurers.reduce((total, a) => {
const power = total + a.combatPower;
return power * a.count;
}, 0);
const zoneOrder = new Map( const zoneOrder = new Map(
next.zones.map((z, index) => { next.zones.map((z, index) => {
return [ z.id, index ]; return [ z.id, index ];
@@ -1087,14 +1117,31 @@ export const GameProvider = ({
next.autoAdventurer === true next.autoAdventurer === true
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer") && 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. const [ bestAdventurer ] = next.adventurers.
filter((adventurer) => { filter((adventurer) => {
const cost const cost
= adventurer.baseCost * Math.pow(1.15, adventurer.count); = adventurer.baseCost * Math.pow(1.15, adventurer.count);
return adventurer.unlocked && next.resources.gold >= cost; const isMaxTier = adventurer.level === maxAdventurerLevel;
const withinCap
= isMaxTier || adventurer.count < autoBuyCap;
return (
adventurer.unlocked
&& next.resources.gold >= cost
&& withinCap
);
}). }).
sort((adventurerA, adventurerB) => { sort((adventurerA, adventurerB) => {
return adventurerB.combatPower - adventurerA.combatPower; return adventurerB.level - adventurerA.level;
}); });
if (bestAdventurer !== undefined) { if (bestAdventurer !== undefined) {
const purchaseCost const purchaseCost
@@ -1313,6 +1360,13 @@ export const GameProvider = ({
} }
return afterBoss; 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({ setAutoBossLastResult({
at: Date.now(), at: Date.now(),
bossName: bossName, bossName: bossName,
@@ -1846,14 +1900,6 @@ export const GameProvider = ({
if (previous?.exploration === undefined) { if (previous?.exploration === undefined) {
return previous; return previous;
} }
let materials = [ ...previous.exploration.materials ];
for (const request of recipe.requiredMaterials) {
materials = materials.map((mat) => {
return mat.materialId === request.materialId
? { ...mat, quantity: mat.quantity - request.quantity }
: mat;
});
}
return { return {
...previous, ...previous,
exploration: { exploration: {
@@ -1866,7 +1912,7 @@ export const GameProvider = ({
...previous.exploration.craftedRecipeIds, ...previous.exploration.craftedRecipeIds,
recipeId, recipeId,
], ],
materials: materials, materials: result.materials,
}, },
}; };
}); });
@@ -2151,6 +2197,63 @@ export const GameProvider = ({
} }
}, []); }, []);
const syncNewContent = useCallback(async() => {
try {
const data = await syncNewContentApi();
setState(data.state);
if (data.signature !== undefined) {
signatureReference.current = data.signature;
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,
};
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "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,
};
}
}, []);
const debugHardReset = useCallback(async() => { const debugHardReset = useCallback(async() => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -2228,6 +2331,7 @@ export const GameProvider = ({
forceUnlocks, forceUnlocks,
formatNumber, formatNumber,
handleClick, handleClick,
inGuild,
isLoading, isLoading,
isSyncing, isSyncing,
lastSavedAt, lastSavedAt,
@@ -2251,6 +2355,7 @@ export const GameProvider = ({
startQuest, startQuest,
state, state,
syncError, syncError,
syncNewContent,
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
@@ -2298,6 +2403,7 @@ export const GameProvider = ({
error, error,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
inGuild,
forceUnlocks, forceUnlocks,
handleClick, handleClick,
isLoading, isLoading,
@@ -2323,6 +2429,7 @@ export const GameProvider = ({
startQuest, startQuest,
state, state,
syncError, syncError,
syncNewContent,
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
+145
View File
@@ -195,6 +195,138 @@ export const computeGoldPerSecond = (state: GameState): number => {
return goldPerSecond; 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. * Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick. * DeltaSeconds: time elapsed since last tick.
@@ -469,6 +601,19 @@ export const applyTick = (
challengeCrystals = result.crystalsAwarded; 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 goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource( const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence, state.resources.essence + essenceGained + questEssence,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.3.0", "version": "0.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.3.0", "version": "0.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+2
View File
@@ -55,6 +55,7 @@ export type {
BuyPrestigeUpgradeResponse, BuyPrestigeUpgradeResponse,
CraftRecipeRequest, CraftRecipeRequest,
CraftRecipeResponse, CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectEventResult, ExploreCollectEventResult,
ExploreCollectRequest, ExploreCollectRequest,
ExploreCollectResponse, ExploreCollectResponse,
@@ -72,6 +73,7 @@ export type {
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,
+116
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- API types file grows with each new endpoint */
import type { import type {
EquipmentBonus, EquipmentBonus,
EquipmentRarity, EquipmentRarity,
@@ -69,6 +70,11 @@ interface LoginBonusResult {
interface LoadResponse { interface LoadResponse {
state: GameState; state: GameState;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/** /**
* Offline gold earned since last save (server-calculated). * Offline gold earned since last save (server-calculated).
*/ */
@@ -384,6 +390,10 @@ interface ExploreCollectResponse {
event: ExploreCollectEventResult | null; event: ExploreCollectEventResult | null;
} }
interface ExploreClaimableResponse {
claimable: boolean;
}
interface CraftRecipeRequest { interface CraftRecipeRequest {
recipeId: string; recipeId: string;
} }
@@ -396,6 +406,7 @@ interface CraftRecipeResponse {
craftedEssenceMultiplier: number; craftedEssenceMultiplier: number;
craftedClickMultiplier: number; craftedClickMultiplier: number;
craftedCombatMultiplier: number; craftedCombatMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
} }
interface ForceUnlocksResponse { interface ForceUnlocksResponse {
@@ -451,6 +462,109 @@ interface ForceUnlocksResponse {
signature?: string; signature?: string;
} }
interface SyncNewContentResponse {
/**
* The updated game state after injecting all missing content entries.
*/
state: GameState;
/**
* Number of adventurer tiers added to the save.
*/
adventurersAdded: number;
/**
* Number of existing adventurer entries whose stats were patched to match current defaults.
*/
adventurerStatsPatched: number;
/**
* Number of upgrades added to the save.
*/
upgradesAdded: number;
/**
* Number of rewards patched onto existing quests.
*/
questRewardsPatched: number;
/**
* Number of quests added to the save.
*/
questsAdded: number;
/**
* Number of bosses added to the save.
*/
bossesAdded: number;
/**
* Number of upgrade reward IDs patched onto existing bosses.
*/
bossRewardsPatched: number;
/**
* Number of equipment items added to the save.
*/
equipmentAdded: number;
/**
* Number of achievements added to the save.
*/
achievementsAdded: number;
/**
* Number of zones added to the save.
*/
zonesAdded: number;
/**
* Number of exploration areas added to the save.
*/
explorationAreasAdded: number;
/**
* Number of achievements whose stats were updated to match current defaults.
*/
achievementsPatched: number;
/**
* Number of bosses whose stats were updated to match current defaults.
*/
bossesPatched: number;
/**
* Number of crafted recipes whose multiplier contribution was reapplied during recompute.
*/
craftingRecipesReapplied: number;
/**
* Number of equipment items whose stats were updated to match current defaults.
*/
equipmentPatched: number;
/**
* Number of quests whose stats were updated to match current defaults.
*/
questsPatched: number;
/**
* Number of upgrades whose stats were updated to match current defaults.
*/
upgradesPatched: number;
/**
* Number of zones whose stats were updated to match current defaults.
*/
zonesPatched: number;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
}
export type { export type {
AboutResponse, AboutResponse,
ApiError, ApiError,
@@ -465,6 +579,7 @@ export type {
BuyPrestigeUpgradeResponse, BuyPrestigeUpgradeResponse,
CraftRecipeRequest, CraftRecipeRequest,
CraftRecipeResponse, CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectEventResult, ExploreCollectEventResult,
ExploreCollectRequest, ExploreCollectRequest,
ExploreCollectResponse, ExploreCollectResponse,
@@ -482,6 +597,7 @@ export type {
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,