generated from nhcarrigan/template
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eec93e442b
|
|||
|
9926e7f639
|
|||
| 6bf1ac5e7d | |||
| b48beef474 | |||
| 6e573bea14 | |||
|
790d35420f
|
@@ -0,0 +1,135 @@
|
|||||||
|
# Vampire Expansion — Implementation TODO
|
||||||
|
|
||||||
|
Branch: `feat/expansions`
|
||||||
|
|
||||||
|
Thematic currency names:
|
||||||
|
- Gold → **Blood**
|
||||||
|
- Essence → **Ichor**
|
||||||
|
- Crystals → **Soul Shards**
|
||||||
|
- Runestones → **Bloodstones**
|
||||||
|
- Echoes → **Whispers**
|
||||||
|
- Click action → **Hunt**
|
||||||
|
- Adventurers → **Thralls**
|
||||||
|
- Prestige → **Siring** (working name)
|
||||||
|
- Transcendence → **The Awakening** (working name)
|
||||||
|
- Apotheosis → **Eternal Sovereignty** (role ID: 1486144657023959180)
|
||||||
|
|
||||||
|
CDN prefix for all vampire art: `https://cdn.nhcarrigan.com/elysium/vampire/<folder>/<id>.jpg`
|
||||||
|
Local scratch dir (delete before committing): `img/vampire/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Types
|
||||||
|
|
||||||
|
- [ ] Add `VampireExpansionState` interface to `packages/types/src/interfaces/` mirroring full `GameState` structure (zones, bosses, quests, adventurers, upgrades, equipment, achievements, prestige, transcendence, apotheosis, exploration, resources, baseClickPower, lastTickAt, dailyChallenges, codex, autoQuest, autoBoss, autoAdventurer, companions, story)
|
||||||
|
- [ ] Add `ExpansionsState` interface: `{ vampire?: VampireExpansionState }`
|
||||||
|
- [ ] Add `expansions?: ExpansionsState` field to `GameState`
|
||||||
|
- [ ] Export new types from `packages/types/src/index.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Data files (vampire content)
|
||||||
|
|
||||||
|
All data files go in `apps/api/src/data/vampire/`.
|
||||||
|
Same content scale as base game; use vampire theming throughout.
|
||||||
|
|
||||||
|
- [ ] `zones.ts` — 18 vampire-themed zones (crypts, blood forests, cursed castles, etc.)
|
||||||
|
- [ ] `bosses.ts` — 72 vampire-themed bosses (4 per zone)
|
||||||
|
- [ ] `quests.ts` — match base game quest count (~95); vampire-themed names/descriptions
|
||||||
|
- [ ] `adventurers.ts` — 32 thrall tiers with progressive stats
|
||||||
|
- [ ] `upgrades.ts` — match base game upgrade count (~57); vampire-themed
|
||||||
|
- [ ] `equipment.ts` — match base game equipment count (~53); vampire-themed sets
|
||||||
|
- [ ] `equipmentSets.ts` — vampire equipment sets
|
||||||
|
- [ ] `achievements.ts` — match base game count (~40); vampire-themed conditions
|
||||||
|
- [ ] `explorations.ts` — 72 areas across 18 vampire lore zones
|
||||||
|
- [ ] `materials.ts` — match base game material count (~54); vampire-themed
|
||||||
|
- [ ] `recipes.ts` — match base game recipe count (~36); vampire-themed
|
||||||
|
- [ ] `prestigeUpgrades.ts` — 25 "Siring" upgrades
|
||||||
|
- [ ] `transcendenceUpgrades.ts` — 15 "Awakening" upgrades
|
||||||
|
- [ ] `dailyChallenges.ts` — 10 vampire daily challenges
|
||||||
|
- [ ] `initialState.ts` — `initialVampireState()` function mirroring `initialGameState` structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Art generation & CDN upload
|
||||||
|
|
||||||
|
For each category below, generate images via Gemini API (`gemini-3-pro-image-preview`),
|
||||||
|
save locally to `img/vampire/<folder>/`, upload to R2, then delete local files.
|
||||||
|
|
||||||
|
Use soft-shaded anime style; vampire/gothic aesthetic; crimson/black/dark purple palette.
|
||||||
|
|
||||||
|
- [ ] Zone banners (18) → `img/vampire/zones/` → CDN `vampire/zones/`
|
||||||
|
- [ ] Boss portraits (72) → `img/vampire/bosses/` → CDN `vampire/bosses/`
|
||||||
|
- [ ] Quest banners (match count) → `img/vampire/quests/` → CDN `vampire/quests/`
|
||||||
|
- [ ] Adventurer/thrall portraits (32) → `img/vampire/adventurers/` → CDN `vampire/adventurers/`
|
||||||
|
- [ ] Equipment icons (match count) → `img/vampire/equipment/` → CDN `vampire/equipment/`
|
||||||
|
- [ ] Achievement icons (match count) → `img/vampire/achievements/` → CDN `vampire/achievements/`
|
||||||
|
- [ ] Exploration area art (72) → `img/vampire/explorations/` → CDN `vampire/explorations/`
|
||||||
|
- [ ] Material icons (match count) → `img/vampire/materials/` → CDN `vampire/materials/`
|
||||||
|
- [ ] Story chapter banners (match count) → `img/vampire/story-chapters/` → CDN `vampire/story-chapters/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — API changes
|
||||||
|
|
||||||
|
- [ ] Add `inGuild` to Prisma `Player` model → update `initialGameState` if needed (already done in #134 — verify migration)
|
||||||
|
- [ ] Update Prisma schema: no DB changes needed (expansion state is inside the `GameState` JSON blob)
|
||||||
|
- [ ] Update `initialState.ts` to include `expansions: {}` in `initialGameState`
|
||||||
|
- [ ] Update `sync-new-content` debug route to inject/patch vampire expansion content when expansion is unlocked
|
||||||
|
- [ ] Add vampire-specific unlock trigger: when base-game apotheosis count ≥ 1, set `expansions.vampire` to `initialVampireState()` and `unlocked: true`
|
||||||
|
- [ ] Update the load endpoint to pass expansion state through to the client
|
||||||
|
- [ ] Ensure prestige/transcendence/apotheosis routes only reset state for their own expansion (base game routes must NOT touch `expansions.*`)
|
||||||
|
- [ ] Add vampire prestige, transcendence, and apotheosis routes (mirrors of base game routes, scoped to `expansions.vampire`)
|
||||||
|
- [ ] Grant `Eternal Sovereignty` role (ID: `1486144657023959180`) on vampire apotheosis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Frontend changes
|
||||||
|
|
||||||
|
### Expansion switcher
|
||||||
|
- [ ] Add expansion toggle buttons below the Early Access warning in the sidebar
|
||||||
|
- [ ] Always render all expansion buttons; disable any where `unlocked !== true`
|
||||||
|
- [ ] Active expansion stored in React state (not game state); defaults to `"base"`
|
||||||
|
- [ ] Switching expansion updates which data the UI panels display
|
||||||
|
|
||||||
|
### Resource bar
|
||||||
|
- [ ] Show ALL currencies from ALL expansions as separate labelled lines
|
||||||
|
- [ ] Vampire currencies use distinct icons/colours (crimson tint for blood, etc.)
|
||||||
|
- [ ] The "expand" button label shows the gold-equivalent currency of the active expansion
|
||||||
|
|
||||||
|
### Thematic UI
|
||||||
|
- [ ] When vampire expansion is active, swap labels: gold → Blood, essence → Ichor, etc.
|
||||||
|
- [ ] Apply `.vampire-mode` CSS class to game container when vampire is active
|
||||||
|
- [ ] Vampire colour palette: deep crimsons (`#5C0A1A`), rich crimson (`#C41E3A`), blacks, desaturated purples
|
||||||
|
|
||||||
|
### Tick engine
|
||||||
|
- [ ] Update `apps/web/src/engine/tick.ts` to compute passive income for all unlocked expansions every tick (not just base game)
|
||||||
|
- [ ] Offline income calculation must also cover all expansions
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
- [ ] Profile panel: tab stats by expansion (base game tab + one tab per unlocked expansion)
|
||||||
|
- [ ] Show correct thematic prestige/transcendence/apotheosis badge names per expansion
|
||||||
|
- [ ] Lifetime stats (gold earned, clicks, etc.) tracked separately per expansion
|
||||||
|
|
||||||
|
### About / How to Play
|
||||||
|
- [ ] Update `aboutPanel.tsx` `HOW_TO_PLAY` array to document the expansion system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Tests & CI
|
||||||
|
|
||||||
|
- [ ] Unit tests for all new data files (at minimum, validate structure/required fields)
|
||||||
|
- [ ] Unit tests for `initialVampireState()`
|
||||||
|
- [ ] Tests for vampire unlock trigger route
|
||||||
|
- [ ] Tests for vampire prestige/transcendence/apotheosis routes
|
||||||
|
- [ ] Tests for updated tick engine (expansion income)
|
||||||
|
- [ ] Maintain 100% coverage on `apps/api` and `packages/types`
|
||||||
|
- [ ] Full pipeline: lint → build → test passing before PR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Final
|
||||||
|
|
||||||
|
- [ ] Delete `img/vampire/` directory before committing
|
||||||
|
- [ ] Update `MEMORY.md` with new content counts
|
||||||
|
- [ ] Open PR → request review
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+377
-10
@@ -20,6 +20,7 @@ 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";
|
||||||
@@ -566,34 +567,380 @@ const injectMissingExplorationAreas = (state: GameState): number => {
|
|||||||
return added;
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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
|
||||||
* entries that are missing because they were added after the save was created.
|
* 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).
|
* @param state - The player's current game state (mutated in place).
|
||||||
* @returns Counts of how many entries were added per content type.
|
* @returns Counts of how many entries were added or patched per content type.
|
||||||
*/
|
*/
|
||||||
const syncNewContent = (
|
const syncNewContent = (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
): {
|
): {
|
||||||
achievementsAdded: number;
|
achievementsAdded: number;
|
||||||
|
achievementsPatched: number;
|
||||||
adventurersAdded: number;
|
adventurersAdded: number;
|
||||||
|
adventurerStatsPatched: number;
|
||||||
bossesAdded: number;
|
bossesAdded: number;
|
||||||
|
bossesPatched: number;
|
||||||
|
bossRewardsPatched: number;
|
||||||
|
craftingRecipesReapplied: number;
|
||||||
equipmentAdded: number;
|
equipmentAdded: number;
|
||||||
|
equipmentPatched: number;
|
||||||
explorationAreasAdded: number;
|
explorationAreasAdded: number;
|
||||||
|
questRewardsPatched: number;
|
||||||
questsAdded: number;
|
questsAdded: number;
|
||||||
|
questsPatched: number;
|
||||||
upgradesAdded: number;
|
upgradesAdded: number;
|
||||||
|
upgradesPatched: number;
|
||||||
zonesAdded: 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 {
|
return {
|
||||||
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
|
achievementsAdded,
|
||||||
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
|
achievementsPatched,
|
||||||
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
|
adventurerStatsPatched,
|
||||||
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
|
adventurersAdded,
|
||||||
explorationAreasAdded: injectMissingExplorationAreas(state),
|
bossRewardsPatched,
|
||||||
questsAdded: injectMissingEntries(state.quests, defaultQuests),
|
bossesAdded,
|
||||||
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
|
bossesPatched,
|
||||||
zonesAdded: injectMissingEntries(state.zones, defaultZones),
|
craftingRecipesReapplied,
|
||||||
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||||
@@ -678,13 +1025,23 @@ debugRouter.post("/sync-new-content", async(context) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
|
adventurerStatsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
|
bossRewardsPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
upgradesAdded,
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
zonesAdded,
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
} = syncNewContent(state);
|
} = syncNewContent(state);
|
||||||
|
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
@@ -702,15 +1059,25 @@ debugRouter.post("/sync-new-content", async(context) => {
|
|||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
|
adventurerStatsPatched,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
|
bossRewardsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
signature,
|
signature,
|
||||||
state,
|
state,
|
||||||
upgradesAdded,
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
zonesAdded,
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
void logger.error(
|
void logger.error(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
@@ -244,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.
|
||||||
@@ -316,6 +330,7 @@ export {
|
|||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
challengeBoss,
|
challengeBoss,
|
||||||
|
checkExplorationClaimable,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
debugHardReset,
|
debugHardReset,
|
||||||
|
|||||||
@@ -13,16 +13,30 @@ 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;
|
achievementsAdded: number | undefined;
|
||||||
adventurersAdded: number;
|
achievementsPatched: number | undefined;
|
||||||
bossesAdded: number;
|
adventurersAdded: number | undefined;
|
||||||
equipmentAdded: number;
|
adventurerStatsPatched: number | undefined;
|
||||||
explorationAreasAdded: number;
|
bossesAdded: number | undefined;
|
||||||
questsAdded: number;
|
bossesPatched: number | undefined;
|
||||||
upgradesAdded: number;
|
bossRewardsPatched: number | undefined;
|
||||||
zonesAdded: number;
|
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.
|
* Builds a human-readable summary of what the sync-new-content operation added.
|
||||||
* @param result - The counts returned by the operation.
|
* @param result - The counts returned by the operation.
|
||||||
@@ -30,14 +44,24 @@ interface SyncNewContentResult {
|
|||||||
*/
|
*/
|
||||||
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||||
const entries: Array<[ number, string ]> = [
|
const entries: Array<[ number, string ]> = [
|
||||||
[ result.zonesAdded, "zone(s)" ],
|
[ safeNumber(result.zonesAdded), "zone(s)" ],
|
||||||
[ result.questsAdded, "quest(s)" ],
|
[ safeNumber(result.questsAdded), "quest(s)" ],
|
||||||
[ result.bossesAdded, "boss(es)" ],
|
[ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ],
|
||||||
[ result.explorationAreasAdded, "exploration area(s)" ],
|
[ safeNumber(result.bossesAdded), "boss(es)" ],
|
||||||
[ result.adventurersAdded, "adventurer tier(s)" ],
|
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
||||||
[ result.upgradesAdded, "upgrade(s)" ],
|
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
||||||
[ result.equipmentAdded, "equipment item(s)" ],
|
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
||||||
[ result.achievementsAdded, "achievement(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.
|
const parts = entries.
|
||||||
filter(([ count ]) => {
|
filter(([ count ]) => {
|
||||||
@@ -52,18 +76,18 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
|||||||
const total = entries.reduce((sum, [ count ]) => {
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
return sum + count;
|
return sum + count;
|
||||||
}, 0);
|
}, 0);
|
||||||
return `Added ${String(total)} new item(s) to your save: ${parts.join(", ")}.`;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,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 ]) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -243,6 +243,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.
|
||||||
*/
|
*/
|
||||||
@@ -581,13 +586,23 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
syncNewContent: ()=> Promise<{
|
syncNewContent: ()=> Promise<{
|
||||||
achievementsAdded: number;
|
achievementsAdded: number;
|
||||||
|
achievementsPatched: number;
|
||||||
|
adventurerStatsPatched: number;
|
||||||
adventurersAdded: number;
|
adventurersAdded: number;
|
||||||
|
bossRewardsPatched: number;
|
||||||
bossesAdded: number;
|
bossesAdded: number;
|
||||||
|
bossesPatched: number;
|
||||||
|
craftingRecipesReapplied: number;
|
||||||
equipmentAdded: number;
|
equipmentAdded: number;
|
||||||
|
equipmentPatched: number;
|
||||||
explorationAreasAdded: number;
|
explorationAreasAdded: number;
|
||||||
|
questRewardsPatched: number;
|
||||||
questsAdded: number;
|
questsAdded: number;
|
||||||
|
questsPatched: number;
|
||||||
upgradesAdded: number;
|
upgradesAdded: number;
|
||||||
|
upgradesPatched: number;
|
||||||
zonesAdded: number;
|
zonesAdded: number;
|
||||||
|
zonesPatched: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -684,6 +699,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>
|
||||||
>([]);
|
>([]);
|
||||||
@@ -721,6 +737,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}`).
|
||||||
@@ -1862,14 +1879,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: {
|
||||||
@@ -1882,7 +1891,7 @@ export const GameProvider = ({
|
|||||||
...previous.exploration.craftedRecipeIds,
|
...previous.exploration.craftedRecipeIds,
|
||||||
recipeId,
|
recipeId,
|
||||||
],
|
],
|
||||||
materials: materials,
|
materials: result.materials,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2177,13 +2186,23 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
achievementsAdded: data.achievementsAdded,
|
achievementsAdded: data.achievementsAdded,
|
||||||
|
achievementsPatched: data.achievementsPatched,
|
||||||
|
adventurerStatsPatched: data.adventurerStatsPatched,
|
||||||
adventurersAdded: data.adventurersAdded,
|
adventurersAdded: data.adventurersAdded,
|
||||||
|
bossRewardsPatched: data.bossRewardsPatched,
|
||||||
bossesAdded: data.bossesAdded,
|
bossesAdded: data.bossesAdded,
|
||||||
|
bossesPatched: data.bossesPatched,
|
||||||
|
craftingRecipesReapplied: data.craftingRecipesReapplied,
|
||||||
equipmentAdded: data.equipmentAdded,
|
equipmentAdded: data.equipmentAdded,
|
||||||
|
equipmentPatched: data.equipmentPatched,
|
||||||
explorationAreasAdded: data.explorationAreasAdded,
|
explorationAreasAdded: data.explorationAreasAdded,
|
||||||
|
questRewardsPatched: data.questRewardsPatched,
|
||||||
questsAdded: data.questsAdded,
|
questsAdded: data.questsAdded,
|
||||||
|
questsPatched: data.questsPatched,
|
||||||
upgradesAdded: data.upgradesAdded,
|
upgradesAdded: data.upgradesAdded,
|
||||||
|
upgradesPatched: data.upgradesPatched,
|
||||||
zonesAdded: data.zonesAdded,
|
zonesAdded: data.zonesAdded,
|
||||||
|
zonesPatched: data.zonesPatched,
|
||||||
};
|
};
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
setError(
|
setError(
|
||||||
@@ -2193,13 +2212,23 @@ export const GameProvider = ({
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
achievementsAdded: 0,
|
achievementsAdded: 0,
|
||||||
|
achievementsPatched: 0,
|
||||||
|
adventurerStatsPatched: 0,
|
||||||
adventurersAdded: 0,
|
adventurersAdded: 0,
|
||||||
|
bossRewardsPatched: 0,
|
||||||
bossesAdded: 0,
|
bossesAdded: 0,
|
||||||
|
bossesPatched: 0,
|
||||||
|
craftingRecipesReapplied: 0,
|
||||||
equipmentAdded: 0,
|
equipmentAdded: 0,
|
||||||
|
equipmentPatched: 0,
|
||||||
explorationAreasAdded: 0,
|
explorationAreasAdded: 0,
|
||||||
|
questRewardsPatched: 0,
|
||||||
questsAdded: 0,
|
questsAdded: 0,
|
||||||
|
questsPatched: 0,
|
||||||
upgradesAdded: 0,
|
upgradesAdded: 0,
|
||||||
|
upgradesPatched: 0,
|
||||||
zonesAdded: 0,
|
zonesAdded: 0,
|
||||||
|
zonesPatched: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2281,6 +2310,7 @@ export const GameProvider = ({
|
|||||||
forceUnlocks,
|
forceUnlocks,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
handleClick,
|
handleClick,
|
||||||
|
inGuild,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
@@ -2352,6 +2382,7 @@ export const GameProvider = ({
|
|||||||
error,
|
error,
|
||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
forceSync,
|
forceSync,
|
||||||
|
inGuild,
|
||||||
forceUnlocks,
|
forceUnlocks,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -463,11 +474,21 @@ interface SyncNewContentResponse {
|
|||||||
*/
|
*/
|
||||||
adventurersAdded: number;
|
adventurersAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||||
|
*/
|
||||||
|
adventurerStatsPatched: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of upgrades added to the save.
|
* Number of upgrades added to the save.
|
||||||
*/
|
*/
|
||||||
upgradesAdded: number;
|
upgradesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of rewards patched onto existing quests.
|
||||||
|
*/
|
||||||
|
questRewardsPatched: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of quests added to the save.
|
* Number of quests added to the save.
|
||||||
*/
|
*/
|
||||||
@@ -478,6 +499,11 @@ interface SyncNewContentResponse {
|
|||||||
*/
|
*/
|
||||||
bossesAdded: number;
|
bossesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrade reward IDs patched onto existing bosses.
|
||||||
|
*/
|
||||||
|
bossRewardsPatched: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of equipment items added to the save.
|
* Number of equipment items added to the save.
|
||||||
*/
|
*/
|
||||||
@@ -498,6 +524,41 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -518,6 +579,7 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user