5 Commits

Author SHA1 Message Date
hikari b3913cef52 fix: patch adventurer stats on sync so rebalances apply to existing saves
CI / Lint, Build & Test (pull_request) Failing after 1m0s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m5s
Sync New Content now updates baseCost, class, combatPower, essencePerSecond,
goldPerSecond, level, and name for all existing adventurer entries to match
the current defaults, while preserving count and unlocked state.

Closes #126
2026-03-24 14:46:34 -07:00
hikari 050e34e6cd test: add coverage for sync-new-content and explore claimable endpoints
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m4s
CI / Lint, Build & Test (pull_request) Successful in 1m10s
2026-03-24 13:18:15 -07:00
hikari e808d92909 fix: return authoritative materials from craft API to prevent client desync
CI / Lint, Build & Test (pull_request) Failing after 1m15s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m16s
Closes #128
2026-03-24 12:15:23 -07:00
hikari b85126c345 feat: poll server for exploration claimability before showing collect button
Resolves #127
2026-03-24 12:00:11 -07:00
hikari 0c7a5f50fc fix: guard against undefined counts in sync and force-unlock messages
Closes #125
2026-03-24 11:23:31 -07:00
23 changed files with 146 additions and 1265 deletions
-135
View File
@@ -1,135 +0,0 @@
# Vampire Expansion — Implementation TODO
Branch: `feat/expansions`
Thematic currency names:
- Gold → **Blood**
- Essence → **Ichor**
- Crystals → **Soul Shards**
- Runestones → **Bloodstones**
- Echoes → **Whispers**
- Click action → **Hunt**
- Adventurers → **Thralls**
- Prestige → **Siring** (working name)
- Transcendence → **The Awakening** (working name)
- Apotheosis → **Eternal Sovereignty** (role ID: 1486144657023959180)
CDN prefix for all vampire art: `https://cdn.nhcarrigan.com/elysium/vampire/<folder>/<id>.jpg`
Local scratch dir (delete before committing): `img/vampire/`
---
## Phase 1 — Types
- [ ] Add `VampireExpansionState` interface to `packages/types/src/interfaces/` mirroring full `GameState` structure (zones, bosses, quests, adventurers, upgrades, equipment, achievements, prestige, transcendence, apotheosis, exploration, resources, baseClickPower, lastTickAt, dailyChallenges, codex, autoQuest, autoBoss, autoAdventurer, companions, story)
- [ ] Add `ExpansionsState` interface: `{ vampire?: VampireExpansionState }`
- [ ] Add `expansions?: ExpansionsState` field to `GameState`
- [ ] Export new types from `packages/types/src/index.ts`
---
## Phase 2 — Data files (vampire content)
All data files go in `apps/api/src/data/vampire/`.
Same content scale as base game; use vampire theming throughout.
- [ ] `zones.ts` — 18 vampire-themed zones (crypts, blood forests, cursed castles, etc.)
- [ ] `bosses.ts` — 72 vampire-themed bosses (4 per zone)
- [ ] `quests.ts` — match base game quest count (~95); vampire-themed names/descriptions
- [ ] `adventurers.ts` — 32 thrall tiers with progressive stats
- [ ] `upgrades.ts` — match base game upgrade count (~57); vampire-themed
- [ ] `equipment.ts` — match base game equipment count (~53); vampire-themed sets
- [ ] `equipmentSets.ts` — vampire equipment sets
- [ ] `achievements.ts` — match base game count (~40); vampire-themed conditions
- [ ] `explorations.ts` — 72 areas across 18 vampire lore zones
- [ ] `materials.ts` — match base game material count (~54); vampire-themed
- [ ] `recipes.ts` — match base game recipe count (~36); vampire-themed
- [ ] `prestigeUpgrades.ts` — 25 "Siring" upgrades
- [ ] `transcendenceUpgrades.ts` — 15 "Awakening" upgrades
- [ ] `dailyChallenges.ts` — 10 vampire daily challenges
- [ ] `initialState.ts` — `initialVampireState()` function mirroring `initialGameState` structure
---
## Phase 3 — Art generation & CDN upload
For each category below, generate images via Gemini API (`gemini-3-pro-image-preview`),
save locally to `img/vampire/<folder>/`, upload to R2, then delete local files.
Use soft-shaded anime style; vampire/gothic aesthetic; crimson/black/dark purple palette.
- [ ] Zone banners (18) → `img/vampire/zones/` → CDN `vampire/zones/`
- [ ] Boss portraits (72) → `img/vampire/bosses/` → CDN `vampire/bosses/`
- [ ] Quest banners (match count) → `img/vampire/quests/` → CDN `vampire/quests/`
- [ ] Adventurer/thrall portraits (32) → `img/vampire/adventurers/` → CDN `vampire/adventurers/`
- [ ] Equipment icons (match count) → `img/vampire/equipment/` → CDN `vampire/equipment/`
- [ ] Achievement icons (match count) → `img/vampire/achievements/` → CDN `vampire/achievements/`
- [ ] Exploration area art (72) → `img/vampire/explorations/` → CDN `vampire/explorations/`
- [ ] Material icons (match count) → `img/vampire/materials/` → CDN `vampire/materials/`
- [ ] Story chapter banners (match count) → `img/vampire/story-chapters/` → CDN `vampire/story-chapters/`
---
## Phase 4 — API changes
- [ ] Add `inGuild` to Prisma `Player` model → update `initialGameState` if needed (already done in #134 — verify migration)
- [ ] Update Prisma schema: no DB changes needed (expansion state is inside the `GameState` JSON blob)
- [ ] Update `initialState.ts` to include `expansions: {}` in `initialGameState`
- [ ] Update `sync-new-content` debug route to inject/patch vampire expansion content when expansion is unlocked
- [ ] Add vampire-specific unlock trigger: when base-game apotheosis count ≥ 1, set `expansions.vampire` to `initialVampireState()` and `unlocked: true`
- [ ] Update the load endpoint to pass expansion state through to the client
- [ ] Ensure prestige/transcendence/apotheosis routes only reset state for their own expansion (base game routes must NOT touch `expansions.*`)
- [ ] Add vampire prestige, transcendence, and apotheosis routes (mirrors of base game routes, scoped to `expansions.vampire`)
- [ ] Grant `Eternal Sovereignty` role (ID: `1486144657023959180`) on vampire apotheosis
---
## Phase 5 — Frontend changes
### Expansion switcher
- [ ] Add expansion toggle buttons below the Early Access warning in the sidebar
- [ ] Always render all expansion buttons; disable any where `unlocked !== true`
- [ ] Active expansion stored in React state (not game state); defaults to `"base"`
- [ ] Switching expansion updates which data the UI panels display
### Resource bar
- [ ] Show ALL currencies from ALL expansions as separate labelled lines
- [ ] Vampire currencies use distinct icons/colours (crimson tint for blood, etc.)
- [ ] The "expand" button label shows the gold-equivalent currency of the active expansion
### Thematic UI
- [ ] When vampire expansion is active, swap labels: gold → Blood, essence → Ichor, etc.
- [ ] Apply `.vampire-mode` CSS class to game container when vampire is active
- [ ] Vampire colour palette: deep crimsons (`#5C0A1A`), rich crimson (`#C41E3A`), blacks, desaturated purples
### Tick engine
- [ ] Update `apps/web/src/engine/tick.ts` to compute passive income for all unlocked expansions every tick (not just base game)
- [ ] Offline income calculation must also cover all expansions
### Profile
- [ ] Profile panel: tab stats by expansion (base game tab + one tab per unlocked expansion)
- [ ] Show correct thematic prestige/transcendence/apotheosis badge names per expansion
- [ ] Lifetime stats (gold earned, clicks, etc.) tracked separately per expansion
### About / How to Play
- [ ] Update `aboutPanel.tsx` `HOW_TO_PLAY` array to document the expansion system
---
## Phase 6 — Tests & CI
- [ ] Unit tests for all new data files (at minimum, validate structure/required fields)
- [ ] Unit tests for `initialVampireState()`
- [ ] Tests for vampire unlock trigger route
- [ ] Tests for vampire prestige/transcendence/apotheosis routes
- [ ] Tests for updated tick engine (expansion income)
- [ ] Maintain 100% coverage on `apps/api` and `packages/types`
- [ ] Full pipeline: lint → build → test passing before PR
---
## Phase 7 — Final
- [ ] Delete `img/vampire/` directory before committing
- [ ] Update `MEMORY.md` with new content counts
- [ ] Open PR → request review
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
-1
View File
@@ -35,7 +35,6 @@ model Player {
lifetimeAchievementsUnlocked Float @default(0) lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String? lastLoginDate String?
loginStreak Int @default(1) loginStreak Int @default(1)
inGuild Boolean @default(false)
} }
model GameState { model GameState {
+4
View File
@@ -1,4 +1,6 @@
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret" DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret" JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url" DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret" ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
@@ -6,4 +8,6 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
-2
View File
@@ -21,7 +21,6 @@ import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js"; import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js"; import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js"; import { transcendenceRouter } from "./routes/transcendence.js";
import { connectGateway } from "./services/gateway.js";
import { logger } from "./services/logger.js"; import { logger } from "./services/logger.js";
const app = new Hono(); const app = new Hono();
@@ -69,7 +68,6 @@ const port = Number(process.env.PORT ?? 3001);
try { try {
serve({ fetch: app.fetch, port: port }, () => { serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`); process.stdout.write(`Elysium API running on port ${String(port)}\n`);
connectGateway();
}); });
} catch (error) { } catch (error) {
void logger.error( void logger.error(
-9
View File
@@ -16,7 +16,6 @@ import {
} from "../services/discord.js"; } from "../services/discord.js";
import { signToken } from "../services/jwt.js"; import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import { grantElysianRole } from "../services/webhook.js";
import type { Player } from "@elysium/types"; import type { Player } from "@elysium/types";
const authRouter = new Hono(); const authRouter = new Hono();
@@ -93,12 +92,6 @@ authRouter.get("/callback", async(context) => {
}, },
}); });
const inGuild = await grantElysianRole(player.discordId);
await prisma.player.update({
data: { inGuild },
where: { discordId: player.discordId },
});
const jwtToken = signToken(player.discordId); const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`); void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId }); void logger.metric("user_registered", 1, { discordId: player.discordId });
@@ -111,12 +104,10 @@ authRouter.get("/callback", async(context) => {
); );
} }
const inGuild = await grantElysianRole(discordUser.id);
const updated = await prisma.player.update({ const updated = await prisma.player.update({
data: { data: {
avatar: discordUser.avatar, avatar: discordUser.avatar,
discriminator: discordUser.discriminator, discriminator: discordUser.discriminator,
inGuild: inGuild,
username: discordUser.username, username: discordUser.username,
}, },
where: { discordId: discordUser.id }, where: { discordId: discordUser.id },
+11 -267
View File
@@ -20,7 +20,6 @@ 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 { defaultUpgrades } from "../data/upgrades.js";
import { defaultZones } from "../data/zones.js"; import { defaultZones } from "../data/zones.js";
@@ -654,226 +653,6 @@ const patchAdventurerStats = (state: GameState): number => {
return patched; 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;
}
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;
}
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.
*/
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;
}
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;
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;
}
savedZone.name = defaultZone.name;
savedZone.description = defaultZone.description;
savedZone.emoji = defaultZone.emoji;
savedZone.unlockBossId = defaultZone.unlockBossId;
savedZone.unlockQuestId = defaultZone.unlockQuestId;
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.
*/
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;
}
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;
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.
*/
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;
}
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;
}
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;
}
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 };
}
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 */ /* 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 * Syncs a player's save with the current game data, injecting any content
@@ -885,33 +664,19 @@ const recomputeCraftingMultipliers = (state: GameState): number => {
const syncNewContent = ( const syncNewContent = (
state: GameState, state: GameState,
): { ): {
achievementsAdded: number; achievementsAdded: number;
achievementsPatched: number; adventurersAdded: number;
adventurersAdded: number; adventurerStatsPatched: number;
adventurerStatsPatched: number; bossesAdded: number;
bossesAdded: number; bossRewardsPatched: number;
bossesPatched: number; equipmentAdded: number;
bossRewardsPatched: number; explorationAreasAdded: number;
craftingRecipesReapplied: number; questRewardsPatched: number;
equipmentAdded: number; questsAdded: number;
equipmentPatched: number; upgradesAdded: number;
explorationAreasAdded: number; zonesAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
} => { } => {
const adventurerStatsPatched = patchAdventurerStats(state); 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 achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers); const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
const bossRewardsPatched = patchBossUpgradeRewards(state); const bossRewardsPatched = patchBossUpgradeRewards(state);
@@ -924,23 +689,16 @@ const syncNewContent = (
const zonesAdded = injectMissingEntries(state.zones, defaultZones); const zonesAdded = injectMissingEntries(state.zones, defaultZones);
return { return {
achievementsAdded, achievementsAdded,
achievementsPatched,
adventurerStatsPatched, adventurerStatsPatched,
adventurersAdded, adventurersAdded,
bossRewardsPatched, bossRewardsPatched,
bossesAdded, bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded, equipmentAdded,
equipmentPatched,
explorationAreasAdded, explorationAreasAdded,
questRewardsPatched, questRewardsPatched,
questsAdded, questsAdded,
questsPatched,
upgradesAdded, upgradesAdded,
upgradesPatched,
zonesAdded, zonesAdded,
zonesPatched,
}; };
}; };
/* eslint-enable stylistic/max-len -- Re-enable after long lines */ /* eslint-enable stylistic/max-len -- Re-enable after long lines */
@@ -1025,23 +783,16 @@ debugRouter.post("/sync-new-content", async(context) => {
const { const {
achievementsAdded, achievementsAdded,
achievementsPatched,
adventurersAdded, adventurersAdded,
adventurerStatsPatched, adventurerStatsPatched,
bossesAdded, bossesAdded,
bossesPatched,
bossRewardsPatched, bossRewardsPatched,
craftingRecipesReapplied,
equipmentAdded, equipmentAdded,
equipmentPatched,
explorationAreasAdded, explorationAreasAdded,
questRewardsPatched, questRewardsPatched,
questsAdded, questsAdded,
questsPatched,
upgradesAdded, upgradesAdded,
upgradesPatched,
zonesAdded, zonesAdded,
zonesPatched,
} = syncNewContent(state); } = syncNewContent(state);
const updatedAt = Date.now(); const updatedAt = Date.now();
@@ -1059,25 +810,18 @@ debugRouter.post("/sync-new-content", async(context) => {
return context.json({ return context.json({
achievementsAdded, achievementsAdded,
achievementsPatched,
adventurerStatsPatched, adventurerStatsPatched,
adventurersAdded, adventurersAdded,
bossRewardsPatched, bossRewardsPatched,
bossesAdded, bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded, equipmentAdded,
equipmentPatched,
explorationAreasAdded, explorationAreasAdded,
questRewardsPatched, questRewardsPatched,
questsAdded, questsAdded,
questsPatched,
signature, signature,
state, state,
upgradesAdded, upgradesAdded,
upgradesPatched,
zonesAdded, zonesAdded,
zonesPatched,
}); });
} catch (error) { } catch (error) {
void logger.error( void logger.error(
-3
View File
@@ -760,7 +760,6 @@ gameRouter.get("/load", async(context) => {
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion: currentSchemaVersion,
inGuild: playerRecord.inGuild,
loginBonus: null, loginBonus: null,
loginStreak: playerRecord.loginStreak, loginStreak: playerRecord.loginStreak,
offlineEssence: 0, offlineEssence: 0,
@@ -899,10 +898,8 @@ gameRouter.get("/load", async(context) => {
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(state), secret); : computeHmac(JSON.stringify(state), secret);
const inGuild = playerRecord?.inGuild ?? false;
return context.json({ return context.json({
currentSchemaVersion, currentSchemaVersion,
inGuild,
loginBonus, loginBonus,
loginStreak, loginStreak,
offlineEssence, offlineEssence,
+21 -8
View File
@@ -7,9 +7,6 @@
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js"; import { logger } from "./logger.js";
const discordClientId = "1479551654264049908";
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
interface DiscordTokenResponse { interface DiscordTokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
@@ -34,18 +31,24 @@ interface DiscordUser {
const exchangeCode = async( const exchangeCode = async(
code: string, code: string,
): Promise<DiscordTokenResponse> => { ): Promise<DiscordTokenResponse> => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET; const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (clientSecret === undefined || clientSecret === "") { if (
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required"); throw new Error("Discord OAuth environment variables are required");
} }
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
client_id: discordClientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
code: code, code: code,
grant_type: "authorization_code", grant_type: "authorization_code",
redirect_uri: discordRedirectUri, redirect_uri: redirectUri,
}); });
try { try {
@@ -143,9 +146,19 @@ const fetchDiscordUserById = async(
* @throws {Error} If OAuth environment variables are missing. * @throws {Error} If OAuth environment variables are missing.
*/ */
const buildOAuthUrl = (): string => { const buildOAuthUrl = (): string => {
const clientId = process.env.DISCORD_CLIENT_ID;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (
clientId === undefined || clientId === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({ const parameters = new URLSearchParams({
client_id: discordClientId, client_id: clientId,
redirect_uri: discordRedirectUri, redirect_uri: redirectUri,
response_type: "code", response_type: "code",
scope: "identify", scope: "identify",
}); });
-182
View File
@@ -1,182 +0,0 @@
/**
* @file Discord Gateway WebSocket client for listening to guild member events.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */
import { prisma } from "../db/client.js";
import { logger } from "./logger.js";
const discordGuildId = "1354624415861833870";
/**
* Discord Gateway opcodes used by this client.
*/
const gatewayOpcodes = {
dispatch: 0,
heartbeat: 1,
heartbeatAck: 11,
hello: 10,
identify: 2,
} as const;
/**
* GUILD_MEMBERS privileged intent bitmask.
*/
/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */
const guildMembersIntent = 1 << 1;
/**
* Updates the inGuild flag for a player when they join the configured guild.
* No-ops silently if the Discord user has no player record.
* @param discordId - The Discord user ID of the member who joined.
* @param guildId - The ID of the guild they joined.
* @returns A promise that resolves when the update attempt completes.
*/
const handleGuildMemberAdd = async(
discordId: string,
guildId: string,
): Promise<void> => {
if (guildId !== discordGuildId) {
return;
}
try {
await prisma.player.updateMany({
data: { inGuild: true },
where: { discordId },
});
} catch (error) {
void logger.error(
"gateway_member_add",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
/**
* Updates the inGuild flag for a player when they leave the configured guild.
* No-ops silently if the Discord user has no player record.
* @param discordId - The Discord user ID of the member who left.
* @param guildId - The ID of the guild they left.
* @returns A promise that resolves when the update attempt completes.
*/
const handleGuildMemberRemove = async(
discordId: string,
guildId: string,
): Promise<void> => {
if (guildId !== discordGuildId) {
return;
}
try {
await prisma.player.updateMany({
data: { inGuild: false },
where: { discordId },
});
} catch (error) {
void logger.error(
"gateway_member_remove",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase
/* v8 ignore next 95 -- @preserve */
/**
* Connects to the Discord Gateway and listens for guild member events.
* Reconnects automatically on close or error.
* Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal.
*/
const connectGateway = (): void => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
void logger.log("info", "Gateway: no bot token configured, skipping");
return;
}
const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json");
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let lastSequence: number | null = null;
const stopHeartbeat = (): void => {
if (heartbeatInterval !== null) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
};
ws.addEventListener("message", (event) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */
const payload = JSON.parse(event.data as string) as {
op: number;
d: unknown;
s: number | null;
t: string | null;
};
if (payload.s !== null) {
lastSequence = payload.s;
}
if (payload.op === gatewayOpcodes.hello) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */
const helloData = payload.d as { heartbeat_interval: number };
const heartbeatMs = helloData.heartbeat_interval;
heartbeatInterval = setInterval(() => {
ws.send(JSON.stringify({
d: lastSequence,
op: gatewayOpcodes.heartbeat,
}));
}, heartbeatMs);
ws.send(JSON.stringify({
d: {
intents: guildMembersIntent,
properties: { browser: "elysium", device: "elysium", os: "linux" },
token: botToken,
},
op: gatewayOpcodes.identify,
}));
}
if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */
const data = payload.d as { user?: { id: string }; guild_id?: string };
const discordId = data.user?.id;
const guildId = data.guild_id;
if (discordId === undefined || guildId === undefined) {
return;
}
if (payload.t === "GUILD_MEMBER_ADD") {
void handleGuildMemberAdd(discordId, guildId);
} else if (payload.t === "GUILD_MEMBER_REMOVE") {
void handleGuildMemberRemove(discordId, guildId);
}
}
});
ws.addEventListener("close", () => {
stopHeartbeat();
void logger.log("info", "Gateway: connection closed, reconnecting in 5s");
setTimeout(connectGateway, 5000);
});
ws.addEventListener("error", (event) => {
const message
= event instanceof ErrorEvent
? event.message
: "WebSocket error";
void logger.error("gateway_error", new Error(message));
stopHeartbeat();
ws.close();
});
};
export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove };
+11 -51
View File
@@ -15,49 +15,6 @@ const discordApi = "https://discord.com/api/v10";
*/ */
const suppressNotifications = 4096; const suppressNotifications = 4096;
/**
* The Discord role ID for the Elysian role granted to all Elysium players.
*/
const discordGuildId = "1354624415861833870";
const elysianRoleId = "1486144823684628490";
const apotheosisRoleId = "1479966598210129991";
/**
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
* Fails silently so role grant errors do not affect the auth flow.
* @param discordId - The Discord user ID to grant the role to.
* @returns True if the player is in the guild and the role was granted, false otherwise.
*/
const grantElysianRole = async(discordId: string): Promise<boolean> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return false;
}
try {
const response = await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
return response.ok || response.status === 204;
} catch (error) {
void logger.error(
"webhook_elysian_role",
error instanceof Error
? error
: new Error(String(error)),
);
return false;
}
};
/** /**
* Grants the apotheosis Discord role to the given player if configured. * Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action. * Fails silently so role grant errors do not affect the game action.
@@ -66,20 +23,23 @@ const grantElysianRole = async(discordId: string): Promise<boolean> => {
*/ */
const grantApotheosisRole = async(discordId: string): Promise<void> => { const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN; const botToken = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
if (botToken === undefined || botToken === "") { if (
botToken === undefined || botToken === ""
|| guildId === undefined || guildId === ""
|| roleId === undefined || roleId === ""
) {
return; return;
} }
try { try {
await fetch( await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`, `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
{ {
headers: { headers: { Authorization: `Bot ${botToken}` },
"Authorization": `Bot ${botToken}`, method: "PUT",
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
}, },
); );
} catch (error) { } catch (error) {
@@ -149,4 +109,4 @@ const postMilestoneWebhook = async(
} }
}; };
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook }; export { grantApotheosisRole, postMilestoneWebhook };
-235
View File
@@ -750,32 +750,6 @@ describe("debug route", () => {
expect(res.status).toBe(200); 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 () => { it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret"; process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState(); const state = makeState();
@@ -799,215 +773,6 @@ describe("debug route", () => {
const res = await syncNewContent(); const res = await syncNewContent();
expect(res.status).toBe(500); 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", () => {
+25 -3
View File
@@ -18,31 +18,51 @@ describe("discord service", () => {
}); });
describe("buildOAuthUrl", () => { describe("buildOAuthUrl", () => {
it("throws when DISCORD_CLIENT_ID is missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
delete process.env["DISCORD_REDIRECT_URI"];
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("returns a URL with correct query params", async () => { it("returns a URL with correct query params", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js"); const { buildOAuthUrl } = await import("../../src/services/discord.js");
const url = buildOAuthUrl(); const url = buildOAuthUrl();
expect(url).toContain("client_id=1479551654264049908"); expect(url).toContain("client_id=client123");
expect(url).toContain("response_type=code"); expect(url).toContain("response_type=code");
expect(url).toContain("scope=identify"); expect(url).toContain("scope=identify");
}); });
}); });
describe("exchangeCode", () => { describe("exchangeCode", () => {
it("throws when DISCORD_CLIENT_SECRET is missing", async () => { it("throws when env vars are missing", async () => {
delete process.env["DISCORD_CLIENT_SECRET"]; delete process.env["DISCORD_CLIENT_ID"];
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required"); await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
}); });
it("throws when response is not ok", async () => { it("throws when response is not ok", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" }); mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed"); await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
}); });
it("returns parsed body on success", async () => { it("returns parsed body on success", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" }; const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
@@ -76,7 +96,9 @@ describe("discord service", () => {
describe("exchangeCode non-Error throw", () => { describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => { it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret"; process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockRejectedValueOnce("raw string error"); mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js"); const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
-105
View File
@@ -1,105 +0,0 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { updateMany: vi.fn() },
},
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
log: vi.fn().mockResolvedValue(undefined),
},
}));
import { prisma } from "../../src/db/client.js";
const discordGuildId = "1354624415861833870";
describe("gateway service", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("handleGuildMemberAdd", () => {
it("sets inGuild to true for the matching guild", async () => {
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(prisma.player.updateMany).toHaveBeenCalledWith({
data: { inGuild: true },
where: { discordId: "user123" },
});
});
it("no-ops when guild id does not match the configured guild", async () => {
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
await handleGuildMemberAdd("user123", "other_guild");
expect(prisma.player.updateMany).not.toHaveBeenCalled();
});
it("logs error when prisma throws an Error", async () => {
const dbError = new Error("DB failure");
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
});
it("logs error when prisma throws a non-Error", async () => {
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith(
"gateway_member_add",
new Error("raw error"),
);
});
});
describe("handleGuildMemberRemove", () => {
it("sets inGuild to false for the matching guild", async () => {
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(prisma.player.updateMany).toHaveBeenCalledWith({
data: { inGuild: false },
where: { discordId: "user123" },
});
});
it("no-ops when guild id does not match the configured guild", async () => {
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
await handleGuildMemberRemove("user123", "other_guild");
expect(prisma.player.updateMany).not.toHaveBeenCalled();
});
it("logs error when prisma throws an Error", async () => {
const dbError = new Error("DB failure");
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
});
it("logs error when prisma throws a non-Error", async () => {
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith(
"gateway_member_remove",
new Error("raw error"),
);
});
});
});
+29 -60
View File
@@ -20,20 +20,42 @@ describe("webhook service", () => {
describe("grantApotheosisRole", () => { describe("grantApotheosisRole", () => {
it("does nothing when bot token is missing", async () => { it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"]; delete process.env["DISCORD_BOT_TOKEN"];
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123"); await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
it("calls Discord API with correct URL and auth when bot token is set", async () => { it("does nothing when guild id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
delete process.env["DISCORD_GUILD_ID"];
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when role id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
process.env["DISCORD_GUILD_ID"] = "guild123";
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("calls Discord API with correct URL and auth when env vars are set", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token"; process.env["DISCORD_BOT_TOKEN"] = "bot_token";
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
mockFetch.mockResolvedValueOnce({ ok: true }); mockFetch.mockResolvedValueOnce({ ok: true });
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user789"); await grantApotheosisRole("user789");
expect(mockFetch).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991", "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
expect.objectContaining({ expect.objectContaining({
method: "PUT", method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }), headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}), }),
); );
@@ -41,6 +63,8 @@ describe("webhook service", () => {
it("swallows fetch errors gracefully", async () => { it("swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce(new Error("Network error")); mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
@@ -48,69 +72,14 @@ describe("webhook service", () => {
it("swallows non-Error fetch rejections gracefully", async () => { it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error"); mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
}); });
}); });
describe("grantElysianRole", () => {
it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("returns true when Discord API responds with ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user789");
expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
expect(result).toBe(true);
});
it("returns true when Discord API responds with 204", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(true);
});
it("returns false when Discord API responds with an error status", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows non-Error fetch rejections", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
});
describe("postMilestoneWebhook", () => { describe("postMilestoneWebhook", () => {
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 }; const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+11 -25
View File
@@ -13,24 +13,17 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null; type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
interface SyncNewContentResult { interface SyncNewContentResult {
achievementsAdded: number | undefined; achievementsAdded: number | undefined;
achievementsPatched: number | undefined; adventurersAdded: number | undefined;
adventurersAdded: number | undefined; adventurerStatsPatched: number | undefined;
adventurerStatsPatched: number | undefined; bossesAdded: number | undefined;
bossesAdded: number | undefined; bossRewardsPatched: number | undefined;
bossesPatched: number | undefined; equipmentAdded: number | undefined;
bossRewardsPatched: number | undefined; explorationAreasAdded: number | undefined;
craftingRecipesReapplied: number | undefined; questRewardsPatched: number | undefined;
equipmentAdded: number | undefined; questsAdded: number | undefined;
equipmentPatched: number | undefined; upgradesAdded: number | undefined;
explorationAreasAdded: number | undefined; zonesAdded: 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 => { const safeNumber = (value: number | undefined): number => {
@@ -55,13 +48,6 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
[ safeNumber(result.upgradesAdded), "upgrade(s)" ], [ safeNumber(result.upgradesAdded), "upgrade(s)" ],
[ safeNumber(result.equipmentAdded), "equipment item(s)" ], [ safeNumber(result.equipmentAdded), "equipment item(s)" ],
[ safeNumber(result.achievementsAdded), "achievement(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. const parts = entries.
filter(([ count ]) => { filter(([ count ]) => {
@@ -27,7 +27,6 @@ import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js"; import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
import { JoinCommunityModal } from "./joinCommunityModal.js";
import { LoginBonusModal } from "./loginBonusModal.js"; import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js"; import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
@@ -165,7 +164,6 @@ const GameLayout = (): JSX.Element => {
transcendenceCount={state.transcendence?.count ?? 0} transcendenceCount={state.transcendence?.count ?? 0}
/> />
<OfflineModal /> <OfflineModal />
<JoinCommunityModal />
{schemaOutdated && !dismissedOutdatedWarning {schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} /> ? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null} : null}
@@ -1,70 +0,0 @@
/**
* @file Modal prompting players to join the NHCarrigan Discord community.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { useCallback, useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
const sessionKey = "elysium_join_community_dismissed";
/**
* Renders a modal prompting the player to join the NHCarrigan Discord server.
* Shown once per session when the player is not already in the guild.
* @returns The JSX element or null if the player is in the guild or dismissed.
*/
const JoinCommunityModal = (): JSX.Element | null => {
const { inGuild } = useGame();
const [ dismissed, setDismissed ] = useState(
() => {
return sessionStorage.getItem(sessionKey) === "true";
},
);
const handleDismiss = useCallback((): void => {
sessionStorage.setItem(sessionKey, "true");
setDismissed(true);
}, []);
if (inGuild || dismissed) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal">
<h2>{"Join Our Community!"}</h2>
<p>
{"Did you know Elysium has an active Discord community? "}
{"Join to chat with other players, get updates, and earn "}
{"the exclusive Elysian role!"}
</p>
<p className="modal-note">
{"You already earn the Elysian role just by playing — "}
{"joining lets us show it off in the server!"}
</p>
<div className="modal-actions">
<a
className="modal-close-button"
href="https://discord.gg/KKe7BaEnQB"
onClick={handleDismiss}
rel="noreferrer"
target="_blank"
>
{"Join Discord"}
</a>
<button
className="modal-close-button"
onClick={handleDismiss}
type="button"
>
{"Maybe later"}
</button>
</div>
</div>
</div>
);
};
export { JoinCommunityModal };
+30 -63
View File
@@ -243,11 +243,6 @@ interface GameContextValue {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/** /**
* Click the crystal to earn gold. * Click the crystal to earn gold.
*/ */
@@ -585,24 +580,16 @@ interface GameContextValue {
* @returns Counts of what was added per content type. * @returns Counts of what was added per content type.
*/ */
syncNewContent: ()=> Promise<{ syncNewContent: ()=> Promise<{
achievementsAdded: number; achievementsAdded: number;
achievementsPatched: number; adventurersAdded: number;
adventurerStatsPatched: number; bossesAdded: number;
adventurersAdded: number; bossRewardsPatched: number;
bossRewardsPatched: number; equipmentAdded: number;
bossesAdded: number; explorationAreasAdded: number;
bossesPatched: number; questRewardsPatched: number;
craftingRecipesReapplied: number; questsAdded: number;
equipmentAdded: number; upgradesAdded: number;
equipmentPatched: number; zonesAdded: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
}>; }>;
/** /**
@@ -699,7 +686,6 @@ export const GameProvider = ({
const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ inGuild, setInGuild ] = useState(false);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string> Array<string>
>([]); >([]);
@@ -737,7 +723,6 @@ export const GameProvider = ({
setSchemaOutdated(data.schemaOutdated); setSchemaOutdated(data.schemaOutdated);
setSaveSchemaVersion(data.state.schemaVersion ?? 0); setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion); setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild);
// Fetch number format preference from profile (fire-and-forget, non-blocking) // Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`). void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -2185,24 +2170,16 @@ export const GameProvider = ({
localStorage.setItem("elysium_save_signature", data.signature); localStorage.setItem("elysium_save_signature", data.signature);
} }
return { return {
achievementsAdded: data.achievementsAdded, achievementsAdded: data.achievementsAdded,
achievementsPatched: data.achievementsPatched, adventurersAdded: data.adventurersAdded,
adventurerStatsPatched: data.adventurerStatsPatched, bossRewardsPatched: data.bossRewardsPatched,
adventurersAdded: data.adventurersAdded, bossesAdded: data.bossesAdded,
bossRewardsPatched: data.bossRewardsPatched, equipmentAdded: data.equipmentAdded,
bossesAdded: data.bossesAdded, explorationAreasAdded: data.explorationAreasAdded,
bossesPatched: data.bossesPatched, questRewardsPatched: data.questRewardsPatched,
craftingRecipesReapplied: data.craftingRecipesReapplied, questsAdded: data.questsAdded,
equipmentAdded: data.equipmentAdded, upgradesAdded: data.upgradesAdded,
equipmentPatched: data.equipmentPatched, zonesAdded: data.zonesAdded,
explorationAreasAdded: data.explorationAreasAdded,
questRewardsPatched: data.questRewardsPatched,
questsAdded: data.questsAdded,
questsPatched: data.questsPatched,
upgradesAdded: data.upgradesAdded,
upgradesPatched: data.upgradesPatched,
zonesAdded: data.zonesAdded,
zonesPatched: data.zonesPatched,
}; };
} catch (error_: unknown) { } catch (error_: unknown) {
setError( setError(
@@ -2211,24 +2188,16 @@ export const GameProvider = ({
: "Failed to sync new content", : "Failed to sync new content",
); );
return { return {
achievementsAdded: 0, achievementsAdded: 0,
achievementsPatched: 0, adventurersAdded: 0,
adventurerStatsPatched: 0, bossRewardsPatched: 0,
adventurersAdded: 0, bossesAdded: 0,
bossRewardsPatched: 0, equipmentAdded: 0,
bossesAdded: 0, explorationAreasAdded: 0,
bossesPatched: 0, questRewardsPatched: 0,
craftingRecipesReapplied: 0, questsAdded: 0,
equipmentAdded: 0, upgradesAdded: 0,
equipmentPatched: 0, zonesAdded: 0,
explorationAreasAdded: 0,
questRewardsPatched: 0,
questsAdded: 0,
questsPatched: 0,
upgradesAdded: 0,
upgradesPatched: 0,
zonesAdded: 0,
zonesPatched: 0,
}; };
} }
}, []); }, []);
@@ -2310,7 +2279,6 @@ export const GameProvider = ({
forceUnlocks, forceUnlocks,
formatNumber, formatNumber,
handleClick, handleClick,
inGuild,
isLoading, isLoading,
isSyncing, isSyncing,
lastSavedAt, lastSavedAt,
@@ -2382,7 +2350,6 @@ export const GameProvider = ({
error, error,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
inGuild,
forceUnlocks, forceUnlocks,
handleClick, handleClick,
isLoading, isLoading,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.3.2", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
-40
View File
@@ -70,11 +70,6 @@ interface LoginBonusResult {
interface LoadResponse { interface LoadResponse {
state: GameState; state: GameState;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/** /**
* Offline gold earned since last save (server-calculated). * Offline gold earned since last save (server-calculated).
*/ */
@@ -524,41 +519,6 @@ interface SyncNewContentResponse {
*/ */
explorationAreasAdded: number; 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. * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/ */