generated from nhcarrigan/template
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eec93e442b
|
|||
|
9926e7f639
|
|||
| 6bf1ac5e7d | |||
| b48beef474 | |||
| 6e573bea14 | |||
|
790d35420f
|
|||
|
9f9edae45e
|
|||
|
a7a255dab6
|
|||
|
e92cf3c9a1
|
|||
|
26d30c271d
|
|||
| 34d07bec95 | |||
| 3ac1d566cb | |||
|
7bd6b2d3e3
|
|||
| 354b7e372e | |||
| dc1782bec9 | |||
| 635c630e49 | |||
| bb60ae3390 | |||
| ee47c1e8c9 | |||
| 2236d1dc9f | |||
|
621f594018
|
|||
| 1e845b14ce | |||
| 81ae1f18e1 | |||
| 0057cfeaaa | |||
| 161127dc21 | |||
| a8a465f293 | |||
| 79c4b99e8a | |||
| 3d114f63d7 | |||
| 911e089a9e | |||
| 14de87d765 | |||
| c4b4fba4c9 | |||
| d723656743 | |||
| 7e10757e68 | |||
| ca2edb090e | |||
| cfcf763ce3 | |||
| aede55a13d | |||
| 744cbf121f | |||
| 03b6c847b3 | |||
| 219d299e9f | |||
| 9e5b8ed972 | |||
|
a20cf3ef87
|
|||
| 9860a2cb1f | |||
| 404b31bd13 | |||
| d0790890ee | |||
| 4d7e624358 | |||
| ac94f67797 | |||
| a36c8e72a5 | |||
| 11e97325cb | |||
| 7a1c57be9a |
@@ -7,6 +7,41 @@
|
|||||||
2. `pnpm build` — all packages build cleanly
|
2. `pnpm build` — all packages build cleanly
|
||||||
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
||||||
|
|
||||||
|
## Art Assets
|
||||||
|
|
||||||
|
Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/image at 1K resolution) and hosted on the CDN at `https://cdn.nhcarrigan.com/elysium/`.
|
||||||
|
|
||||||
|
### Process
|
||||||
|
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
|
||||||
|
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
|
||||||
|
3. Upload to R2 with the AWS CLI — credentials are in the global `~/.claude/CLAUDE.md` (never commit them here)
|
||||||
|
4. Delete the local `img/` directory before committing (images live on CDN only)
|
||||||
|
|
||||||
|
### CDN URL Helper
|
||||||
|
`apps/web/src/utils/cdn.ts` exports `cdnImage(folder, id)` → `https://cdn.nhcarrigan.com/elysium/<folder>/<id>.jpg`
|
||||||
|
|
||||||
|
### Directory → Category Mapping
|
||||||
|
| Game entity | CDN folder |
|
||||||
|
|---|---|
|
||||||
|
| Zones | `zones` |
|
||||||
|
| Bosses | `bosses` |
|
||||||
|
| Quests | `quests` |
|
||||||
|
| Adventurers | `adventurers` |
|
||||||
|
| Companions | `companions` |
|
||||||
|
| Equipment | `equipment` |
|
||||||
|
| Upgrades | `upgrades` |
|
||||||
|
| Prestige upgrades | `prestige-upgrades` |
|
||||||
|
| Transcendence upgrades | `transcendence-upgrades` |
|
||||||
|
| Achievements | `achievements` |
|
||||||
|
| Explorations | `explorations` |
|
||||||
|
| Materials | `materials` |
|
||||||
|
| Recipes | `recipes` |
|
||||||
|
| Story chapter banners | `story-chapters` |
|
||||||
|
|
||||||
|
### API Rate Limits
|
||||||
|
- 250 images/day per API key — use a second key if quota is hit
|
||||||
|
- Free-tier keys cannot use `gemini-3-pro-image-preview`; key must be on a billing-linked project
|
||||||
|
|
||||||
## About Page
|
## About Page
|
||||||
|
|
||||||
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
||||||
|
|||||||
@@ -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.1.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"@hono/node-server": "1.13.7",
|
"@hono/node-server": "1.13.7",
|
||||||
|
"@nhcarrigan/logger": "1.1.1",
|
||||||
"@prisma/client": "6.5.0",
|
"@prisma/client": "6.5.0",
|
||||||
"hono": "4.7.4",
|
"hono": "4.7.4",
|
||||||
"prisma": "6.5.0"
|
"prisma": "6.5.0"
|
||||||
|
|||||||
@@ -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
-4
@@ -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,5 +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"
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
|
||||||
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 18, type: "bossesDefeated" },
|
condition: { amount: 18, type: "bossesDefeated" },
|
||||||
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
|
description: "Defeat all 18 bosses across the first six zones.",
|
||||||
icon: "🌟",
|
icon: "🌟",
|
||||||
id: "devourer_slayer",
|
id: "devourer_slayer",
|
||||||
name: "World Saver",
|
name: "World Saver",
|
||||||
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 40, type: "equipmentOwned" },
|
condition: { amount: 65, type: "equipmentOwned" },
|
||||||
description: "Own 40 pieces of equipment.",
|
description: "Own all 65 pieces of equipment.",
|
||||||
icon: "🛡️",
|
icon: "🛡️",
|
||||||
id: "fully_equipped",
|
id: "fully_equipped",
|
||||||
name: "Fully Equipped",
|
name: "Fully Equipped",
|
||||||
@@ -289,8 +289,8 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 72, type: "questsCompleted" },
|
condition: { amount: 95, type: "questsCompleted" },
|
||||||
description: "Complete all 72 quests across the known multiverse.",
|
description: "Complete all 95 quests across the known multiverse.",
|
||||||
icon: "🌌",
|
icon: "🌌",
|
||||||
id: "quest_eternal",
|
id: "quest_eternal",
|
||||||
name: "Quest Eternal",
|
name: "Quest Eternal",
|
||||||
@@ -317,8 +317,8 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 60, type: "bossesDefeated" },
|
condition: { amount: 72, type: "bossesDefeated" },
|
||||||
description: "Defeat all 60 bosses across every plane of existence.",
|
description: "Defeat all 72 bosses across every plane of existence.",
|
||||||
icon: "💀",
|
icon: "💀",
|
||||||
id: "boss_eternal",
|
id: "boss_eternal",
|
||||||
name: "Eternal Vanquisher",
|
name: "Eternal Vanquisher",
|
||||||
|
|||||||
@@ -129,27 +129,39 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 4_000_000_000,
|
baseCost: 2_600_000_000,
|
||||||
class: "rogue",
|
class: "mage",
|
||||||
combatPower: 18_000,
|
combatPower: 13_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 6,
|
essencePerSecond: 6,
|
||||||
goldPerSecond: 5000,
|
goldPerSecond: 4500,
|
||||||
id: "shadow_assassin",
|
id: "arcane_scholar",
|
||||||
level: 11,
|
level: 11,
|
||||||
|
name: "Arcane Scholar",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseCost: 11_000_000_000,
|
||||||
|
class: "rogue",
|
||||||
|
combatPower: 28_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 11,
|
||||||
|
goldPerSecond: 9500,
|
||||||
|
id: "shadow_assassin",
|
||||||
|
level: 12,
|
||||||
name: "Shadow Assassin",
|
name: "Shadow Assassin",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 28_000_000_000,
|
baseCost: 47_000_000_000,
|
||||||
class: "mage",
|
class: "paladin",
|
||||||
combatPower: 45_000,
|
combatPower: 60_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 15,
|
essencePerSecond: 20,
|
||||||
goldPerSecond: 14_000,
|
goldPerSecond: 20_000,
|
||||||
id: "arcane_scholar",
|
id: "dark_templar",
|
||||||
level: 12,
|
level: 13,
|
||||||
name: "Arcane Scholar",
|
name: "Dark Templar",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,7 +172,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 35,
|
essencePerSecond: 35,
|
||||||
goldPerSecond: 40_000,
|
goldPerSecond: 40_000,
|
||||||
id: "void_walker",
|
id: "void_walker",
|
||||||
level: 13,
|
level: 14,
|
||||||
name: "Void Walker",
|
name: "Void Walker",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -172,7 +184,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 100,
|
essencePerSecond: 100,
|
||||||
goldPerSecond: 120_000,
|
goldPerSecond: 120_000,
|
||||||
id: "celestial_guard",
|
id: "celestial_guard",
|
||||||
level: 14,
|
level: 15,
|
||||||
name: "Celestial Guard",
|
name: "Celestial Guard",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300,
|
essencePerSecond: 300,
|
||||||
goldPerSecond: 400_000,
|
goldPerSecond: 400_000,
|
||||||
id: "divine_champion",
|
id: "divine_champion",
|
||||||
level: 15,
|
level: 16,
|
||||||
name: "Divine Champion",
|
name: "Divine Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 800,
|
essencePerSecond: 800,
|
||||||
goldPerSecond: 1_200_000,
|
goldPerSecond: 1_200_000,
|
||||||
id: "seraph_knight",
|
id: "seraph_knight",
|
||||||
level: 16,
|
level: 17,
|
||||||
name: "Seraph Knight",
|
name: "Seraph Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2000,
|
essencePerSecond: 2000,
|
||||||
goldPerSecond: 3_500_000,
|
goldPerSecond: 3_500_000,
|
||||||
id: "abyss_diver",
|
id: "abyss_diver",
|
||||||
level: 17,
|
level: 18,
|
||||||
name: "Abyss Diver",
|
name: "Abyss Diver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 5000,
|
essencePerSecond: 5000,
|
||||||
goldPerSecond: 10_000_000,
|
goldPerSecond: 10_000_000,
|
||||||
id: "infernal_warden",
|
id: "infernal_warden",
|
||||||
level: 18,
|
level: 19,
|
||||||
name: "Infernal Warden",
|
name: "Infernal Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000,
|
essencePerSecond: 12_000,
|
||||||
goldPerSecond: 30_000_000,
|
goldPerSecond: 30_000_000,
|
||||||
id: "crystal_sage",
|
id: "crystal_sage",
|
||||||
level: 19,
|
level: 20,
|
||||||
name: "Crystal Sage",
|
name: "Crystal Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 30_000,
|
essencePerSecond: 30_000,
|
||||||
goldPerSecond: 90_000_000,
|
goldPerSecond: 90_000_000,
|
||||||
id: "void_sentinel",
|
id: "void_sentinel",
|
||||||
level: 20,
|
level: 21,
|
||||||
name: "Void Sentinel",
|
name: "Void Sentinel",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 80_000,
|
essencePerSecond: 80_000,
|
||||||
goldPerSecond: 270_000_000,
|
goldPerSecond: 270_000_000,
|
||||||
id: "eternal_champion",
|
id: "eternal_champion",
|
||||||
level: 21,
|
level: 22,
|
||||||
name: "Eternal Champion",
|
name: "Eternal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 220_000,
|
essencePerSecond: 220_000,
|
||||||
goldPerSecond: 800_000_000,
|
goldPerSecond: 800_000_000,
|
||||||
id: "aether_weaver",
|
id: "aether_weaver",
|
||||||
level: 22,
|
level: 23,
|
||||||
name: "Aether Weaver",
|
name: "Aether Weaver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 600_000,
|
essencePerSecond: 600_000,
|
||||||
goldPerSecond: 2_500_000_000,
|
goldPerSecond: 2_500_000_000,
|
||||||
id: "titan_warrior",
|
id: "titan_warrior",
|
||||||
level: 23,
|
level: 24,
|
||||||
name: "Titan Warrior",
|
name: "Titan Warrior",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 1_600_000,
|
essencePerSecond: 1_600_000,
|
||||||
goldPerSecond: 7_500_000_000,
|
goldPerSecond: 7_500_000_000,
|
||||||
id: "nexus_sage",
|
id: "nexus_sage",
|
||||||
level: 24,
|
level: 25,
|
||||||
name: "Nexus Sage",
|
name: "Nexus Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 4_500_000,
|
essencePerSecond: 4_500_000,
|
||||||
goldPerSecond: 22_000_000_000,
|
goldPerSecond: 22_000_000_000,
|
||||||
id: "cosmos_knight",
|
id: "cosmos_knight",
|
||||||
level: 25,
|
level: 26,
|
||||||
name: "Cosmos Knight",
|
name: "Cosmos Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000_000,
|
essencePerSecond: 12_000_000,
|
||||||
goldPerSecond: 65_000_000_000,
|
goldPerSecond: 65_000_000_000,
|
||||||
id: "astral_sovereign",
|
id: "astral_sovereign",
|
||||||
level: 26,
|
level: 27,
|
||||||
name: "Astral Sovereign",
|
name: "Astral Sovereign",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 35_000_000,
|
essencePerSecond: 35_000_000,
|
||||||
goldPerSecond: 200_000_000_000,
|
goldPerSecond: 200_000_000_000,
|
||||||
id: "primordial_mage",
|
id: "primordial_mage",
|
||||||
level: 27,
|
level: 28,
|
||||||
name: "Primordial Mage",
|
name: "Primordial Mage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 100_000_000,
|
essencePerSecond: 100_000_000,
|
||||||
goldPerSecond: 600_000_000_000,
|
goldPerSecond: 600_000_000_000,
|
||||||
id: "reality_warden",
|
id: "reality_warden",
|
||||||
level: 28,
|
level: 29,
|
||||||
name: "Reality Warden",
|
name: "Reality Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300_000_000,
|
essencePerSecond: 300_000_000,
|
||||||
goldPerSecond: 1_800_000_000_000,
|
goldPerSecond: 1_800_000_000_000,
|
||||||
id: "infinity_ranger",
|
id: "infinity_ranger",
|
||||||
level: 29,
|
level: 30,
|
||||||
name: "Infinity Ranger",
|
name: "Infinity Ranger",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 850_000_000,
|
essencePerSecond: 850_000_000,
|
||||||
goldPerSecond: 5_500_000_000_000,
|
goldPerSecond: 5_500_000_000_000,
|
||||||
id: "oblivion_paladin",
|
id: "oblivion_paladin",
|
||||||
level: 30,
|
level: 31,
|
||||||
name: "Oblivion Paladin",
|
name: "Oblivion Paladin",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2_500_000_000,
|
essencePerSecond: 2_500_000_000,
|
||||||
goldPerSecond: 16_000_000_000_000,
|
goldPerSecond: 16_000_000_000_000,
|
||||||
id: "transcendent_rogue",
|
id: "transcendent_rogue",
|
||||||
level: 31,
|
level: 32,
|
||||||
name: "Transcendent Rogue",
|
name: "Transcendent Rogue",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 7_000_000_000,
|
essencePerSecond: 7_000_000_000,
|
||||||
goldPerSecond: 50_000_000_000_000,
|
goldPerSecond: 50_000_000_000_000,
|
||||||
id: "omniversal_champion",
|
id: "omniversal_champion",
|
||||||
level: 32,
|
level: 33,
|
||||||
name: "Omniversal Champion",
|
name: "Omniversal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+46
-46
@@ -121,17 +121,17 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
},
|
},
|
||||||
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
bountyRunestones: 5,
|
bountyRunestones: 20,
|
||||||
crystalReward: 30,
|
crystalReward: 700,
|
||||||
currentHp: 80_000,
|
currentHp: 6_000_000,
|
||||||
damagePerSecond: 80,
|
damagePerSecond: 1200,
|
||||||
description:
|
description:
|
||||||
"She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.",
|
"She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.",
|
||||||
equipmentRewards: [],
|
equipmentRewards: [],
|
||||||
essenceReward: 800,
|
essenceReward: 30_000,
|
||||||
goldReward: 800_000,
|
goldReward: 80_000_000,
|
||||||
id: "swamp_witch",
|
id: "swamp_witch",
|
||||||
maxHp: 80_000,
|
maxHp: 6_000_000,
|
||||||
name: "Morgantha the Swamp Witch",
|
name: "Morgantha the Swamp Witch",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -139,17 +139,17 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bountyRunestones: 8,
|
bountyRunestones: 25,
|
||||||
crystalReward: 80,
|
crystalReward: 1500,
|
||||||
currentHp: 300_000,
|
currentHp: 12_000_000,
|
||||||
damagePerSecond: 180,
|
damagePerSecond: 2400,
|
||||||
description:
|
description:
|
||||||
"A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.",
|
"A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.",
|
||||||
equipmentRewards: [ "runestone_amulet" ],
|
equipmentRewards: [ "runestone_amulet" ],
|
||||||
essenceReward: 2000,
|
essenceReward: 60_000,
|
||||||
goldReward: 3_000_000,
|
goldReward: 180_000_000,
|
||||||
id: "plague_lord",
|
id: "plague_lord",
|
||||||
maxHp: 300_000,
|
maxHp: 12_000_000,
|
||||||
name: "The Plague Lord",
|
name: "The Plague Lord",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -157,17 +157,17 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bountyRunestones: 10,
|
bountyRunestones: 30,
|
||||||
crystalReward: 150,
|
crystalReward: 3000,
|
||||||
currentHp: 800_000,
|
currentHp: 20_000_000,
|
||||||
damagePerSecond: 350,
|
damagePerSecond: 4000,
|
||||||
description:
|
description:
|
||||||
"An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.",
|
"An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.",
|
||||||
equipmentRewards: [ "crystal_shard" ],
|
equipmentRewards: [ "crystal_shard" ],
|
||||||
essenceReward: 4000,
|
essenceReward: 100_000,
|
||||||
goldReward: 8_000_000,
|
goldReward: 350_000_000,
|
||||||
id: "mud_kraken",
|
id: "mud_kraken",
|
||||||
maxHp: 800_000,
|
maxHp: 20_000_000,
|
||||||
name: "The Mud Kraken",
|
name: "The Mud Kraken",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -231,53 +231,53 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
},
|
},
|
||||||
// ── Volcanic Depths ───────────────────────────────────────────────────────
|
// ── Volcanic Depths ───────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
bountyRunestones: 12,
|
bountyRunestones: 32,
|
||||||
crystalReward: 150,
|
crystalReward: 4000,
|
||||||
currentHp: 1_000_000,
|
currentHp: 25_000_000,
|
||||||
damagePerSecond: 400,
|
damagePerSecond: 5000,
|
||||||
description:
|
description:
|
||||||
"Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.",
|
"Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.",
|
||||||
equipmentRewards: [ "flame_lance" ],
|
equipmentRewards: [ "flame_lance" ],
|
||||||
essenceReward: 6000,
|
essenceReward: 150_000,
|
||||||
goldReward: 10_000_000,
|
goldReward: 500_000_000,
|
||||||
id: "fire_elemental",
|
id: "fire_elemental",
|
||||||
maxHp: 1_000_000,
|
maxHp: 25_000_000,
|
||||||
name: "The Ancient Fire Elemental",
|
name: "The Ancient Fire Elemental",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
upgradeRewards: [ "celestial_guard_1" ],
|
upgradeRewards: [ "dark_templar_1" ],
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bountyRunestones: 18,
|
bountyRunestones: 40,
|
||||||
crystalReward: 400,
|
crystalReward: 8000,
|
||||||
currentHp: 4_000_000,
|
currentHp: 60_000_000,
|
||||||
damagePerSecond: 1000,
|
damagePerSecond: 12_000,
|
||||||
description:
|
description:
|
||||||
"Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.",
|
"Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.",
|
||||||
equipmentRewards: [ "volcanic_plate" ],
|
equipmentRewards: [ "volcanic_plate" ],
|
||||||
essenceReward: 15_000,
|
essenceReward: 300_000,
|
||||||
goldReward: 40_000_000,
|
goldReward: 1_000_000_000,
|
||||||
id: "magma_titan",
|
id: "magma_titan",
|
||||||
maxHp: 4_000_000,
|
maxHp: 60_000_000,
|
||||||
name: "The Magma Titan",
|
name: "The Magma Titan",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
upgradeRewards: [ "crystal_resonance" ],
|
upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bountyRunestones: 25,
|
bountyRunestones: 50,
|
||||||
crystalReward: 800,
|
crystalReward: 15_000,
|
||||||
currentHp: 12_000_000,
|
currentHp: 150_000_000,
|
||||||
damagePerSecond: 2500,
|
damagePerSecond: 30_000,
|
||||||
description:
|
description:
|
||||||
"The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.",
|
"The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.",
|
||||||
equipmentRewards: [ "eternal_flame" ],
|
equipmentRewards: [ "eternal_flame" ],
|
||||||
essenceReward: 40_000,
|
essenceReward: 600_000,
|
||||||
goldReward: 120_000_000,
|
goldReward: 2_000_000_000,
|
||||||
id: "phoenix_lord",
|
id: "phoenix_lord",
|
||||||
maxHp: 12_000_000,
|
maxHp: 150_000_000,
|
||||||
name: "The Phoenix Lord",
|
name: "The Phoenix Lord",
|
||||||
prestigeRequirement: 0,
|
prestigeRequirement: 0,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -1120,7 +1120,7 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
name: "The Storm Colossus",
|
name: "The Storm Colossus",
|
||||||
prestigeRequirement: 51,
|
prestigeRequirement: 51,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
upgradeRewards: [ "cosmos_knight_1" ],
|
upgradeRewards: [],
|
||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
|
|||||||
id: "the_absolute_one",
|
id: "the_absolute_one",
|
||||||
maxHp: 2e145,
|
maxHp: 2e145,
|
||||||
name: "The Absolute One",
|
name: "The Absolute One",
|
||||||
prestigeRequirement: 90,
|
prestigeRequirement: 88,
|
||||||
status: "locked",
|
status: "locked",
|
||||||
upgradeRewards: [],
|
upgradeRewards: [],
|
||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "weapon",
|
type: "weapon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 2.75 },
|
bonus: { combatMultiplier: 3.25 },
|
||||||
cost: { crystals: 500, essence: 2000, gold: 0 },
|
cost: { crystals: 500, essence: 2000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
||||||
@@ -204,7 +204,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 2.25 },
|
bonus: { goldMultiplier: 2.75 },
|
||||||
description:
|
description:
|
||||||
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -305,7 +305,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2, goldMultiplier: 1.25 },
|
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
|
||||||
description:
|
description:
|
||||||
"The legendary stone that grants mastery over gold and combat alike.",
|
"The legendary stone that grants mastery over gold and combat alike.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 },
|
bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
|
||||||
description:
|
description:
|
||||||
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -697,7 +697,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
},
|
},
|
||||||
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.5 },
|
bonus: { clickMultiplier: 3 },
|
||||||
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
||||||
@@ -709,7 +709,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 3 },
|
bonus: { goldMultiplier: 3.75 },
|
||||||
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
||||||
@@ -721,7 +721,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 4 },
|
bonus: { combatMultiplier: 7 },
|
||||||
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
||||||
@@ -733,7 +733,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "weapon",
|
type: "weapon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
|
bonus: { clickMultiplier: 4, goldMultiplier: 1.5 },
|
||||||
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
||||||
@@ -745,7 +745,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 4 },
|
bonus: { goldMultiplier: 4.75 },
|
||||||
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
||||||
@@ -757,7 +757,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 },
|
bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
|
||||||
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const initialGameState = (
|
|||||||
achievements: structuredClone(defaultAchievements),
|
achievements: structuredClone(defaultAchievements),
|
||||||
adventurers: structuredClone(defaultAdventurers),
|
adventurers: structuredClone(defaultAdventurers),
|
||||||
apotheosis: { ...initialApotheosis },
|
apotheosis: { ...initialApotheosis },
|
||||||
|
autoBoss: false,
|
||||||
|
autoQuest: false,
|
||||||
baseClickPower: 1,
|
baseClickPower: 1,
|
||||||
bosses: structuredClone(defaultBosses),
|
bosses: structuredClone(defaultBosses),
|
||||||
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
description:
|
description:
|
||||||
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
|
"The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
|
||||||
id: "income_10",
|
id: "income_10",
|
||||||
multiplier: 500,
|
multiplier: 200,
|
||||||
name: "Eternal Rune I",
|
name: "Eternal Rune I",
|
||||||
runestonesCost: 30_000,
|
runestonesCost: 30_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
description:
|
description:
|
||||||
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
|
"Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
|
||||||
id: "income_11",
|
id: "income_11",
|
||||||
multiplier: 1000,
|
multiplier: 500,
|
||||||
name: "Eternal Rune II",
|
name: "Eternal Rune II",
|
||||||
runestonesCost: 80_000,
|
runestonesCost: 80_000,
|
||||||
},
|
},
|
||||||
@@ -210,6 +210,15 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
runestonesCost: 1200,
|
runestonesCost: 1200,
|
||||||
},
|
},
|
||||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
category: "utility",
|
||||||
|
description:
|
||||||
|
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
|
||||||
|
id: "auto_adventurer",
|
||||||
|
multiplier: 1,
|
||||||
|
name: "Autonomous Recruitment",
|
||||||
|
runestonesCost: 50,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "utility",
|
category: "utility",
|
||||||
description:
|
description:
|
||||||
|
|||||||
+152
-76
@@ -33,6 +33,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 2000, type: "gold" },
|
{ amount: 2000, type: "gold" },
|
||||||
{ amount: 5, type: "essence" },
|
{ amount: 5, type: "essence" },
|
||||||
|
{ targetId: "peasant_1", type: "upgrade" },
|
||||||
{ targetId: "apprentice", type: "adventurer" },
|
{ targetId: "apprentice", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -64,7 +65,6 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
prerequisiteIds: [ "haunted_mine" ],
|
prerequisiteIds: [ "haunted_mine" ],
|
||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 50, type: "essence" },
|
{ amount: 50, type: "essence" },
|
||||||
{ targetId: "click_2", type: "upgrade" },
|
|
||||||
{ targetId: "acolyte", type: "adventurer" },
|
{ targetId: "acolyte", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -82,7 +82,8 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 15_000, type: "gold" },
|
{ amount: 15_000, type: "gold" },
|
||||||
{ amount: 20, type: "essence" },
|
{ amount: 20, type: "essence" },
|
||||||
{ targetId: "cleric_1", type: "upgrade" },
|
{ targetId: "militia_1", type: "upgrade" },
|
||||||
|
{ targetId: "acolyte_1", type: "upgrade" },
|
||||||
{ targetId: "ranger", type: "adventurer" },
|
{ targetId: "ranger", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -116,7 +117,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 300, type: "essence" },
|
{ amount: 300, type: "essence" },
|
||||||
{ amount: 30, type: "crystals" },
|
{ amount: 30, type: "crystals" },
|
||||||
{ targetId: "mage_1", type: "upgrade" },
|
{ targetId: "apprentice_1", type: "upgrade" },
|
||||||
{ targetId: "archmage", type: "adventurer" },
|
{ targetId: "archmage", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
@@ -139,69 +140,6 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "shattered_ruins",
|
zoneId: "shattered_ruins",
|
||||||
},
|
},
|
||||||
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
combatPowerRequired: 5000,
|
|
||||||
description:
|
|
||||||
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
|
|
||||||
durationSeconds: 45 * 60,
|
|
||||||
id: "shadow_mere",
|
|
||||||
name: "The Shadow Mere",
|
|
||||||
prerequisiteIds: [],
|
|
||||||
rewards: [
|
|
||||||
{ amount: 150, type: "essence" },
|
|
||||||
{ targetId: "militia_1", type: "upgrade" },
|
|
||||||
],
|
|
||||||
status: "locked",
|
|
||||||
zoneId: "shadow_marshes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
combatPowerRequired: 20_000,
|
|
||||||
description:
|
|
||||||
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
|
|
||||||
durationSeconds: 90 * 60,
|
|
||||||
id: "witch_coven",
|
|
||||||
name: "The Witch Coven",
|
|
||||||
prerequisiteIds: [ "shadow_mere" ],
|
|
||||||
rewards: [
|
|
||||||
{ amount: 500, type: "essence" },
|
|
||||||
{ targetId: "shadow_assassin", type: "adventurer" },
|
|
||||||
],
|
|
||||||
status: "locked",
|
|
||||||
zoneId: "shadow_marshes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
combatPowerRequired: 80_000,
|
|
||||||
description:
|
|
||||||
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
|
|
||||||
durationSeconds: 2 * 60 * 60,
|
|
||||||
id: "sunken_temple",
|
|
||||||
name: "The Sunken Temple",
|
|
||||||
prerequisiteIds: [ "witch_coven" ],
|
|
||||||
rewards: [
|
|
||||||
{ amount: 2_000_000, type: "gold" },
|
|
||||||
{ amount: 75, type: "crystals" },
|
|
||||||
{ targetId: "knight_1", type: "upgrade" },
|
|
||||||
],
|
|
||||||
status: "locked",
|
|
||||||
zoneId: "shadow_marshes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
combatPowerRequired: 300_000,
|
|
||||||
description:
|
|
||||||
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
|
|
||||||
durationSeconds: 3 * 60 * 60,
|
|
||||||
id: "plague_ruins",
|
|
||||||
name: "The Plague Ruins",
|
|
||||||
prerequisiteIds: [ "sunken_temple" ],
|
|
||||||
rewards: [
|
|
||||||
{ amount: 8_000_000, type: "gold" },
|
|
||||||
{ amount: 2000, type: "essence" },
|
|
||||||
{ amount: 150, type: "crystals" },
|
|
||||||
],
|
|
||||||
status: "locked",
|
|
||||||
zoneId: "shadow_marshes",
|
|
||||||
},
|
|
||||||
// ── Frozen Peaks ──────────────────────────────────────────────────────────
|
// ── Frozen Peaks ──────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
combatPowerRequired: 100_000,
|
combatPowerRequired: 100_000,
|
||||||
@@ -246,14 +184,78 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 30_000_000, type: "gold" },
|
{ amount: 30_000_000, type: "gold" },
|
||||||
{ amount: 10_000, type: "essence" },
|
{ amount: 10_000, type: "essence" },
|
||||||
{ targetId: "peasant_1", type: "upgrade" },
|
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
},
|
},
|
||||||
|
// ── Shadow Marshes ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
combatPowerRequired: 5_000_000,
|
||||||
|
description:
|
||||||
|
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
|
||||||
|
durationSeconds: 45 * 60,
|
||||||
|
id: "shadow_mere",
|
||||||
|
name: "The Shadow Mere",
|
||||||
|
prerequisiteIds: [],
|
||||||
|
rewards: [
|
||||||
|
{ amount: 150, type: "essence" },
|
||||||
|
],
|
||||||
|
status: "locked",
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combatPowerRequired: 20_000_000,
|
||||||
|
description:
|
||||||
|
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
|
||||||
|
durationSeconds: 90 * 60,
|
||||||
|
id: "witch_coven",
|
||||||
|
name: "The Witch Coven",
|
||||||
|
prerequisiteIds: [ "shadow_mere" ],
|
||||||
|
rewards: [
|
||||||
|
{ amount: 500, type: "essence" },
|
||||||
|
{ targetId: "shadow_assassin", type: "adventurer" },
|
||||||
|
],
|
||||||
|
status: "locked",
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combatPowerRequired: 80_000_000,
|
||||||
|
description:
|
||||||
|
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
|
||||||
|
durationSeconds: 2 * 60 * 60,
|
||||||
|
id: "sunken_temple",
|
||||||
|
name: "The Sunken Temple",
|
||||||
|
prerequisiteIds: [ "witch_coven" ],
|
||||||
|
rewards: [
|
||||||
|
{ amount: 2_000_000, type: "gold" },
|
||||||
|
{ amount: 1500, type: "essence" },
|
||||||
|
{ amount: 75, type: "crystals" },
|
||||||
|
{ targetId: "knight_1", type: "upgrade" },
|
||||||
|
{ targetId: "peasant_2", type: "upgrade" },
|
||||||
|
],
|
||||||
|
status: "locked",
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combatPowerRequired: 300_000_000,
|
||||||
|
description:
|
||||||
|
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
|
||||||
|
durationSeconds: 3 * 60 * 60,
|
||||||
|
id: "plague_ruins",
|
||||||
|
name: "The Plague Ruins",
|
||||||
|
prerequisiteIds: [ "sunken_temple" ],
|
||||||
|
rewards: [
|
||||||
|
{ amount: 8_000_000, type: "gold" },
|
||||||
|
{ amount: 2000, type: "essence" },
|
||||||
|
{ amount: 150, type: "crystals" },
|
||||||
|
{ targetId: "dark_templar", type: "adventurer" },
|
||||||
|
],
|
||||||
|
status: "locked",
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
// ── Volcanic Depths ───────────────────────────────────────────────────────
|
// ── Volcanic Depths ───────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
combatPowerRequired: 2_000_000,
|
combatPowerRequired: 1_200_000_000,
|
||||||
description:
|
description:
|
||||||
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
|
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
|
||||||
durationSeconds: 3 * 60 * 60,
|
durationSeconds: 3 * 60 * 60,
|
||||||
@@ -263,12 +265,13 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 15_000_000, type: "gold" },
|
{ amount: 15_000_000, type: "gold" },
|
||||||
{ amount: 4000, type: "essence" },
|
{ amount: 4000, type: "essence" },
|
||||||
|
{ targetId: "void_walker", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
combatPowerRequired: 8_000_000,
|
combatPowerRequired: 4_800_000_000,
|
||||||
description:
|
description:
|
||||||
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
|
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
|
||||||
durationSeconds: 5 * 60 * 60,
|
durationSeconds: 5 * 60 * 60,
|
||||||
@@ -276,15 +279,16 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
name: "The Temple of the Flame",
|
name: "The Temple of the Flame",
|
||||||
prerequisiteIds: [ "lava_flows" ],
|
prerequisiteIds: [ "lava_flows" ],
|
||||||
rewards: [
|
rewards: [
|
||||||
|
{ amount: 40_000_000, type: "gold" },
|
||||||
{ amount: 12_000, type: "essence" },
|
{ amount: 12_000, type: "essence" },
|
||||||
{ amount: 300, type: "crystals" },
|
{ amount: 300, type: "crystals" },
|
||||||
{ targetId: "void_walker", type: "adventurer" },
|
{ targetId: "peasant_3", type: "upgrade" },
|
||||||
],
|
],
|
||||||
status: "locked",
|
status: "locked",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
combatPowerRequired: 30_000_000,
|
combatPowerRequired: 18_000_000_000,
|
||||||
description:
|
description:
|
||||||
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
|
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
|
||||||
durationSeconds: 7 * 60 * 60,
|
durationSeconds: 7 * 60 * 60,
|
||||||
@@ -300,7 +304,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
combatPowerRequired: 120_000_000,
|
combatPowerRequired: 72_000_000_000,
|
||||||
description:
|
description:
|
||||||
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
|
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
|
||||||
durationSeconds: 10 * 60 * 60,
|
durationSeconds: 10 * 60 * 60,
|
||||||
@@ -317,7 +321,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Astral Void ───────────────────────────────────────────────────────────
|
// ── Astral Void ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
combatPowerRequired: 50_000_000,
|
combatPowerRequired: 300_000_000_000,
|
||||||
description:
|
description:
|
||||||
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
|
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
|
||||||
durationSeconds: 4 * 60 * 60,
|
durationSeconds: 4 * 60 * 60,
|
||||||
@@ -332,7 +336,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
combatPowerRequired: 200_000_000,
|
combatPowerRequired: 1_200_000_000_000,
|
||||||
description:
|
description:
|
||||||
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
|
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
|
||||||
durationSeconds: 8 * 60 * 60,
|
durationSeconds: 8 * 60 * 60,
|
||||||
@@ -348,7 +352,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
combatPowerRequired: 800_000_000,
|
combatPowerRequired: 4_800_000_000_000,
|
||||||
description:
|
description:
|
||||||
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
|
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
|
||||||
durationSeconds: 12 * 60 * 60,
|
durationSeconds: 12 * 60 * 60,
|
||||||
@@ -364,7 +368,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
combatPowerRequired: 3_000_000_000,
|
combatPowerRequired: 18_000_000_000_000,
|
||||||
description:
|
description:
|
||||||
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
|
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
|
||||||
durationSeconds: 24 * 60 * 60,
|
durationSeconds: 24 * 60 * 60,
|
||||||
@@ -381,6 +385,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Celestial Reaches ─────────────────────────────────────────────────────
|
// ── Celestial Reaches ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e13,
|
||||||
description:
|
description:
|
||||||
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
|
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
|
||||||
durationSeconds: Math.round(1.5 * 60 * 60),
|
durationSeconds: Math.round(1.5 * 60 * 60),
|
||||||
@@ -396,6 +401,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e14,
|
||||||
description:
|
description:
|
||||||
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
|
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
|
||||||
durationSeconds: 3 * 60 * 60,
|
durationSeconds: 3 * 60 * 60,
|
||||||
@@ -410,6 +416,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e15,
|
||||||
description:
|
description:
|
||||||
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
|
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
|
||||||
durationSeconds: 5 * 60 * 60,
|
durationSeconds: 5 * 60 * 60,
|
||||||
@@ -425,6 +432,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e15,
|
||||||
description:
|
description:
|
||||||
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
|
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
|
||||||
durationSeconds: 8 * 60 * 60,
|
durationSeconds: 8 * 60 * 60,
|
||||||
@@ -440,6 +448,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e16,
|
||||||
description:
|
description:
|
||||||
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
|
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
|
||||||
durationSeconds: 12 * 60 * 60,
|
durationSeconds: 12 * 60 * 60,
|
||||||
@@ -456,6 +465,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e16,
|
||||||
description:
|
description:
|
||||||
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
|
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
|
||||||
durationSeconds: 20 * 60 * 60,
|
durationSeconds: 20 * 60 * 60,
|
||||||
@@ -472,6 +482,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Abyssal Trench ────────────────────────────────────────────────────────
|
// ── Abyssal Trench ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e17,
|
||||||
description:
|
description:
|
||||||
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
|
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
|
||||||
durationSeconds: 2 * 60 * 60,
|
durationSeconds: 2 * 60 * 60,
|
||||||
@@ -487,6 +498,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e18,
|
||||||
description:
|
description:
|
||||||
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
|
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
|
||||||
durationSeconds: 4 * 60 * 60,
|
durationSeconds: 4 * 60 * 60,
|
||||||
@@ -502,6 +514,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e18,
|
||||||
description:
|
description:
|
||||||
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
|
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
|
||||||
durationSeconds: 7 * 60 * 60,
|
durationSeconds: 7 * 60 * 60,
|
||||||
@@ -517,6 +530,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e19,
|
||||||
description:
|
description:
|
||||||
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
|
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
|
||||||
durationSeconds: 12 * 60 * 60,
|
durationSeconds: 12 * 60 * 60,
|
||||||
@@ -532,6 +546,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e19,
|
||||||
description:
|
description:
|
||||||
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
|
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
|
||||||
durationSeconds: 18 * 60 * 60,
|
durationSeconds: 18 * 60 * 60,
|
||||||
@@ -548,6 +563,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e20,
|
||||||
description:
|
description:
|
||||||
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
|
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
|
||||||
durationSeconds: 30 * 60 * 60,
|
durationSeconds: 30 * 60 * 60,
|
||||||
@@ -564,6 +580,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Infernal Court ────────────────────────────────────────────────────────
|
// ── Infernal Court ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e21,
|
||||||
description:
|
description:
|
||||||
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
|
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
|
||||||
durationSeconds: 3 * 60 * 60,
|
durationSeconds: 3 * 60 * 60,
|
||||||
@@ -579,6 +596,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e21,
|
||||||
description:
|
description:
|
||||||
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
|
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
|
||||||
durationSeconds: 6 * 60 * 60,
|
durationSeconds: 6 * 60 * 60,
|
||||||
@@ -594,6 +612,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e22,
|
||||||
description:
|
description:
|
||||||
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
|
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
|
||||||
durationSeconds: 10 * 60 * 60,
|
durationSeconds: 10 * 60 * 60,
|
||||||
@@ -609,6 +628,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e22,
|
||||||
description:
|
description:
|
||||||
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
|
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
|
||||||
durationSeconds: 16 * 60 * 60,
|
durationSeconds: 16 * 60 * 60,
|
||||||
@@ -624,6 +644,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e23,
|
||||||
description:
|
description:
|
||||||
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
|
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
|
||||||
durationSeconds: 24 * 60 * 60,
|
durationSeconds: 24 * 60 * 60,
|
||||||
@@ -640,6 +661,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e24,
|
||||||
description:
|
description:
|
||||||
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
|
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
|
||||||
durationSeconds: 40 * 60 * 60,
|
durationSeconds: 40 * 60 * 60,
|
||||||
@@ -656,6 +678,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Crystalline Spire ─────────────────────────────────────────────────────
|
// ── Crystalline Spire ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e24,
|
||||||
description:
|
description:
|
||||||
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
|
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
|
||||||
durationSeconds: 4 * 60 * 60,
|
durationSeconds: 4 * 60 * 60,
|
||||||
@@ -671,6 +694,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e25,
|
||||||
description:
|
description:
|
||||||
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
|
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
|
||||||
durationSeconds: 8 * 60 * 60,
|
durationSeconds: 8 * 60 * 60,
|
||||||
@@ -686,6 +710,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e25,
|
||||||
description:
|
description:
|
||||||
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
|
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
|
||||||
durationSeconds: 14 * 60 * 60,
|
durationSeconds: 14 * 60 * 60,
|
||||||
@@ -701,6 +726,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e26,
|
||||||
description:
|
description:
|
||||||
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
|
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
|
||||||
durationSeconds: 20 * 60 * 60,
|
durationSeconds: 20 * 60 * 60,
|
||||||
@@ -716,6 +742,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e27,
|
||||||
description:
|
description:
|
||||||
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
|
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
|
||||||
durationSeconds: 32 * 60 * 60,
|
durationSeconds: 32 * 60 * 60,
|
||||||
@@ -732,6 +759,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e27,
|
||||||
description:
|
description:
|
||||||
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
|
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
|
||||||
durationSeconds: 50 * 60 * 60,
|
durationSeconds: 50 * 60 * 60,
|
||||||
@@ -748,6 +776,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Void Sanctum ──────────────────────────────────────────────────────────
|
// ── Void Sanctum ──────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e28,
|
||||||
description:
|
description:
|
||||||
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
|
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
|
||||||
durationSeconds: 6 * 60 * 60,
|
durationSeconds: 6 * 60 * 60,
|
||||||
@@ -763,6 +792,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e28,
|
||||||
description:
|
description:
|
||||||
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
|
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
|
||||||
durationSeconds: 12 * 60 * 60,
|
durationSeconds: 12 * 60 * 60,
|
||||||
@@ -778,6 +808,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e29,
|
||||||
description:
|
description:
|
||||||
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
|
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
|
||||||
durationSeconds: 20 * 60 * 60,
|
durationSeconds: 20 * 60 * 60,
|
||||||
@@ -793,6 +824,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e30,
|
||||||
description:
|
description:
|
||||||
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
|
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
|
||||||
durationSeconds: 30 * 60 * 60,
|
durationSeconds: 30 * 60 * 60,
|
||||||
@@ -808,6 +840,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e30,
|
||||||
description:
|
description:
|
||||||
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
|
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
|
||||||
durationSeconds: 48 * 60 * 60,
|
durationSeconds: 48 * 60 * 60,
|
||||||
@@ -824,6 +857,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e31,
|
||||||
description:
|
description:
|
||||||
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
|
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
|
||||||
durationSeconds: 72 * 60 * 60,
|
durationSeconds: 72 * 60 * 60,
|
||||||
@@ -840,6 +874,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Eternal Throne ────────────────────────────────────────────────────────
|
// ── Eternal Throne ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e31,
|
||||||
description:
|
description:
|
||||||
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
|
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
|
||||||
durationSeconds: 8 * 60 * 60,
|
durationSeconds: 8 * 60 * 60,
|
||||||
@@ -855,6 +890,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e32,
|
||||||
description:
|
description:
|
||||||
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
|
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
|
||||||
durationSeconds: 16 * 60 * 60,
|
durationSeconds: 16 * 60 * 60,
|
||||||
@@ -870,6 +906,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e33,
|
||||||
description:
|
description:
|
||||||
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
|
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
|
||||||
durationSeconds: 28 * 60 * 60,
|
durationSeconds: 28 * 60 * 60,
|
||||||
@@ -885,6 +922,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e33,
|
||||||
description:
|
description:
|
||||||
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
|
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
|
||||||
durationSeconds: 40 * 60 * 60,
|
durationSeconds: 40 * 60 * 60,
|
||||||
@@ -901,6 +939,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e34,
|
||||||
description:
|
description:
|
||||||
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
|
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
|
||||||
durationSeconds: 60 * 60 * 60,
|
durationSeconds: 60 * 60 * 60,
|
||||||
@@ -916,6 +955,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e34,
|
||||||
description:
|
description:
|
||||||
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
|
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
|
||||||
durationSeconds: 96 * 60 * 60,
|
durationSeconds: 96 * 60 * 60,
|
||||||
@@ -932,6 +972,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Primordial Chaos ──────────────────────────────────────────────────────
|
// ── Primordial Chaos ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e35,
|
||||||
description:
|
description:
|
||||||
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
|
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
|
||||||
durationSeconds: 10 * 60 * 60,
|
durationSeconds: 10 * 60 * 60,
|
||||||
@@ -947,6 +988,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e36,
|
||||||
description:
|
description:
|
||||||
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
|
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
|
||||||
durationSeconds: 18 * 60 * 60,
|
durationSeconds: 18 * 60 * 60,
|
||||||
@@ -962,6 +1004,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e36,
|
||||||
description:
|
description:
|
||||||
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
|
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
|
||||||
durationSeconds: 30 * 60 * 60,
|
durationSeconds: 30 * 60 * 60,
|
||||||
@@ -978,6 +1021,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e37,
|
||||||
description:
|
description:
|
||||||
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
|
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
|
||||||
durationSeconds: 45 * 60 * 60,
|
durationSeconds: 45 * 60 * 60,
|
||||||
@@ -993,6 +1037,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e37,
|
||||||
description:
|
description:
|
||||||
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
|
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
|
||||||
durationSeconds: 65 * 60 * 60,
|
durationSeconds: 65 * 60 * 60,
|
||||||
@@ -1009,6 +1054,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e38,
|
||||||
description:
|
description:
|
||||||
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
|
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
|
||||||
durationSeconds: 90 * 60 * 60,
|
durationSeconds: 90 * 60 * 60,
|
||||||
@@ -1025,6 +1071,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Infinite Expanse ──────────────────────────────────────────────────────
|
// ── Infinite Expanse ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e39,
|
||||||
description:
|
description:
|
||||||
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
|
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
|
||||||
durationSeconds: 12 * 60 * 60,
|
durationSeconds: 12 * 60 * 60,
|
||||||
@@ -1040,6 +1087,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e39,
|
||||||
description:
|
description:
|
||||||
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
|
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
|
||||||
durationSeconds: 22 * 60 * 60,
|
durationSeconds: 22 * 60 * 60,
|
||||||
@@ -1055,6 +1103,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e40,
|
||||||
description:
|
description:
|
||||||
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
|
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
|
||||||
durationSeconds: 36 * 60 * 60,
|
durationSeconds: 36 * 60 * 60,
|
||||||
@@ -1071,6 +1120,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e40,
|
||||||
description:
|
description:
|
||||||
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
|
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
|
||||||
durationSeconds: 55 * 60 * 60,
|
durationSeconds: 55 * 60 * 60,
|
||||||
@@ -1087,6 +1137,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e41,
|
||||||
description:
|
description:
|
||||||
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
|
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
|
||||||
durationSeconds: 80 * 60 * 60,
|
durationSeconds: 80 * 60 * 60,
|
||||||
@@ -1102,6 +1153,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e42,
|
||||||
description:
|
description:
|
||||||
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
|
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
|
||||||
durationSeconds: 110 * 60 * 60,
|
durationSeconds: 110 * 60 * 60,
|
||||||
@@ -1118,6 +1170,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Reality Forge ─────────────────────────────────────────────────────────
|
// ── Reality Forge ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e42,
|
||||||
description:
|
description:
|
||||||
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
|
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
|
||||||
durationSeconds: 14 * 60 * 60,
|
durationSeconds: 14 * 60 * 60,
|
||||||
@@ -1133,6 +1186,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e43,
|
||||||
description:
|
description:
|
||||||
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
|
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
|
||||||
durationSeconds: 25 * 60 * 60,
|
durationSeconds: 25 * 60 * 60,
|
||||||
@@ -1148,6 +1202,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e43,
|
||||||
description:
|
description:
|
||||||
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
|
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
|
||||||
durationSeconds: 40 * 60 * 60,
|
durationSeconds: 40 * 60 * 60,
|
||||||
@@ -1164,6 +1219,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e44,
|
||||||
description:
|
description:
|
||||||
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
|
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
|
||||||
durationSeconds: 60 * 60 * 60,
|
durationSeconds: 60 * 60 * 60,
|
||||||
@@ -1180,6 +1236,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e45,
|
||||||
description:
|
description:
|
||||||
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
|
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
|
||||||
durationSeconds: 85 * 60 * 60,
|
durationSeconds: 85 * 60 * 60,
|
||||||
@@ -1195,6 +1252,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e45,
|
||||||
description:
|
description:
|
||||||
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
|
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
|
||||||
durationSeconds: 120 * 60 * 60,
|
durationSeconds: 120 * 60 * 60,
|
||||||
@@ -1211,6 +1269,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
|
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e46,
|
||||||
description:
|
description:
|
||||||
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
|
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
|
||||||
durationSeconds: 16 * 60 * 60,
|
durationSeconds: 16 * 60 * 60,
|
||||||
@@ -1226,6 +1285,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e46,
|
||||||
description:
|
description:
|
||||||
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
|
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
|
||||||
durationSeconds: 28 * 60 * 60,
|
durationSeconds: 28 * 60 * 60,
|
||||||
@@ -1241,6 +1301,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e47,
|
||||||
description:
|
description:
|
||||||
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
|
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
|
||||||
durationSeconds: 45 * 60 * 60,
|
durationSeconds: 45 * 60 * 60,
|
||||||
@@ -1257,6 +1318,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e48,
|
||||||
description:
|
description:
|
||||||
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
|
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
|
||||||
durationSeconds: 65 * 60 * 60,
|
durationSeconds: 65 * 60 * 60,
|
||||||
@@ -1273,6 +1335,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e48,
|
||||||
description:
|
description:
|
||||||
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
|
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
|
||||||
durationSeconds: 90 * 60 * 60,
|
durationSeconds: 90 * 60 * 60,
|
||||||
@@ -1288,6 +1351,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e49,
|
||||||
description:
|
description:
|
||||||
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
|
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
|
||||||
durationSeconds: 130 * 60 * 60,
|
durationSeconds: 130 * 60 * 60,
|
||||||
@@ -1304,6 +1368,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── Primeval Sanctum ──────────────────────────────────────────────────────
|
// ── Primeval Sanctum ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e49,
|
||||||
description:
|
description:
|
||||||
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
|
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
|
||||||
durationSeconds: 18 * 60 * 60,
|
durationSeconds: 18 * 60 * 60,
|
||||||
@@ -1319,6 +1384,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e50,
|
||||||
description:
|
description:
|
||||||
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
|
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
|
||||||
durationSeconds: 32 * 60 * 60,
|
durationSeconds: 32 * 60 * 60,
|
||||||
@@ -1334,6 +1400,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e51,
|
||||||
description:
|
description:
|
||||||
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
|
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
|
||||||
durationSeconds: 50 * 60 * 60,
|
durationSeconds: 50 * 60 * 60,
|
||||||
@@ -1350,6 +1417,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e51,
|
||||||
description:
|
description:
|
||||||
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
|
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
|
||||||
durationSeconds: 72 * 60 * 60,
|
durationSeconds: 72 * 60 * 60,
|
||||||
@@ -1366,6 +1434,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e52,
|
||||||
description:
|
description:
|
||||||
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
|
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
|
||||||
durationSeconds: 100 * 60 * 60,
|
durationSeconds: 100 * 60 * 60,
|
||||||
@@ -1381,6 +1450,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e52,
|
||||||
description:
|
description:
|
||||||
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
|
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
|
||||||
durationSeconds: 144 * 60 * 60,
|
durationSeconds: 144 * 60 * 60,
|
||||||
@@ -1397,6 +1467,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
},
|
},
|
||||||
// ── The Absolute ──────────────────────────────────────────────────────────
|
// ── The Absolute ──────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e53,
|
||||||
description:
|
description:
|
||||||
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
|
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
|
||||||
durationSeconds: 20 * 60 * 60,
|
durationSeconds: 20 * 60 * 60,
|
||||||
@@ -1412,6 +1483,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.2e54,
|
||||||
description:
|
description:
|
||||||
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
|
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
|
||||||
durationSeconds: 36 * 60 * 60,
|
durationSeconds: 36 * 60 * 60,
|
||||||
@@ -1427,6 +1499,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 4.8e54,
|
||||||
description:
|
description:
|
||||||
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
|
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
|
||||||
durationSeconds: 56 * 60 * 60,
|
durationSeconds: 56 * 60 * 60,
|
||||||
@@ -1443,6 +1516,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 1.8e55,
|
||||||
description:
|
description:
|
||||||
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
|
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
|
||||||
durationSeconds: 80 * 60 * 60,
|
durationSeconds: 80 * 60 * 60,
|
||||||
@@ -1458,6 +1532,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 7.2e55,
|
||||||
description:
|
description:
|
||||||
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
|
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
|
||||||
durationSeconds: 120 * 60 * 60,
|
durationSeconds: 120 * 60 * 60,
|
||||||
@@ -1474,6 +1549,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
combatPowerRequired: 3e56,
|
||||||
description:
|
description:
|
||||||
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
|
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
|
||||||
durationSeconds: 168 * 60 * 60,
|
durationSeconds: 168 * 60 * 60,
|
||||||
|
|||||||
@@ -451,6 +451,62 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Cross-zone recipes ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { type: "gold_income", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
|
||||||
|
id: "verdant_pyre_seal",
|
||||||
|
name: "Verdant Pyre Seal",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "verdant_sap", quantity: 8 },
|
||||||
|
{ materialId: "ember_crystal", quantity: 6 },
|
||||||
|
{ materialId: "legendary_ore", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "volcanic_depths",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "click_power", value: 1.22 },
|
||||||
|
description:
|
||||||
|
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
|
||||||
|
id: "voidfrost_weave",
|
||||||
|
name: "Voidfrost Weave",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "glacial_ice", quantity: 8 },
|
||||||
|
{ materialId: "void_shard", quantity: 3 },
|
||||||
|
{ materialId: "shadow_essence", quantity: 5 },
|
||||||
|
],
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "essence_income", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
|
||||||
|
id: "choir_of_the_deep",
|
||||||
|
name: "Choir of the Deep",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "celestial_dust", quantity: 8 },
|
||||||
|
{ materialId: "choir_shard", quantity: 2 },
|
||||||
|
{ materialId: "ancient_tooth", quantity: 2 },
|
||||||
|
{ materialId: "pressure_gem", quantity: 5 },
|
||||||
|
],
|
||||||
|
zoneId: "abyssal_trench",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "combat_power", value: 1.4 },
|
||||||
|
description:
|
||||||
|
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
|
||||||
|
id: "eternal_omega",
|
||||||
|
name: "Eternal Omega",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "crown_fragment", quantity: 6 },
|
||||||
|
{ materialId: "eternity_splinter", quantity: 2 },
|
||||||
|
{ materialId: "boundary_shard", quantity: 4 },
|
||||||
|
{ materialId: "omega_crystal", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "the_absolute",
|
||||||
|
},
|
||||||
|
|
||||||
// Zone 18: the_absolute
|
// Zone 18: the_absolute
|
||||||
{
|
{
|
||||||
bonus: { type: "gold_income", value: 1.3 },
|
bonus: { type: "gold_income", value: 1.3 },
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 10,
|
cost: 50,
|
||||||
description:
|
description:
|
||||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||||
id: "echo_meta_1",
|
id: "echo_meta_1",
|
||||||
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 25,
|
cost: 150,
|
||||||
description:
|
description:
|
||||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||||
id: "echo_meta_2",
|
id: "echo_meta_2",
|
||||||
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 50,
|
cost: 400,
|
||||||
description:
|
description:
|
||||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||||
id: "echo_meta_3",
|
id: "echo_meta_3",
|
||||||
|
|||||||
+127
-19
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "peasant",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 20,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Organised labour guilds and proper scheduling make peasants ten times more productive.",
|
||||||
|
id: "peasant_2",
|
||||||
|
multiplier: 10,
|
||||||
|
name: "Guild Organisation",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "peasant",
|
||||||
|
costCrystals: 50,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Magical augmentation through crystalline resonance supercharges even the humblest worker.",
|
||||||
|
id: "peasant_3",
|
||||||
|
multiplier: 50,
|
||||||
|
name: "Crystal Augmentation",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
adventurerId: "militia",
|
adventurerId: "militia",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
costEssence: 2,
|
costEssence: 2,
|
||||||
costGold: 5000,
|
costGold: 5000,
|
||||||
description: "Ancient books of magic double mage output.",
|
description: "Ancient books of magic double mage output.",
|
||||||
id: "mage_1",
|
id: "apprentice_1",
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
name: "Arcane Tomes",
|
name: "Arcane Tomes",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
costEssence: 3,
|
costEssence: 3,
|
||||||
costGold: 8000,
|
costGold: 8000,
|
||||||
description: "Sacred ceremonies double the output of your clerics.",
|
description: "Sacred ceremonies double the output of your clerics.",
|
||||||
id: "cleric_1",
|
id: "acolyte_1",
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
name: "Holy Rites",
|
name: "Holy Rites",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
adventurerId: "shadow_assassin",
|
|
||||||
costCrystals: 0,
|
|
||||||
costEssence: 50,
|
|
||||||
costGold: 0,
|
|
||||||
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
|
||||||
id: "shadow_assassin_1",
|
|
||||||
multiplier: 2,
|
|
||||||
name: "Shadow Arts",
|
|
||||||
purchased: false,
|
|
||||||
target: "adventurer",
|
|
||||||
unlocked: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
adventurerId: "arcane_scholar",
|
adventurerId: "arcane_scholar",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 150,
|
costEssence: 1000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "Access to forbidden libraries doubles scholar output.",
|
description: "Access to forbidden libraries doubles scholar output.",
|
||||||
id: "arcane_scholar_1",
|
id: "arcane_scholar_1",
|
||||||
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "shadow_assassin",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5000,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
||||||
|
id: "shadow_assassin_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Shadow Arts",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "dark_templar",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 25_000,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"A sworn oath to the darkness of the marshes doubles templar output.",
|
||||||
|
id: "dark_templar_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Templar's Oath",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
adventurerId: "void_walker",
|
adventurerId: "void_walker",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 300,
|
costEssence: 100_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
"Walking through the void itself doubles the output of your void walkers.",
|
"Walking through the void itself doubles the output of your void walkers.",
|
||||||
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "celestial_guard",
|
adventurerId: "celestial_guard",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 750,
|
costEssence: 500_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
"A blessing from the celestials themselves doubles guard output.",
|
"A blessing from the celestials themselves doubles guard output.",
|
||||||
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "divine_champion",
|
adventurerId: "divine_champion",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 2000,
|
costEssence: 2_000_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "An unbreakable oath to the divine doubles champion output.",
|
description: "An unbreakable oath to the divine doubles champion output.",
|
||||||
id: "divine_champion_1",
|
id: "divine_champion_1",
|
||||||
@@ -767,4 +809,70 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
// ── Essence Sinks ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 1e12,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Channel a vast reservoir of essence into the guild's core — all production ×2.",
|
||||||
|
id: "essence_sink_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion I",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5e12,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"A deeper infusion saturates every operation with raw essence — all production ×2.",
|
||||||
|
id: "essence_sink_2",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion II",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 2.5e13,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Essence floods the ley-lines binding your guild — all production ×2.",
|
||||||
|
id: "essence_sink_3",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion III",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 1e14,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"The guild breathes essence as its very lifeblood — all production ×3.",
|
||||||
|
id: "essence_sink_4",
|
||||||
|
multiplier: 3,
|
||||||
|
name: "Essence Infusion IV",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5e14,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Essence transcends material form and reshapes reality itself — all production ×5.",
|
||||||
|
id: "essence_sink_5",
|
||||||
|
multiplier: 5,
|
||||||
|
name: "Essence Infusion V",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+31
-5
@@ -7,22 +7,26 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { aboutRouter } from "./routes/about.js";
|
import { aboutRouter } from "./routes/about.js";
|
||||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||||
import { authRouter } from "./routes/auth.js";
|
import { authRouter } from "./routes/auth.js";
|
||||||
import { bossRouter } from "./routes/boss.js";
|
import { bossRouter } from "./routes/boss.js";
|
||||||
import { craftRouter } from "./routes/craft.js";
|
import { craftRouter } from "./routes/craft.js";
|
||||||
|
import { debugRouter } from "./routes/debug.js";
|
||||||
import { exploreRouter } from "./routes/explore.js";
|
import { exploreRouter } from "./routes/explore.js";
|
||||||
|
import { frontendRouter } from "./routes/frontend.js";
|
||||||
import { gameRouter } from "./routes/game.js";
|
import { gameRouter } from "./routes/game.js";
|
||||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
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";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", honoLogger());
|
||||||
app.use(
|
app.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
cors({
|
||||||
@@ -33,6 +37,8 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.route("/about", aboutRouter);
|
app.route("/about", aboutRouter);
|
||||||
|
app.route("/debug", debugRouter);
|
||||||
|
app.route("/fe", frontendRouter);
|
||||||
app.route("/auth", authRouter);
|
app.route("/auth", authRouter);
|
||||||
app.route("/game", gameRouter);
|
app.route("/game", gameRouter);
|
||||||
app.route("/boss", bossRouter);
|
app.route("/boss", bossRouter);
|
||||||
@@ -48,8 +54,28 @@ app.get("/health", (context) => {
|
|||||||
return context.json({ status: "ok" });
|
return context.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.onError((error, context) => {
|
||||||
|
void logger.error(
|
||||||
|
"hono_unhandled_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: port }, () => {
|
try {
|
||||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
serve({ fetch: app.fetch, port: port }, () => {
|
||||||
});
|
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||||
|
connectGateway();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"server_startup",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyToken } from "../services/jwt.js";
|
import { verifyToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
try {
|
try {
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_middleware",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
|||||||
const aboutRouter = new Hono();
|
const aboutRouter = new Hono();
|
||||||
|
|
||||||
aboutRouter.get("/", async(context) => {
|
aboutRouter.get("/", async(context) => {
|
||||||
const releases = await fetchReleases();
|
try {
|
||||||
const body: AboutResponse = {
|
const releases = await fetchReleases();
|
||||||
apiVersion,
|
const body: AboutResponse = {
|
||||||
releases,
|
apiVersion,
|
||||||
};
|
releases,
|
||||||
return context.json(body);
|
};
|
||||||
|
return context.json(body);
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 9 -- @preserve */
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"about",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { aboutRouter };
|
export { aboutRouter };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
|
||||||
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
buildPostApotheosisState,
|
buildPostApotheosisState,
|
||||||
isEligibleForApotheosis,
|
isEligibleForApotheosis,
|
||||||
} from "../services/apotheosis.js";
|
} from "../services/apotheosis.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
grantApotheosisRole,
|
grantApotheosisRole,
|
||||||
postMilestoneWebhook,
|
postMilestoneWebhook,
|
||||||
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
|
|||||||
apotheosisRouter.use("*", authMiddleware);
|
apotheosisRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
apotheosisRouter.post("/", async(context) => {
|
apotheosisRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
const rawState: unknown = record.state;
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
if (!isEligibleForApotheosis(state)) {
|
if (!isEligibleForApotheosis(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current-run stats before the nuclear reset
|
// Capture current-run stats before the nuclear reset
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 9 -- @preserve */
|
|
||||||
const runBossesDefeated = state.bosses.filter((b) => {
|
|
||||||
return b.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
const runQuestsCompleted = state.quests.filter((q) => {
|
|
||||||
return q.status === "completed";
|
|
||||||
}).length;
|
|
||||||
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
|
||||||
return sum + a.count;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
|
||||||
return a.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
|
||||||
state,
|
|
||||||
state.player.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: updatedState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void grantApotheosisRole(discordId);
|
|
||||||
void postMilestoneWebhook(discordId, "apotheosis", {
|
|
||||||
apotheosis: updatedApotheosisData.count,
|
|
||||||
prestige: updatedState.prestige.count,
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 9 -- @preserve */
|
||||||
transcendence: updatedState.transcendence?.count ?? 0,
|
const runBossesDefeated = state.bosses.filter((b) => {
|
||||||
});
|
return b.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = state.quests.filter((q) => {
|
||||||
|
return q.status === "completed";
|
||||||
|
}).length;
|
||||||
|
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
||||||
|
return sum + a.count;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
||||||
|
return a.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
||||||
|
state,
|
||||||
|
state.player.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const apotheosisCount = updatedApotheosisData.count;
|
||||||
|
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
|
||||||
|
void grantApotheosisRole(discordId);
|
||||||
|
void postMilestoneWebhook(discordId, "apotheosis", {
|
||||||
|
apotheosis: updatedApotheosisData.count,
|
||||||
|
prestige: updatedState.prestige.count,
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
transcendence: updatedState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"apotheosis",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { apotheosisRouter };
|
export { apotheosisRouter };
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
} 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 { 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();
|
||||||
@@ -91,7 +93,15 @@ 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.metric("user_registered", 1, { discordId: player.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -101,16 +111,20 @@ 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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(updated.discordId);
|
const jwtToken = signToken(updated.discordId);
|
||||||
|
void logger.log("info", `Player logged in: ${updated.discordId}`);
|
||||||
|
void logger.metric("user_login", 1, { discordId: updated.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -118,7 +132,13 @@ authRouter.get("/callback", async(context) => {
|
|||||||
return context.redirect(
|
return context.redirect(
|
||||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_callback",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||||
|
|||||||
+253
-232
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||||
|
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||||
import {
|
import {
|
||||||
computeSetBonuses,
|
computeSetBonuses,
|
||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
@@ -20,6 +21,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
const bossRouter = new Hono<HonoEnvironment>();
|
const bossRouter = new Hono<HonoEnvironment>();
|
||||||
@@ -121,254 +123,273 @@ const calculatePartyStats = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
bossRouter.post("/challenge", async(context) => {
|
bossRouter.post("/challenge", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<{ bossId: string }>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<{ bossId: string }>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!body.bossId) {
|
if (!body.bossId) {
|
||||||
return context.json({ error: "Invalid request body" }, 400);
|
return context.json({ error: "Invalid request body" }, 400);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
const boss = state.bosses.find((b) => {
|
|
||||||
return b.id === body.bossId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!boss) {
|
|
||||||
return context.json({ error: "Boss not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boss.status !== "available" && boss.status !== "in_progress") {
|
|
||||||
return context.json({ error: "Boss is not currently available" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boss.prestigeRequirement > state.prestige.count) {
|
|
||||||
return context.json({ error: "Prestige requirement not met" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
|
|
||||||
|
|
||||||
if (
|
|
||||||
partyDPS === 0
|
|
||||||
|| partyMaxHp === 0
|
|
||||||
|| !Number.isFinite(partyDPS)
|
|
||||||
|| !Number.isFinite(partyMaxHp)
|
|
||||||
) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Your party has no adventurers ready to fight" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bossHpBefore = boss.currentHp;
|
|
||||||
const bossDPS = boss.damagePerSecond;
|
|
||||||
|
|
||||||
const timeToKillBoss = bossHpBefore / partyDPS;
|
|
||||||
const timeToKillParty = partyMaxHp / bossDPS;
|
|
||||||
|
|
||||||
const won = timeToKillBoss <= timeToKillParty;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
|
||||||
let partyHpRemaining: number;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
|
||||||
let bossHpAtBattleEnd: number;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
|
||||||
let bossUpdatedHp: number;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
|
||||||
let rewards: BossChallengeResponse["rewards"];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
|
||||||
let casualties: BossChallengeResponse["casualties"];
|
|
||||||
|
|
||||||
if (won) {
|
|
||||||
bossHpAtBattleEnd = 0;
|
|
||||||
bossUpdatedHp = 0;
|
|
||||||
const bossDamageDealt = bossDPS * timeToKillBoss;
|
|
||||||
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
|
||||||
|
|
||||||
boss.status = "defeated";
|
|
||||||
boss.currentHp = 0;
|
|
||||||
|
|
||||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
|
||||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
|
||||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
|
||||||
|
|
||||||
for (const upgradeId of boss.upgradeRewards) {
|
|
||||||
const upgrade = state.upgrades.find((u) => {
|
|
||||||
return u.id === upgradeId;
|
|
||||||
});
|
|
||||||
if (upgrade) {
|
|
||||||
upgrade.unlocked = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 14 -- @preserve */
|
|
||||||
for (const equipmentId of boss.equipmentRewards) {
|
|
||||||
const equipment = state.equipment.find((item) => {
|
|
||||||
return item.id === equipmentId;
|
|
||||||
});
|
|
||||||
if (equipment) {
|
|
||||||
equipment.owned = true;
|
|
||||||
|
|
||||||
const slotAlreadyEquipped = state.equipment.some((item) => {
|
if (!record) {
|
||||||
return item.type === equipment.type && item.equipped;
|
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;
|
||||||
|
const boss = state.bosses.find((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!boss) {
|
||||||
|
return context.json({ error: "Boss not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||||
|
return context.json({ error: "Boss is not currently available" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.prestigeRequirement > state.prestige.count) {
|
||||||
|
return context.json({ error: "Prestige requirement not met" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
|
||||||
|
|
||||||
|
if (
|
||||||
|
partyDPS === 0
|
||||||
|
|| partyMaxHp === 0
|
||||||
|
|| !Number.isFinite(partyDPS)
|
||||||
|
|| !Number.isFinite(partyMaxHp)
|
||||||
|
) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Your party has no adventurers ready to fight" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossHpBefore = boss.currentHp;
|
||||||
|
const bossDPS = boss.damagePerSecond;
|
||||||
|
|
||||||
|
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||||
|
const timeToKillParty = partyMaxHp / bossDPS;
|
||||||
|
|
||||||
|
const won = timeToKillBoss <= timeToKillParty;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let partyHpRemaining: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let bossHpAtBattleEnd: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let bossUpdatedHp: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||||
|
let rewards: BossChallengeResponse["rewards"];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||||
|
let casualties: BossChallengeResponse["casualties"];
|
||||||
|
|
||||||
|
if (won) {
|
||||||
|
bossHpAtBattleEnd = 0;
|
||||||
|
bossUpdatedHp = 0;
|
||||||
|
const bossDamageDealt = bossDPS * timeToKillBoss;
|
||||||
|
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
||||||
|
|
||||||
|
boss.status = "defeated";
|
||||||
|
boss.currentHp = 0;
|
||||||
|
|
||||||
|
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||||
|
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||||
|
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
||||||
|
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||||
|
|
||||||
|
for (const upgradeId of boss.upgradeRewards) {
|
||||||
|
const upgrade = state.upgrades.find((u) => {
|
||||||
|
return u.id === upgradeId;
|
||||||
});
|
});
|
||||||
if (!slotAlreadyEquipped) {
|
if (upgrade) {
|
||||||
equipment.equipped = true;
|
upgrade.unlocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 14 -- @preserve */
|
||||||
|
for (const equipmentId of boss.equipmentRewards) {
|
||||||
|
const equipment = state.equipment.find((item) => {
|
||||||
|
return item.id === equipmentId;
|
||||||
|
});
|
||||||
|
if (equipment) {
|
||||||
|
equipment.owned = true;
|
||||||
|
|
||||||
|
const slotAlreadyEquipped = state.equipment.some((item) => {
|
||||||
|
return item.type === equipment.type && item.equipped;
|
||||||
|
});
|
||||||
|
if (!slotAlreadyEquipped) {
|
||||||
|
equipment.equipped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock next boss in the same zone (zone-based sequential progression)
|
||||||
|
const zoneBosses = state.bosses.filter((b) => {
|
||||||
|
return b.zoneId === boss.zoneId;
|
||||||
|
});
|
||||||
|
const zoneIndex = zoneBosses.findIndex((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
||||||
|
if (
|
||||||
|
nextZoneBoss
|
||||||
|
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
|
||||||
|
) {
|
||||||
|
const nextBossInState = state.bosses.find((b) => {
|
||||||
|
return b.id === nextZoneBoss.id;
|
||||||
|
});
|
||||||
|
if (nextBossInState) {
|
||||||
|
nextBossInState.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unlock any zone whose unlock conditions are now both satisfied
|
||||||
|
* (final boss defeated AND final quest completed)
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
for (const zone of state.zones) {
|
||||||
|
if (zone.status === "unlocked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (zone.unlockBossId !== body.bossId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss condition just became satisfied — check the quest condition too
|
||||||
|
const questSatisfied
|
||||||
|
= zone.unlockQuestId === null
|
||||||
|
|| state.quests.some((q) => {
|
||||||
|
return q.id === zone.unlockQuestId && q.status === "completed";
|
||||||
|
});
|
||||||
|
if (!questSatisfied) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
zone.status = "unlocked";
|
||||||
|
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||||
|
return b.zoneId === zone.id;
|
||||||
|
});
|
||||||
|
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||||
|
if (
|
||||||
|
firstUpdatedBoss
|
||||||
|
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
|
||||||
|
) {
|
||||||
|
firstUpdatedBoss.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update daily boss challenge progress
|
||||||
|
if (state.dailyChallenges) {
|
||||||
|
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
|
||||||
|
state.dailyChallenges,
|
||||||
|
"bossesDefeated",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
state.dailyChallenges = updatedChallenges;
|
||||||
|
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-kill bounty — only awarded once across all prestiges
|
||||||
|
const staticBoss = defaultBosses.find((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const bountyRunestones
|
||||||
|
= boss.bountyRunestonesClaimed === true
|
||||||
|
? 0
|
||||||
|
: staticBoss?.bountyRunestones ?? 0;
|
||||||
|
if (bountyRunestones > 0) {
|
||||||
|
boss.bountyRunestonesClaimed = true;
|
||||||
|
}
|
||||||
|
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||||
|
|
||||||
|
rewards = {
|
||||||
|
bountyRunestones: bountyRunestones,
|
||||||
|
crystals: boss.crystalReward,
|
||||||
|
equipmentIds: boss.equipmentRewards,
|
||||||
|
essence: boss.essenceReward,
|
||||||
|
gold: boss.goldReward,
|
||||||
|
upgradeIds: boss.upgradeRewards,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const partyDamageDealt = partyDPS * timeToKillParty;
|
||||||
|
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
||||||
|
bossUpdatedHp = boss.maxHp;
|
||||||
|
partyHpRemaining = 0;
|
||||||
|
|
||||||
|
boss.status = "available";
|
||||||
|
boss.currentHp = boss.maxHp;
|
||||||
|
|
||||||
|
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
|
||||||
|
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||||
|
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
|
||||||
|
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||||
|
|
||||||
|
casualties = [];
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (adventurer.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const killed = Math.floor(adventurer.count * casualtyFraction);
|
||||||
|
if (killed > 0) {
|
||||||
|
adventurer.count = Math.max(1, adventurer.count - killed);
|
||||||
|
casualties.push({ adventurerId: adventurer.id, killed: killed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock next boss in the same zone (zone-based sequential progression)
|
const now = Date.now();
|
||||||
const zoneBosses = state.bosses.filter((b) => {
|
await prisma.gameState.update({
|
||||||
return b.zoneId === boss.zoneId;
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
});
|
data: { state: state as object, updatedAt: now },
|
||||||
const zoneIndex = zoneBosses.findIndex((b) => {
|
where: { discordId },
|
||||||
return b.id === body.bossId;
|
|
||||||
});
|
|
||||||
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
|
||||||
if (
|
|
||||||
nextZoneBoss
|
|
||||||
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
|
|
||||||
) {
|
|
||||||
const nextBossInState = state.bosses.find((b) => {
|
|
||||||
return b.id === nextZoneBoss.id;
|
|
||||||
});
|
|
||||||
if (nextBossInState) {
|
|
||||||
nextBossInState.status = "available";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Unlock any zone whose unlock conditions are now both satisfied
|
|
||||||
* (final boss defeated AND final quest completed)
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
for (const zone of state.zones) {
|
|
||||||
if (zone.status === "unlocked") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (zone.unlockBossId !== body.bossId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boss condition just became satisfied — check the quest condition too
|
|
||||||
const questSatisfied
|
|
||||||
= zone.unlockQuestId === null
|
|
||||||
|| state.quests.some((q) => {
|
|
||||||
return q.id === zone.unlockQuestId && q.status === "completed";
|
|
||||||
});
|
|
||||||
if (!questSatisfied) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
zone.status = "unlocked";
|
|
||||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
|
||||||
return b.zoneId === zone.id;
|
|
||||||
});
|
|
||||||
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
|
||||||
if (
|
|
||||||
firstUpdatedBoss
|
|
||||||
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
|
|
||||||
) {
|
|
||||||
firstUpdatedBoss.status = "available";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update daily boss challenge progress
|
|
||||||
if (state.dailyChallenges) {
|
|
||||||
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
|
|
||||||
state.dailyChallenges,
|
|
||||||
"bossesDefeated",
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
state.dailyChallenges = updatedChallenges;
|
|
||||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-kill bounty — look up authoritative bounty from static data
|
|
||||||
const staticBoss = defaultBosses.find((b) => {
|
|
||||||
return b.id === body.bossId;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const { bossId } = body;
|
||||||
/* v8 ignore next -- @preserve */
|
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
|
||||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
|
||||||
|
|
||||||
rewards = {
|
const bossMaxHp = boss.maxHp;
|
||||||
bountyRunestones: bountyRunestones,
|
const bossNewHp = bossUpdatedHp;
|
||||||
crystals: boss.crystalReward,
|
const response: BossChallengeResponse = {
|
||||||
equipmentIds: boss.equipmentRewards,
|
bossDPS,
|
||||||
essence: boss.essenceReward,
|
bossHpAtBattleEnd,
|
||||||
gold: boss.goldReward,
|
bossHpBefore,
|
||||||
upgradeIds: boss.upgradeRewards,
|
bossMaxHp,
|
||||||
|
bossNewHp,
|
||||||
|
partyDPS,
|
||||||
|
partyHpRemaining,
|
||||||
|
partyMaxHp,
|
||||||
|
won,
|
||||||
};
|
};
|
||||||
} else {
|
if (rewards !== undefined) {
|
||||||
const partyDamageDealt = partyDPS * timeToKillParty;
|
response.rewards = rewards;
|
||||||
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
}
|
||||||
bossUpdatedHp = boss.maxHp;
|
if (casualties !== undefined) {
|
||||||
partyHpRemaining = 0;
|
response.casualties = casualties;
|
||||||
|
|
||||||
boss.status = "available";
|
|
||||||
boss.currentHp = boss.maxHp;
|
|
||||||
|
|
||||||
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
|
|
||||||
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
|
||||||
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
|
|
||||||
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
|
||||||
|
|
||||||
casualties = [];
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
if (adventurer.count === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const killed = Math.floor(adventurer.count * casualtyFraction);
|
|
||||||
if (killed > 0) {
|
|
||||||
adventurer.count = Math.max(1, adventurer.count - killed);
|
|
||||||
casualties.push({ adventurerId: adventurer.id, killed: killed });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
return context.json(response);
|
||||||
await prisma.gameState.update({
|
} catch (error) {
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
void logger.error(
|
||||||
data: { state: state as object, updatedAt: now },
|
"boss_challenge",
|
||||||
where: { discordId },
|
error instanceof Error
|
||||||
});
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
const bossMaxHp = boss.maxHp;
|
);
|
||||||
const bossNewHp = bossUpdatedHp;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
const response: BossChallengeResponse = {
|
|
||||||
bossDPS,
|
|
||||||
bossHpAtBattleEnd,
|
|
||||||
bossHpBefore,
|
|
||||||
bossMaxHp,
|
|
||||||
bossNewHp,
|
|
||||||
partyDPS,
|
|
||||||
partyHpRemaining,
|
|
||||||
partyMaxHp,
|
|
||||||
won,
|
|
||||||
};
|
|
||||||
if (rewards !== undefined) {
|
|
||||||
response.rewards = rewards;
|
|
||||||
}
|
}
|
||||||
if (casualties !== undefined) {
|
|
||||||
response.casualties = casualties;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { bossRouter };
|
export { bossRouter };
|
||||||
|
|||||||
+106
-82
@@ -11,6 +11,7 @@ import { Hono } from "hono";
|
|||||||
import { defaultRecipes } from "../data/recipes.js";
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
@@ -63,94 +64,117 @@ const recomputeCraftedMultipliers = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
craftRouter.post("/", async(context) => {
|
craftRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<CraftRecipeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<CraftRecipeRequest>();
|
||||||
|
|
||||||
const { recipeId } = body;
|
const { recipeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
return context.json({ error: "recipeId is required" }, 400);
|
return context.json({ error: "recipeId is required" }, 400);
|
||||||
}
|
|
||||||
|
|
||||||
const recipe = defaultRecipes.find((r) => {
|
|
||||||
return r.id === recipeId;
|
|
||||||
});
|
|
||||||
if (!recipe) {
|
|
||||||
return context.json({ error: "Unknown recipe" }, 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) {
|
|
||||||
return context.json({ error: "No exploration state found" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
|
|
||||||
return context.json({ error: "Recipe already crafted" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the player has all required materials
|
|
||||||
for (const requirement of recipe.requiredMaterials) {
|
|
||||||
const material = state.exploration.materials.find((m) => {
|
|
||||||
return m.materialId === requirement.materialId;
|
|
||||||
});
|
|
||||||
const quantity = material?.quantity ?? 0;
|
|
||||||
if (quantity < requirement.quantity) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct materials
|
const recipe = defaultRecipes.find((r) => {
|
||||||
for (const requirement of recipe.requiredMaterials) {
|
return r.id === recipeId;
|
||||||
const material = state.exploration.materials.find((m) => {
|
|
||||||
return m.materialId === requirement.materialId;
|
|
||||||
});
|
});
|
||||||
if (material) {
|
if (!recipe) {
|
||||||
material.quantity = material.quantity - requirement.quantity;
|
return context.json({ error: "Unknown recipe" }, 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) {
|
||||||
|
return context.json({ error: "No exploration state found" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
|
||||||
|
return context.json({ error: "Recipe already crafted" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the player has all required materials
|
||||||
|
for (const requirement of recipe.requiredMaterials) {
|
||||||
|
const material = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === requirement.materialId;
|
||||||
|
});
|
||||||
|
const quantity = material?.quantity ?? 0;
|
||||||
|
if (quantity < requirement.quantity) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct materials
|
||||||
|
for (const requirement of recipe.requiredMaterials) {
|
||||||
|
const material = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === requirement.materialId;
|
||||||
|
});
|
||||||
|
if (material) {
|
||||||
|
material.quantity = material.quantity - requirement.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recipe and recompute all multipliers from scratch
|
||||||
|
state.exploration.craftedRecipeIds.push(recipeId);
|
||||||
|
const updatedMultipliers = recomputeCraftedMultipliers(
|
||||||
|
state.exploration.craftedRecipeIds,
|
||||||
|
);
|
||||||
|
state.exploration.craftedGoldMultiplier
|
||||||
|
= updatedMultipliers.craftedGoldMultiplier;
|
||||||
|
state.exploration.craftedEssenceMultiplier
|
||||||
|
= updatedMultipliers.craftedEssenceMultiplier;
|
||||||
|
state.exploration.craftedClickMultiplier
|
||||||
|
= updatedMultipliers.craftedClickMultiplier;
|
||||||
|
state.exploration.craftedCombatMultiplier
|
||||||
|
= updatedMultipliers.craftedCombatMultiplier;
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
|
||||||
|
|
||||||
|
const bonusType = recipe.bonus.type;
|
||||||
|
const bonusValue = recipe.bonus.value;
|
||||||
|
const { materials } = state.exploration;
|
||||||
|
const {
|
||||||
|
craftedGoldMultiplier,
|
||||||
|
craftedEssenceMultiplier,
|
||||||
|
craftedClickMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
} = updatedMultipliers;
|
||||||
|
const response: CraftRecipeResponse = {
|
||||||
|
bonusType,
|
||||||
|
bonusValue,
|
||||||
|
craftedClickMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
craftedEssenceMultiplier,
|
||||||
|
craftedGoldMultiplier,
|
||||||
|
materials,
|
||||||
|
recipeId,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"craft",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recipe and recompute all multipliers from scratch
|
|
||||||
state.exploration.craftedRecipeIds.push(recipeId);
|
|
||||||
const updatedMultipliers = recomputeCraftedMultipliers(
|
|
||||||
state.exploration.craftedRecipeIds,
|
|
||||||
);
|
|
||||||
state.exploration.craftedGoldMultiplier
|
|
||||||
= updatedMultipliers.craftedGoldMultiplier;
|
|
||||||
state.exploration.craftedEssenceMultiplier
|
|
||||||
= updatedMultipliers.craftedEssenceMultiplier;
|
|
||||||
state.exploration.craftedClickMultiplier
|
|
||||||
= updatedMultipliers.craftedClickMultiplier;
|
|
||||||
state.exploration.craftedCombatMultiplier
|
|
||||||
= updatedMultipliers.craftedCombatMultiplier;
|
|
||||||
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: Date.now() },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const bonusType = recipe.bonus.type;
|
|
||||||
const bonusValue = recipe.bonus.value;
|
|
||||||
const response: CraftRecipeResponse = {
|
|
||||||
bonusType,
|
|
||||||
bonusValue,
|
|
||||||
recipeId,
|
|
||||||
...updatedMultipliers,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { craftRouter };
|
export { craftRouter };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+335
-249
@@ -7,13 +7,16 @@
|
|||||||
/* 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";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.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,
|
||||||
@@ -48,281 +51,292 @@ const pickNothingMessage = (): string => {
|
|||||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
exploreRouter.post("/start", async(context) => {
|
exploreRouter.get("/claimable", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<ExploreStartRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const areaId = context.req.query("areaId");
|
||||||
const { areaId } = body;
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
if (!areaId) {
|
||||||
if (!areaId) {
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
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;
|
|
||||||
|
|
||||||
// Backfill exploration state for old saves that predate this feature
|
|
||||||
if (!state.exploration) {
|
|
||||||
state.exploration = structuredClone(initialExploration);
|
|
||||||
// Unlock areas for zones already unlocked in this save
|
|
||||||
for (const area of state.exploration.areas) {
|
|
||||||
const areaData = defaultExplorations.find((areaItem) => {
|
|
||||||
return areaItem.id === area.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
if (!areaData) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const zone = state.zones.find((z) => {
|
|
||||||
return z.id === areaData.zoneId;
|
|
||||||
});
|
|
||||||
if (zone?.status === "unlocked") {
|
|
||||||
area.status = "available";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const zone = state.zones.find((z) => {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
return z.id === explorationArea.zoneId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!zone || zone.status !== "unlocked") {
|
if (!explorationArea) {
|
||||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const area = state.exploration.areas.find((a) => {
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
return a.id === areaId;
|
if (!record) {
|
||||||
});
|
return context.json({ error: "No save found" }, 404);
|
||||||
if (!area) {
|
}
|
||||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const anyInProgress = state.exploration.areas.some((a) => {
|
const rawState: unknown = record.state;
|
||||||
return a.status === "in_progress";
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
});
|
const state = rawState as GameState;
|
||||||
if (anyInProgress) {
|
|
||||||
return context.json(
|
if (!state.exploration) {
|
||||||
{ error: "An exploration is already in progress" },
|
const response: ExploreClaimableResponse = { claimable: false };
|
||||||
400,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (area.status === "locked") {
|
|
||||||
return context.json({ error: "Exploration area is locked" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
area.status = "in_progress";
|
|
||||||
area.startedAt = now;
|
|
||||||
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
|
||||||
const response: ExploreStartResponse = {
|
|
||||||
areaId,
|
|
||||||
endsAt,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
exploreRouter.post("/collect", async(context) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<ExploreCollectRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<ExploreStartRequest>();
|
||||||
|
|
||||||
const { areaId } = body;
|
const { areaId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!areaId) {
|
if (!areaId) {
|
||||||
return context.json({ error: "areaId is required" }, 400);
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const explorationArea = defaultExplorations.find((a) => {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!explorationArea) {
|
if (!explorationArea) {
|
||||||
return context.json({ error: "Unknown exploration area" }, 404);
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawState: unknown = record.state;
|
const rawState: unknown = record.state;
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
if (!state.exploration) {
|
// Backfill exploration state for old saves that predate this feature
|
||||||
return context.json({ error: "No exploration state found" }, 400);
|
if (!state.exploration) {
|
||||||
}
|
state.exploration = structuredClone(initialExploration);
|
||||||
|
// Unlock areas for zones already unlocked in this save
|
||||||
|
for (const area of state.exploration.areas) {
|
||||||
|
const areaData = defaultExplorations.find((areaItem) => {
|
||||||
|
return areaItem.id === area.id;
|
||||||
|
});
|
||||||
|
|
||||||
const area = state.exploration.areas.find((a) => {
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
return a.id === areaId;
|
/* v8 ignore next 3 -- @preserve */
|
||||||
});
|
if (!areaData) {
|
||||||
if (!area) {
|
continue;
|
||||||
return context.json({ error: "Exploration area not found" }, 404);
|
}
|
||||||
}
|
const zone = state.zones.find((z) => {
|
||||||
|
return z.id === areaData.zoneId;
|
||||||
|
});
|
||||||
|
if (zone?.status === "unlocked") {
|
||||||
|
area.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (area.status !== "in_progress") {
|
const zone = state.zones.find((z) => {
|
||||||
return context.json({ error: "Exploration is not in progress" }, 400);
|
return z.id === explorationArea.zoneId;
|
||||||
}
|
});
|
||||||
|
if (!zone || zone.status !== "unlocked") {
|
||||||
|
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const area = state.exploration.areas.find((a) => {
|
||||||
|
return a.id === areaId;
|
||||||
|
});
|
||||||
|
if (!area) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Exploration area not found in state" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const anyInProgress = state.exploration.areas.some((a) => {
|
||||||
/* v8 ignore next -- @preserve */
|
return a.status === "in_progress";
|
||||||
const startedAt = area.startedAt ?? 0;
|
});
|
||||||
const durationMs = explorationArea.durationSeconds * 1000;
|
if (anyInProgress) {
|
||||||
const expiresAt = startedAt + durationMs;
|
return context.json(
|
||||||
|
{ error: "An exploration is already in progress" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (now < expiresAt) {
|
if (area.status === "locked") {
|
||||||
return context.json({ error: "Exploration is not yet complete" }, 400);
|
return context.json({ error: "Exploration area is locked" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
area.status = "available";
|
const now = Date.now();
|
||||||
area.completedOnce = true;
|
area.status = "in_progress";
|
||||||
|
area.startedAt = now;
|
||||||
|
|
||||||
// 20% chance of finding nothing
|
|
||||||
if (Math.random() < nothingProbability) {
|
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: state as object, updatedAt: now },
|
data: { state: state as object, updatedAt: now },
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ExploreCollectResponse = {
|
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||||
event: null,
|
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||||
foundNothing: true,
|
const response: ExploreStartResponse = {
|
||||||
materialsFound: [],
|
areaId,
|
||||||
nothingMessage: pickNothingMessage(),
|
endsAt,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_start",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Pick a random event
|
exploreRouter.post("/collect", async(context) => {
|
||||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
try {
|
||||||
const event = explorationArea.events[eventIndex];
|
const discordId = context.get("discordId");
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const body = await context.req.json<ExploreCollectRequest>();
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
if (!event) {
|
|
||||||
return context.json({ error: "No events available" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply event effects and build the result summary
|
const { areaId } = body;
|
||||||
let goldChange = 0;
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
let essenceChange = 0;
|
if (!areaId) {
|
||||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (event.effect.type === "gold_gain") {
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
// Gold gain — amount may be undefined in edge cases
|
return a.id === areaId;
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
});
|
||||||
/* v8 ignore next -- @preserve */
|
if (!explorationArea) {
|
||||||
const amount = event.effect.amount ?? 0;
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
state.resources.gold = state.resources.gold + amount;
|
}
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
|
||||||
goldChange = amount;
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
} else if (event.effect.type === "gold_loss") {
|
if (!record) {
|
||||||
// Gold loss — amount may be undefined in edge cases
|
return context.json({ error: "No save found" }, 404);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
}
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
const rawState: unknown = record.state;
|
||||||
state.resources.gold = state.resources.gold - amount;
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
goldChange = -amount;
|
const state = rawState as GameState;
|
||||||
} else if (event.effect.type === "essence_gain") {
|
|
||||||
// Essence gain — amount may be undefined in edge cases
|
if (!state.exploration) {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
return context.json({ error: "No exploration state found" }, 400);
|
||||||
/* v8 ignore next -- @preserve */
|
}
|
||||||
const amount = event.effect.amount ?? 0;
|
|
||||||
state.resources.essence = state.resources.essence + amount;
|
const area = state.exploration.areas.find((a) => {
|
||||||
essenceChange = amount;
|
return a.id === areaId;
|
||||||
} else if (event.effect.type === "material_gain") {
|
});
|
||||||
const { materialId } = event.effect;
|
if (!area) {
|
||||||
|
return context.json({ error: "Exploration area not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area.status !== "in_progress") {
|
||||||
|
return context.json({ error: "Exploration is not in progress" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const quantity = event.effect.quantity ?? 1;
|
const startedAt = area.startedAt ?? 0;
|
||||||
if (materialId !== undefined && materialId !== "") {
|
const durationMs = explorationArea.durationSeconds * 1000;
|
||||||
const existing = state.exploration.materials.find((m) => {
|
const expiresAt = startedAt + durationMs;
|
||||||
return m.materialId === materialId;
|
|
||||||
|
if (now < expiresAt) {
|
||||||
|
return context.json({ error: "Exploration is not yet complete" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
area.status = "available";
|
||||||
|
area.completedOnce = true;
|
||||||
|
|
||||||
|
// 20% chance of finding nothing
|
||||||
|
if (Math.random() < nothingProbability) {
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
});
|
});
|
||||||
if (existing) {
|
|
||||||
existing.quantity = existing.quantity + quantity;
|
const response: ExploreCollectResponse = {
|
||||||
} else {
|
event: null,
|
||||||
state.exploration.materials.push({ materialId, quantity });
|
foundNothing: true,
|
||||||
}
|
materialsFound: [],
|
||||||
materialGained = { materialId, quantity };
|
nothingMessage: pickNothingMessage(),
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
};
|
||||||
/* v8 ignore next 13 -- @preserve */
|
return context.json(response);
|
||||||
}
|
}
|
||||||
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
|
||||||
// Adventurer loss — fraction and loop are defensive
|
// Pick a random event
|
||||||
|
const eventIndex = Math.floor(
|
||||||
|
Math.random() * explorationArea.events.length,
|
||||||
|
);
|
||||||
|
const event = explorationArea.events[eventIndex];
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 8 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
const fraction = event.effect.fraction ?? 0.05;
|
if (!event) {
|
||||||
for (const adventurer of state.adventurers) {
|
return context.json({ error: "No events available" }, 500);
|
||||||
const lost = Math.floor(adventurer.count * fraction);
|
|
||||||
if (lost > 0) {
|
|
||||||
adventurer.count = Math.max(0, adventurer.count - lost);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// Apply event effects and build the result summary
|
||||||
/* v8 ignore next 8 -- @preserve */
|
let goldChange = 0;
|
||||||
let adventurerLostCount = 0;
|
let essenceChange = 0;
|
||||||
if (event.effect.type === "adventurer_loss") {
|
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||||
const fraction = event.effect.fraction ?? 0.05;
|
|
||||||
for (const adv of state.adventurers) {
|
|
||||||
const lost = Math.floor(adv.count * fraction);
|
|
||||||
adventurerLostCount = adventurerLostCount + lost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventResult: ExploreCollectEventResult = {
|
if (event.effect.type === "gold_gain") {
|
||||||
adventurerLostCount: adventurerLostCount,
|
// Gold gain — amount may be undefined in edge cases
|
||||||
essenceChange: essenceChange,
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
goldChange: goldChange,
|
/* v8 ignore next -- @preserve */
|
||||||
materialGained: materialGained,
|
const amount = event.effect.amount ?? 0;
|
||||||
text: event.text,
|
state.resources.gold = state.resources.gold + amount;
|
||||||
};
|
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||||
|
goldChange = amount;
|
||||||
// Roll for material drops from possibleMaterials (weighted random selection)
|
} else if (event.effect.type === "gold_loss") {
|
||||||
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
// Gold loss — amount may be undefined in edge cases
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
if (explorationArea.possibleMaterials.length > 0) {
|
/* v8 ignore next -- @preserve */
|
||||||
let totalWeight = 0;
|
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||||
for (const materialDrop of explorationArea.possibleMaterials) {
|
state.resources.gold = state.resources.gold - amount;
|
||||||
totalWeight = totalWeight + materialDrop.weight;
|
goldChange = -amount;
|
||||||
}
|
} else if (event.effect.type === "essence_gain") {
|
||||||
let roll = Math.random() * totalWeight;
|
// Essence gain — amount may be undefined in edge cases
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
for (const possible of explorationArea.possibleMaterials) {
|
/* v8 ignore next -- @preserve */
|
||||||
roll = roll - possible.weight;
|
const amount = event.effect.amount ?? 0;
|
||||||
if (roll <= 0) {
|
state.resources.essence = state.resources.essence + amount;
|
||||||
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
essenceChange = amount;
|
||||||
const range = maxMinDiff + 1;
|
} else if (event.effect.type === "material_gain") {
|
||||||
const randomOffset = Math.floor(Math.random() * range);
|
const { materialId } = event.effect;
|
||||||
const quantity = randomOffset + possible.minQuantity;
|
|
||||||
const { materialId } = possible;
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const quantity = event.effect.quantity ?? 1;
|
||||||
|
if (materialId !== undefined && materialId !== "") {
|
||||||
const existing = state.exploration.materials.find((m) => {
|
const existing = state.exploration.materials.find((m) => {
|
||||||
return m.materialId === materialId;
|
return m.materialId === materialId;
|
||||||
});
|
});
|
||||||
@@ -331,25 +345,97 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
} else {
|
} else {
|
||||||
state.exploration.materials.push({ materialId, quantity });
|
state.exploration.materials.push({ materialId, quantity });
|
||||||
}
|
}
|
||||||
|
materialGained = { materialId, quantity };
|
||||||
materialsFound.push({ materialId, quantity });
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
break;
|
/* v8 ignore next 13 -- @preserve */
|
||||||
|
}
|
||||||
|
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
||||||
|
// Adventurer loss — fraction and loop are defensive
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
const fraction = event.effect.fraction ?? 0.05;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
const lost = Math.floor(adventurer.count * fraction);
|
||||||
|
if (lost > 0) {
|
||||||
|
adventurer.count = Math.max(0, adventurer.count - lost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
let adventurerLostCount = 0;
|
||||||
|
if (event.effect.type === "adventurer_loss") {
|
||||||
|
const fraction = event.effect.fraction ?? 0.05;
|
||||||
|
for (const adv of state.adventurers) {
|
||||||
|
const lost = Math.floor(adv.count * fraction);
|
||||||
|
adventurerLostCount = adventurerLostCount + lost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventResult: ExploreCollectEventResult = {
|
||||||
|
adventurerLostCount: adventurerLostCount,
|
||||||
|
essenceChange: essenceChange,
|
||||||
|
goldChange: goldChange,
|
||||||
|
materialGained: materialGained,
|
||||||
|
text: event.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roll for material drops from possibleMaterials (weighted random selection)
|
||||||
|
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
||||||
|
|
||||||
|
if (explorationArea.possibleMaterials.length > 0) {
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const materialDrop of explorationArea.possibleMaterials) {
|
||||||
|
totalWeight = totalWeight + materialDrop.weight;
|
||||||
|
}
|
||||||
|
let roll = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
for (const possible of explorationArea.possibleMaterials) {
|
||||||
|
roll = roll - possible.weight;
|
||||||
|
if (roll <= 0) {
|
||||||
|
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
||||||
|
const range = maxMinDiff + 1;
|
||||||
|
const randomOffset = Math.floor(Math.random() * range);
|
||||||
|
const quantity = randomOffset + possible.minQuantity;
|
||||||
|
const { materialId } = possible;
|
||||||
|
|
||||||
|
const existing = state.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === materialId;
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = existing.quantity + quantity;
|
||||||
|
} else {
|
||||||
|
state.exploration.materials.push({ materialId, quantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
materialsFound.push({ materialId, quantity });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ExploreCollectResponse = {
|
||||||
|
event: eventResult,
|
||||||
|
foundNothing: false,
|
||||||
|
materialsFound: materialsFound,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_collect",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ExploreCollectResponse = {
|
|
||||||
event: eventResult,
|
|
||||||
foundNothing: false,
|
|
||||||
materialsFound: materialsFound,
|
|
||||||
};
|
|
||||||
return context.json(response);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { exploreRouter };
|
export { exploreRouter };
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logging routes that pipe client-side logs to the telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
|
const validLevels = new Set([ "debug", "info", "warn" ]);
|
||||||
|
|
||||||
|
const frontendRouter = new Hono();
|
||||||
|
|
||||||
|
frontendRouter.post("/log", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ level: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.level || !body.message || !validLevels.has(body.level)) {
|
||||||
|
return context.json({ error: "level and message are required" }, 400);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */
|
||||||
|
void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`);
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_log",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frontendRouter.post("/error", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ context: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.context || !body.message) {
|
||||||
|
return context.json({ error: "context and message are required" }, 400);
|
||||||
|
}
|
||||||
|
void logger.error(`[FE] ${body.context}`, new Error(body.message));
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { frontendRouter };
|
||||||
+420
-356
@@ -27,6 +27,8 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { fetchDiscordUserById } from "../services/discord.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
checkAndUnlockTitles,
|
checkAndUnlockTitles,
|
||||||
@@ -681,18 +683,414 @@ const gameRouter = new Hono<HonoEnvironment>();
|
|||||||
gameRouter.use("*", authMiddleware);
|
gameRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
gameRouter.get("/load", async(context) => {
|
gameRouter.get("/load", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
Promise.all([
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
]);
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
|
]),
|
||||||
|
fetchDiscordUserById(discordId),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!record) {
|
// Refresh avatar in DB when Discord returns an updated hash
|
||||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
if (
|
||||||
|
freshDiscordUser !== null
|
||||||
|
&& playerRecord !== null
|
||||||
|
&& freshDiscordUser.avatar !== playerRecord.avatar
|
||||||
|
) {
|
||||||
|
playerRecord.avatar = freshDiscordUser.avatar;
|
||||||
|
void prisma.player.update({
|
||||||
|
data: { avatar: freshDiscordUser.avatar },
|
||||||
|
where: { discordId },
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
void logger.error(
|
||||||
|
"avatar_refresh",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||||
|
if (!playerRecord) {
|
||||||
|
return context.json({ error: "No player found" }, 404);
|
||||||
|
}
|
||||||
|
const freshState = initialGameState(
|
||||||
|
{
|
||||||
|
avatar: playerRecord.avatar,
|
||||||
|
characterName: playerRecord.characterName,
|
||||||
|
createdAt: playerRecord.createdAt,
|
||||||
|
discordId: playerRecord.discordId,
|
||||||
|
discriminator: playerRecord.discriminator,
|
||||||
|
lastSavedAt: Date.now(),
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||||
|
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||||
|
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
||||||
|
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
||||||
|
lifetimeClicks: playerRecord.lifetimeClicks,
|
||||||
|
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
|
||||||
|
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
|
||||||
|
totalClicks: 0,
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
username: playerRecord.username,
|
||||||
|
},
|
||||||
|
playerRecord.characterName,
|
||||||
|
);
|
||||||
|
const createdAt = Date.now();
|
||||||
|
await prisma.gameState.create({
|
||||||
|
data: {
|
||||||
|
discordId: discordId,
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
state: freshState as object,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
|
||||||
|
// Sign the state for anti-cheat verification
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const signature = secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(freshState), secret);
|
||||||
|
return context.json({
|
||||||
|
currentSchemaVersion: currentSchemaVersion,
|
||||||
|
inGuild: playerRecord.inGuild,
|
||||||
|
loginBonus: null,
|
||||||
|
loginStreak: playerRecord.loginStreak,
|
||||||
|
offlineEssence: 0,
|
||||||
|
offlineGold: 0,
|
||||||
|
offlineSeconds: 0,
|
||||||
|
schemaOutdated: false,
|
||||||
|
signature: signature,
|
||||||
|
state: freshState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Always sync character name from the Player record — the profile update route
|
||||||
|
* writes to Player.characterName directly, bypassing the game state blob.
|
||||||
|
*/
|
||||||
|
if (playerRecord !== null) {
|
||||||
|
state.player.characterName = playerRecord.characterName;
|
||||||
|
state.player.avatar = playerRecord.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const { offlineGold, offlineEssence, offlineSeconds }
|
||||||
|
= calculateOfflineEarnings(state, now);
|
||||||
|
|
||||||
|
if (offlineGold > 0) {
|
||||||
|
state.resources.gold = state.resources.gold + offlineGold;
|
||||||
|
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offlineEssence > 0) {
|
||||||
|
state.resources.essence = state.resources.essence + offlineEssence;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or reset daily challenges if a new day has begun
|
||||||
|
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||||
|
|
||||||
|
// Daily login bonus — award once per calendar day (UTC)
|
||||||
|
const todayUTC = new Date().toISOString().
|
||||||
|
slice(0, 10);
|
||||||
|
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
|
||||||
|
slice(0, 10);
|
||||||
|
let loginBonus: LoginBonusResult | null = null;
|
||||||
|
|
||||||
|
// Default loginStreak to 1 for brand-new accounts
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
let loginStreak = playerRecord?.loginStreak ?? 1;
|
||||||
|
|
||||||
|
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
|
||||||
|
const previousStreak = playerRecord.loginStreak;
|
||||||
|
const updatedStreak
|
||||||
|
= playerRecord.lastLoginDate === yesterdayUTC
|
||||||
|
? previousStreak + 1
|
||||||
|
: 1;
|
||||||
|
const dayIndex = (updatedStreak - 1) % 7;
|
||||||
|
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
|
||||||
|
const reward = dailyRewards[dayIndex];
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 2 -- @preserve */
|
||||||
|
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
|
||||||
|
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
|
||||||
|
|
||||||
|
state.resources.gold = Math.min(
|
||||||
|
state.resources.gold + goldEarned,
|
||||||
|
resourceCap,
|
||||||
|
);
|
||||||
|
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
|
||||||
|
state.resources.crystals = Math.min(
|
||||||
|
state.resources.crystals + crystalsEarned,
|
||||||
|
resourceCap,
|
||||||
|
);
|
||||||
|
|
||||||
|
loginStreak = updatedStreak;
|
||||||
|
loginBonus = {
|
||||||
|
crystalsEarned: crystalsEarned,
|
||||||
|
day: dayIndex + 1,
|
||||||
|
goldEarned: goldEarned,
|
||||||
|
streak: updatedStreak,
|
||||||
|
weekMultiplier: weekMultiplier,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.player.
|
||||||
|
update({
|
||||||
|
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
|
||||||
|
where: { discordId },
|
||||||
|
}).
|
||||||
|
catch((error: unknown) => {
|
||||||
|
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 5 -- @preserve */
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||||
|
const { code } = error as { code?: string };
|
||||||
|
if (code !== "P2034") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastTickAt = now;
|
||||||
|
|
||||||
|
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
||||||
|
// Persist updated state immediately so offline/login rewards aren't double-counted.
|
||||||
|
/*
|
||||||
|
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
||||||
|
* server-side and must be persisted immediately so they aren't double-counted.
|
||||||
|
*/
|
||||||
|
await prisma.gameState.
|
||||||
|
update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
}).
|
||||||
|
catch((error: unknown) => {
|
||||||
|
// Ignore write-conflict errors (P2034) — rethrow anything else
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 5 -- @preserve */
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
||||||
|
const { code } = error as { code?: string };
|
||||||
|
if (code !== "P2034") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const signature = secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
const inGuild = playerRecord?.inGuild ?? false;
|
||||||
|
return context.json({
|
||||||
|
currentSchemaVersion,
|
||||||
|
inGuild,
|
||||||
|
loginBonus,
|
||||||
|
loginStreak,
|
||||||
|
offlineEssence,
|
||||||
|
offlineGold,
|
||||||
|
offlineSeconds,
|
||||||
|
schemaOutdated,
|
||||||
|
signature,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"game_load",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gameRouter.post("/save", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<SaveRequest>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
|
||||||
|
if (body.state === null || body.state === undefined) {
|
||||||
|
return context.json({ error: "Missing state in request body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const [ record, playerRecord ] = await Promise.all([
|
||||||
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stateToSave = body.state;
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
const rawPreviousState: unknown = record.state;
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
|
const previousState = rawPreviousState as GameState;
|
||||||
|
|
||||||
|
// Option D: verify HMAC signature if the secret is configured and client sent one
|
||||||
|
if (secret !== undefined && body.signature !== undefined) {
|
||||||
|
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
||||||
|
if (body.signature !== expectedSig) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Save rejected: signature mismatch" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
||||||
|
stateToSave = validateAndSanitize(body.state, previousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stamp the authoritative save timestamp into the state blob so that on the
|
||||||
|
* next load the client reads the correct value from state.player.lastSavedAt.
|
||||||
|
*/
|
||||||
|
stateToSave = {
|
||||||
|
...stateToSave,
|
||||||
|
player: { ...stateToSave.player, lastSavedAt: now },
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve the Player record's character name so that profile updates are not
|
||||||
|
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||||
|
*/
|
||||||
|
stateToSave = {
|
||||||
|
...stateToSave,
|
||||||
|
player: {
|
||||||
|
...stateToSave.player,
|
||||||
|
characterName:
|
||||||
|
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||||
|
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 8 -- @preserve */
|
||||||
|
const companionUnlocks = computeUnlockedCompanionIds({
|
||||||
|
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||||
|
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||||
|
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
||||||
|
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||||
|
prestigeCount: stateToSave.prestige.count,
|
||||||
|
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
const clientActiveCompanionId
|
||||||
|
= stateToSave.companions?.activeCompanionId ?? null;
|
||||||
|
const validatedActiveCompanionId
|
||||||
|
= clientActiveCompanionId !== null
|
||||||
|
&& companionUnlocks.includes(clientActiveCompanionId)
|
||||||
|
? clientActiveCompanionId
|
||||||
|
: null;
|
||||||
|
stateToSave = {
|
||||||
|
...stateToSave,
|
||||||
|
companions: {
|
||||||
|
activeCompanionId: validatedActiveCompanionId,
|
||||||
|
unlockedCompanionIds: companionUnlocks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 6 -- @preserve */
|
||||||
|
const updatedTitles = checkAndUnlockTitles({
|
||||||
|
createdAt: playerRecord?.createdAt ?? Date.now(),
|
||||||
|
currentUnlocked: currentUnlocked,
|
||||||
|
guildName: playerRecord?.guildName ?? "",
|
||||||
|
state: stateToSave,
|
||||||
|
});
|
||||||
|
const updatedUnlocked
|
||||||
|
= updatedTitles.length > 0
|
||||||
|
? [ ...currentUnlocked, ...updatedTitles ]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: stateToSave.player.characterName,
|
||||||
|
lastSavedAt: now,
|
||||||
|
totalClicks: stateToSave.player.totalClicks,
|
||||||
|
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||||
|
...updatedUnlocked
|
||||||
|
? { unlockedTitles: updatedUnlocked }
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.gameState.upsert({
|
||||||
|
create: {
|
||||||
|
discordId: discordId,
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||||
|
state: stateToSave as unknown as never,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
||||||
|
update: { state: stateToSave as unknown as never, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||||
|
return context.json({ savedAt: now, signature: signature });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"game_save",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gameRouter.post("/reset", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
return context.json({ error: "No player found" }, 404);
|
return context.json({ error: "No player found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const freshState = initialGameState(
|
const freshState = initialGameState(
|
||||||
{
|
{
|
||||||
avatar: playerRecord.avatar,
|
avatar: playerRecord.avatar,
|
||||||
@@ -713,23 +1111,25 @@ gameRouter.get("/load", async(context) => {
|
|||||||
},
|
},
|
||||||
playerRecord.characterName,
|
playerRecord.characterName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdAt = Date.now();
|
const createdAt = Date.now();
|
||||||
await prisma.gameState.create({
|
await prisma.gameState.upsert({
|
||||||
data: {
|
create: {
|
||||||
discordId: discordId,
|
discordId: discordId,
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
state: freshState as object,
|
state: freshState as object,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
},
|
},
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
update: { state: freshState as object, updatedAt: createdAt },
|
||||||
|
where: { discordId },
|
||||||
});
|
});
|
||||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
||||||
|
|
||||||
// Sign the state for anti-cheat verification
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const signature = secret === undefined
|
const signature = secret === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: computeHmac(JSON.stringify(freshState), secret);
|
: computeHmac(JSON.stringify(freshState), secret);
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
currentSchemaVersion: currentSchemaVersion,
|
currentSchemaVersion: currentSchemaVersion,
|
||||||
loginBonus: null,
|
loginBonus: null,
|
||||||
@@ -741,351 +1141,15 @@ gameRouter.get("/load", async(context) => {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
state: freshState,
|
state: freshState,
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
const rawState: unknown = record.state;
|
"game_reset",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
error instanceof Error
|
||||||
const state = rawState as GameState;
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
/*
|
|
||||||
* Always sync character name from the Player record — the profile update route
|
|
||||||
* writes to Player.characterName directly, bypassing the game state blob.
|
|
||||||
*/
|
|
||||||
if (playerRecord !== null) {
|
|
||||||
state.player.characterName = playerRecord.characterName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const { offlineGold, offlineEssence, offlineSeconds }
|
|
||||||
= calculateOfflineEarnings(state, now);
|
|
||||||
|
|
||||||
if (offlineGold > 0) {
|
|
||||||
state.resources.gold = state.resources.gold + offlineGold;
|
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offlineEssence > 0) {
|
|
||||||
state.resources.essence = state.resources.essence + offlineEssence;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate or reset daily challenges if a new day has begun
|
|
||||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
|
||||||
|
|
||||||
// Daily login bonus — award once per calendar day (UTC)
|
|
||||||
const todayUTC = new Date().toISOString().
|
|
||||||
slice(0, 10);
|
|
||||||
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
|
|
||||||
slice(0, 10);
|
|
||||||
let loginBonus: LoginBonusResult | null = null;
|
|
||||||
|
|
||||||
// Default loginStreak to 1 for brand-new accounts
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
let loginStreak = playerRecord?.loginStreak ?? 1;
|
|
||||||
|
|
||||||
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
|
|
||||||
const previousStreak = playerRecord.loginStreak;
|
|
||||||
const updatedStreak
|
|
||||||
= playerRecord.lastLoginDate === yesterdayUTC
|
|
||||||
? previousStreak + 1
|
|
||||||
: 1;
|
|
||||||
const dayIndex = (updatedStreak - 1) % 7;
|
|
||||||
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
|
|
||||||
const reward = dailyRewards[dayIndex];
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 2 -- @preserve */
|
|
||||||
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
|
|
||||||
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
|
|
||||||
|
|
||||||
state.resources.gold = Math.min(
|
|
||||||
state.resources.gold + goldEarned,
|
|
||||||
resourceCap,
|
|
||||||
);
|
);
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
state.resources.crystals = Math.min(
|
|
||||||
state.resources.crystals + crystalsEarned,
|
|
||||||
resourceCap,
|
|
||||||
);
|
|
||||||
|
|
||||||
loginStreak = updatedStreak;
|
|
||||||
loginBonus = {
|
|
||||||
crystalsEarned: crystalsEarned,
|
|
||||||
day: dayIndex + 1,
|
|
||||||
goldEarned: goldEarned,
|
|
||||||
streak: updatedStreak,
|
|
||||||
weekMultiplier: weekMultiplier,
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.player.
|
|
||||||
update({
|
|
||||||
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
|
|
||||||
where: { discordId },
|
|
||||||
}).
|
|
||||||
catch((error: unknown) => {
|
|
||||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 5 -- @preserve */
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
|
||||||
const { code } = error as { code?: string };
|
|
||||||
if (code !== "P2034") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.lastTickAt = now;
|
|
||||||
|
|
||||||
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
|
||||||
// Persist updated state immediately so offline/login rewards aren't double-counted.
|
|
||||||
/*
|
|
||||||
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
|
||||||
* server-side and must be persisted immediately so they aren't double-counted.
|
|
||||||
*/
|
|
||||||
await prisma.gameState.
|
|
||||||
update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: state as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
}).
|
|
||||||
catch((error: unknown) => {
|
|
||||||
// Ignore write-conflict errors (P2034) — rethrow anything else
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 5 -- @preserve */
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
|
|
||||||
const { code } = error as { code?: string };
|
|
||||||
if (code !== "P2034") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
|
|
||||||
|
|
||||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
||||||
const signature = secret === undefined
|
|
||||||
? undefined
|
|
||||||
: computeHmac(JSON.stringify(state), secret);
|
|
||||||
return context.json({
|
|
||||||
currentSchemaVersion,
|
|
||||||
loginBonus,
|
|
||||||
loginStreak,
|
|
||||||
offlineEssence,
|
|
||||||
offlineGold,
|
|
||||||
offlineSeconds,
|
|
||||||
schemaOutdated,
|
|
||||||
signature,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
gameRouter.post("/save", async(context) => {
|
|
||||||
const discordId = context.get("discordId");
|
|
||||||
const body = await context.req.json<SaveRequest>();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
|
|
||||||
if (body.state === null || body.state === undefined) {
|
|
||||||
return context.json({ error: "Missing state in request body" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "Save rejected: outdated save. Reset your progress to continue.",
|
|
||||||
},
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let stateToSave = body.state;
|
|
||||||
|
|
||||||
if (record) {
|
|
||||||
const rawPreviousState: unknown = record.state;
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
|
||||||
const previousState = rawPreviousState as GameState;
|
|
||||||
|
|
||||||
// Option D: verify HMAC signature if the secret is configured and client sent one
|
|
||||||
if (secret !== undefined && body.signature !== undefined) {
|
|
||||||
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
|
||||||
if (body.signature !== expectedSig) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Save rejected: signature mismatch" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
|
||||||
stateToSave = validateAndSanitize(body.state, previousState);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Stamp the authoritative save timestamp into the state blob so that on the
|
|
||||||
* next load the client reads the correct value from state.player.lastSavedAt.
|
|
||||||
*/
|
|
||||||
stateToSave = {
|
|
||||||
...stateToSave,
|
|
||||||
player: { ...stateToSave.player, lastSavedAt: now },
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Preserve the Player record's character name so that profile updates are not
|
|
||||||
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
|
||||||
*/
|
|
||||||
stateToSave = {
|
|
||||||
...stateToSave,
|
|
||||||
player: {
|
|
||||||
...stateToSave.player,
|
|
||||||
characterName:
|
|
||||||
playerRecord?.characterName ?? stateToSave.player.characterName,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
|
||||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 8 -- @preserve */
|
|
||||||
const companionUnlocks = computeUnlockedCompanionIds({
|
|
||||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
|
||||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
|
||||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
|
||||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
|
||||||
prestigeCount: stateToSave.prestige.count,
|
|
||||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
|
||||||
});
|
|
||||||
const clientActiveCompanionId
|
|
||||||
= stateToSave.companions?.activeCompanionId ?? null;
|
|
||||||
const validatedActiveCompanionId
|
|
||||||
= clientActiveCompanionId !== null
|
|
||||||
&& companionUnlocks.includes(clientActiveCompanionId)
|
|
||||||
? clientActiveCompanionId
|
|
||||||
: null;
|
|
||||||
stateToSave = {
|
|
||||||
...stateToSave,
|
|
||||||
companions: {
|
|
||||||
activeCompanionId: validatedActiveCompanionId,
|
|
||||||
unlockedCompanionIds: companionUnlocks,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 6 -- @preserve */
|
|
||||||
const updatedTitles = checkAndUnlockTitles({
|
|
||||||
createdAt: playerRecord?.createdAt ?? Date.now(),
|
|
||||||
currentUnlocked: currentUnlocked,
|
|
||||||
guildName: playerRecord?.guildName ?? "",
|
|
||||||
state: stateToSave,
|
|
||||||
});
|
|
||||||
const updatedUnlocked
|
|
||||||
= updatedTitles.length > 0
|
|
||||||
? [ ...currentUnlocked, ...updatedTitles ]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: stateToSave.player.characterName,
|
|
||||||
lastSavedAt: now,
|
|
||||||
totalClicks: stateToSave.player.totalClicks,
|
|
||||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
|
||||||
...updatedUnlocked
|
|
||||||
? { unlockedTitles: updatedUnlocked }
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.gameState.upsert({
|
|
||||||
create: {
|
|
||||||
discordId: discordId,
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
|
||||||
state: stateToSave as unknown as never,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
|
|
||||||
update: { state: stateToSave as unknown as never, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const signature = secret === undefined
|
|
||||||
? undefined
|
|
||||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
|
||||||
return context.json({ savedAt: now, signature: signature });
|
|
||||||
});
|
|
||||||
|
|
||||||
gameRouter.post("/reset", async(context) => {
|
|
||||||
const discordId = context.get("discordId");
|
|
||||||
|
|
||||||
const playerRecord = await prisma.player.findUnique({ where: { discordId } });
|
|
||||||
if (!playerRecord) {
|
|
||||||
return context.json({ error: "No player found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const freshState = initialGameState(
|
|
||||||
{
|
|
||||||
avatar: playerRecord.avatar,
|
|
||||||
characterName: playerRecord.characterName,
|
|
||||||
createdAt: playerRecord.createdAt,
|
|
||||||
discordId: playerRecord.discordId,
|
|
||||||
discriminator: playerRecord.discriminator,
|
|
||||||
lastSavedAt: Date.now(),
|
|
||||||
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
|
||||||
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
|
||||||
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
|
||||||
lifetimeClicks: playerRecord.lifetimeClicks,
|
|
||||||
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
|
|
||||||
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
|
|
||||||
totalClicks: 0,
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
username: playerRecord.username,
|
|
||||||
},
|
|
||||||
playerRecord.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const createdAt = Date.now();
|
|
||||||
await prisma.gameState.upsert({
|
|
||||||
create: {
|
|
||||||
discordId: discordId,
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
state: freshState as object,
|
|
||||||
updatedAt: createdAt,
|
|
||||||
},
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
update: { state: freshState as object, updatedAt: createdAt },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
|
||||||
const signature = secret === undefined
|
|
||||||
? undefined
|
|
||||||
: computeHmac(JSON.stringify(freshState), secret);
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
currentSchemaVersion: currentSchemaVersion,
|
|
||||||
loginBonus: null,
|
|
||||||
loginStreak: playerRecord.loginStreak,
|
|
||||||
offlineEssence: 0,
|
|
||||||
offlineGold: 0,
|
|
||||||
offlineSeconds: 0,
|
|
||||||
schemaOutdated: false,
|
|
||||||
signature: signature,
|
|
||||||
state: freshState,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { gameRouter };
|
export { gameRouter };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
leaderboardRouter.get("/", async(context) => {
|
leaderboardRouter.get("/", async(context) => {
|
||||||
const category = context.req.query("category") ?? "totalGold";
|
try {
|
||||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
const category = context.req.query("category") ?? "totalGold";
|
||||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||||
|
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||||
|
|
||||||
if (!validCategories.has(category)) {
|
if (!validCategories.has(category)) {
|
||||||
return context.json({ error: "Invalid category" }, 400);
|
return context.json({ error: "Invalid category" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ players, gameStates ] = await Promise.all([
|
const [ players, gameStates ] = await Promise.all([
|
||||||
prisma.player.findMany(),
|
prisma.player.findMany(),
|
||||||
gameStateCategories.has(category)
|
gameStateCategories.has(category)
|
||||||
? prisma.gameState.findMany()
|
? prisma.gameState.findMany()
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stateMap = new Map(
|
const stateMap = new Map(
|
||||||
gameStates.map((gs) => {
|
gameStates.map((gs) => {
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries = players.
|
const entries = players.
|
||||||
filter((player) => {
|
filter((player) => {
|
||||||
return parseShowOnLeaderboards(player.profileSettings);
|
return parseShowOnLeaderboards(player.profileSettings);
|
||||||
}).
|
}).
|
||||||
map((player) => {
|
map((player) => {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
if (category === "totalGold") {
|
if (category === "totalGold") {
|
||||||
value = player.lifetimeGoldEarned;
|
value = player.lifetimeGoldEarned;
|
||||||
} else if (category === "bossesDefeated") {
|
} else if (category === "bossesDefeated") {
|
||||||
value = player.lifetimeBossesDefeated;
|
value = player.lifetimeBossesDefeated;
|
||||||
} else if (category === "questsCompleted") {
|
} else if (category === "questsCompleted") {
|
||||||
value = player.lifetimeQuestsCompleted;
|
value = player.lifetimeQuestsCompleted;
|
||||||
} else if (category === "achievementsUnlocked") {
|
} else if (category === "achievementsUnlocked") {
|
||||||
value = player.lifetimeAchievementsUnlocked;
|
value = player.lifetimeAchievementsUnlocked;
|
||||||
} else {
|
} else {
|
||||||
const state = stateMap.get(player.discordId);
|
const state = stateMap.get(player.discordId);
|
||||||
if (category === "prestigeCount") {
|
if (category === "prestigeCount") {
|
||||||
value = state?.prestige.count ?? 0;
|
value = state?.prestige.count ?? 0;
|
||||||
} else if (category === "transcendenceCount") {
|
} else if (category === "transcendenceCount") {
|
||||||
value = state?.transcendence?.count ?? 0;
|
value = state?.transcendence?.count ?? 0;
|
||||||
} else if (category === "apotheosisCount") {
|
} else if (category === "apotheosisCount") {
|
||||||
value = state?.apotheosis?.count ?? 0;
|
value = state?.apotheosis?.count ?? 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
return {
|
activeTitle: resolveTitleName(player.activeTitle),
|
||||||
activeTitle: resolveTitleName(player.activeTitle),
|
avatar: player.avatar ?? null,
|
||||||
avatar: player.avatar ?? null,
|
characterName: player.characterName,
|
||||||
characterName: player.characterName,
|
discordId: player.discordId,
|
||||||
discordId: player.discordId,
|
username: player.username,
|
||||||
username: player.username,
|
value: value,
|
||||||
value: value,
|
};
|
||||||
};
|
}).
|
||||||
}).
|
sort((a, b) => {
|
||||||
sort((a, b) => {
|
return b.value - a.value;
|
||||||
return b.value - a.value;
|
}).
|
||||||
}).
|
slice(0, limit).
|
||||||
slice(0, limit).
|
map((entry, index) => {
|
||||||
map((entry, index) => {
|
return { ...entry, rank: index + 1 };
|
||||||
return { ...entry, rank: index + 1 };
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({ category, entries });
|
return context.json({ category, entries });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"leaderboards",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { leaderboardRouter };
|
export { leaderboardRouter };
|
||||||
|
|||||||
+192
-163
@@ -6,11 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
/* 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 */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostPrestigeState,
|
buildPostPrestigeState,
|
||||||
computeRunestoneMultipliers,
|
computeRunestoneMultipliers,
|
||||||
@@ -25,190 +27,217 @@ const prestigeRouter = new Hono<HonoEnvironment>();
|
|||||||
prestigeRouter.use("*", authMiddleware);
|
prestigeRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
prestigeRouter.post("/", async(context) => {
|
prestigeRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForPrestige(state)) {
|
if (!isEligibleForPrestige(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update daily prestige challenge progress before resetting the run
|
||||||
|
let updatedDailyChallenges = state.dailyChallenges;
|
||||||
|
let challengeCrystals = 0;
|
||||||
|
if (updatedDailyChallenges) {
|
||||||
|
const result = updateChallengeProgress(
|
||||||
|
updatedDailyChallenges,
|
||||||
|
"prestige",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
updatedDailyChallenges = result.updatedChallenges;
|
||||||
|
challengeCrystals = result.crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
milestoneRunestones,
|
||||||
|
prestigeData,
|
||||||
|
prestigeState,
|
||||||
|
runestonesEarned,
|
||||||
|
} = buildPostPrestigeState(state, state.player.characterName);
|
||||||
|
|
||||||
|
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
||||||
|
const finalState: GameState = {
|
||||||
|
...prestigeState,
|
||||||
|
...updatedDailyChallenges === undefined
|
||||||
|
? {}
|
||||||
|
: { dailyChallenges: updatedDailyChallenges },
|
||||||
|
resources: {
|
||||||
|
...prestigeState.resources,
|
||||||
|
crystals: prestigeState.resources.crystals + challengeCrystals,
|
||||||
},
|
},
|
||||||
400,
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update daily prestige challenge progress before resetting the run
|
// Capture current-run stats to accumulate into lifetime totals before resetting
|
||||||
let updatedDailyChallenges = state.dailyChallenges;
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
let challengeCrystals = 0;
|
/* v8 ignore next 10 -- @preserve */
|
||||||
if (updatedDailyChallenges) {
|
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||||
const result = updateChallengeProgress(
|
return boss.status === "defeated";
|
||||||
updatedDailyChallenges,
|
}).length;
|
||||||
|
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: finalState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals — never reset
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
},
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const prestigeCount = prestigeData.count;
|
||||||
|
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||||
|
void postMilestoneWebhook(discordId, "prestige", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: prestigeData.count,
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 2 -- @preserve */
|
||||||
|
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
milestoneRunestones: milestoneRunestones,
|
||||||
|
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
|
runestones: runestonesEarned,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
"prestige",
|
"prestige",
|
||||||
1,
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
updatedDailyChallenges = result.updatedChallenges;
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
challengeCrystals = result.crystalsAwarded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
milestoneRunestones,
|
|
||||||
prestigeData,
|
|
||||||
prestigeState,
|
|
||||||
runestonesEarned,
|
|
||||||
} = buildPostPrestigeState(state, state.player.characterName);
|
|
||||||
|
|
||||||
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
|
||||||
const finalState: GameState = {
|
|
||||||
...prestigeState,
|
|
||||||
...updatedDailyChallenges === undefined
|
|
||||||
? {}
|
|
||||||
: { dailyChallenges: updatedDailyChallenges },
|
|
||||||
resources: {
|
|
||||||
...prestigeState.resources,
|
|
||||||
crystals: prestigeState.resources.crystals + challengeCrystals,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture current-run stats to accumulate into lifetime totals before resetting
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 10 -- @preserve */
|
|
||||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
|
||||||
return boss.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
|
||||||
return quest.status === "completed";
|
|
||||||
}).length;
|
|
||||||
let runAdventurersRecruited = 0;
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: finalState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals — never reset
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void postMilestoneWebhook(discordId, "prestige", {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
|
||||||
|
|
||||||
prestige: prestigeData.count,
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 2 -- @preserve */
|
|
||||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
milestoneRunestones: milestoneRunestones,
|
|
||||||
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
|
||||||
runestones: runestonesEarned,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||||
|
|
||||||
const { upgradeId } = body;
|
const { upgradeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!upgradeId) {
|
if (!upgradeId) {
|
||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||||
return prestigeUpgrade.id === upgradeId;
|
return prestigeUpgrade.id === upgradeId;
|
||||||
});
|
});
|
||||||
if (!upgrade) {
|
if (!upgrade) {
|
||||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||||
|
|
||||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runestones < upgrade.runestonesCost) {
|
if (runestones < upgrade.runestonesCost) {
|
||||||
return context.json({ error: "Not enough runestones" }, 400);
|
return context.json({ error: "Not enough runestones" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
|
|
||||||
const updatedState: GameState = {
|
const updatedState: GameState = {
|
||||||
...state,
|
...state,
|
||||||
prestige: {
|
prestige: {
|
||||||
...state.prestige,
|
...state.prestige,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
|
runestones: updatedRunestones,
|
||||||
|
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
||||||
|
|
||||||
|
void logger.metric("prestige_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
|
return context.json({
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
runestones: updatedRunestones,
|
runestonesRemaining: updatedRunestones,
|
||||||
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
...multipliers,
|
||||||
},
|
});
|
||||||
};
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
await prisma.gameState.update({
|
"prestige_buy_upgrade",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
error instanceof Error
|
||||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
? error
|
||||||
where: { discordId },
|
: new Error(String(error)),
|
||||||
});
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
}
|
||||||
|
|
||||||
return context.json({
|
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
|
||||||
runestonesRemaining: updatedRunestones,
|
|
||||||
...multipliers,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { prestigeRouter };
|
export { prestigeRouter };
|
||||||
|
|||||||
+183
-162
@@ -20,6 +20,7 @@ import { Hono } from "hono";
|
|||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { parseUnlockedTitles } from "../services/titles.js";
|
import { parseUnlockedTitles } from "../services/titles.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
@@ -81,190 +82,210 @@ const resolveTitle = (id: string): { id: string; name: string } => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
profileRouter.get("/:discordId", async(context) => {
|
profileRouter.get("/:discordId", async(context) => {
|
||||||
const { discordId } = context.req.param();
|
try {
|
||||||
|
const { discordId } = context.req.param();
|
||||||
|
|
||||||
const [ player, gameStateRecord ] = await Promise.all([
|
const [ player, gameStateRecord ] = await Promise.all([
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!player) {
|
if (!player) {
|
||||||
return context.json({ error: "Player not found" }, 404);
|
return context.json({ error: "Player not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||||
const prestigeCount = state?.prestige.count ?? 0;
|
const prestigeCount = state?.prestige.count ?? 0;
|
||||||
const transcendenceCount = state?.transcendence?.count ?? 0;
|
const transcendenceCount = state?.transcendence?.count ?? 0;
|
||||||
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
||||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||||
|
|
||||||
const bossesDefeated
|
const bossesDefeated
|
||||||
= state?.bosses.filter((boss) => {
|
= state?.bosses.filter((boss) => {
|
||||||
return boss.status === "defeated";
|
return boss.status === "defeated";
|
||||||
}).length ?? 0;
|
}).length ?? 0;
|
||||||
const questsCompleted
|
const questsCompleted
|
||||||
= state?.quests.filter((quest) => {
|
= state?.quests.filter((quest) => {
|
||||||
return quest.status === "completed";
|
return quest.status === "completed";
|
||||||
}).length ?? 0;
|
}).length ?? 0;
|
||||||
|
|
||||||
|
let adventurersRecruited = 0;
|
||||||
|
if (state) {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
adventurersRecruited = adventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let adventurersRecruited = 0;
|
|
||||||
if (state) {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 3 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
for (const adventurer of state.adventurers) {
|
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
||||||
adventurersRecruited = adventurersRecruited + adventurer.count;
|
return achievement.unlockedAt !== null;
|
||||||
}
|
}).length;
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
|
||||||
/* v8 ignore next 3 -- @preserve */
|
const unlockedTitles = unlockedTitleIds.map((id) => {
|
||||||
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
return resolveTitle(id);
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
|
|
||||||
const unlockedTitles = unlockedTitleIds.map((id) => {
|
|
||||||
return resolveTitle(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 12 -- @preserve */
|
|
||||||
const equippedItems = (state?.equipment ?? []).
|
|
||||||
filter((item) => {
|
|
||||||
return item.owned && item.equipped;
|
|
||||||
}).
|
|
||||||
map((item) => {
|
|
||||||
return {
|
|
||||||
bonus: item.bonus,
|
|
||||||
name: item.name,
|
|
||||||
rarity: item.rarity,
|
|
||||||
type: item.type,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const completedChapters = state?.story?.completedChapters ?? [];
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 12 -- @preserve */
|
||||||
|
const equippedItems = (state?.equipment ?? []).
|
||||||
|
filter((item) => {
|
||||||
|
return item.owned && item.equipped;
|
||||||
|
}).
|
||||||
|
map((item) => {
|
||||||
|
return {
|
||||||
|
bonus: item.bonus,
|
||||||
|
name: item.name,
|
||||||
|
rarity: item.rarity,
|
||||||
|
type: item.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return context.json({
|
const completedChapters = state?.story?.completedChapters ?? [];
|
||||||
achievementsUnlocked: achievementsUnlocked,
|
|
||||||
activeTitle: player.activeTitle,
|
return context.json({
|
||||||
adventurersRecruited: adventurersRecruited,
|
achievementsUnlocked: achievementsUnlocked,
|
||||||
apotheosisCount: apotheosisCount,
|
activeTitle: player.activeTitle,
|
||||||
avatar: player.avatar,
|
adventurersRecruited: adventurersRecruited,
|
||||||
bio: player.bio ?? "",
|
apotheosisCount: apotheosisCount,
|
||||||
bossesDefeated: bossesDefeated,
|
avatar: player.avatar,
|
||||||
characterClass: player.characterClass,
|
bio: player.bio ?? "",
|
||||||
characterName: player.characterName,
|
bossesDefeated: bossesDefeated,
|
||||||
characterRace: player.characterRace ?? "",
|
characterClass: player.characterClass,
|
||||||
completedChapters: completedChapters,
|
characterName: player.characterName,
|
||||||
createdAt: player.createdAt,
|
characterRace: player.characterRace ?? "",
|
||||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
completedChapters: completedChapters,
|
||||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
createdAt: player.createdAt,
|
||||||
equippedItems: equippedItems,
|
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||||
guildDescription: player.guildDescription,
|
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||||
guildName: player.guildName,
|
equippedItems: equippedItems,
|
||||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
guildDescription: player.guildDescription,
|
||||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
guildName: player.guildName,
|
||||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||||
prestigeCount: prestigeCount,
|
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||||
profileSettings: profileSettings,
|
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||||
pronouns: player.pronouns ?? "",
|
prestigeCount: prestigeCount,
|
||||||
questsCompleted: questsCompleted,
|
profileSettings: profileSettings,
|
||||||
totalClicks: player.lifetimeClicks,
|
pronouns: player.pronouns ?? "",
|
||||||
totalGoldEarned: player.lifetimeGoldEarned,
|
questsCompleted: questsCompleted,
|
||||||
transcendenceCount: transcendenceCount,
|
totalClicks: player.lifetimeClicks,
|
||||||
unlockedTitles: unlockedTitles,
|
totalGoldEarned: player.lifetimeGoldEarned,
|
||||||
username: player.username,
|
transcendenceCount: transcendenceCount,
|
||||||
});
|
unlockedTitles: unlockedTitles,
|
||||||
|
username: player.username,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_get",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
profileRouter.put("/", authMiddleware, async(context) => {
|
profileRouter.put("/", authMiddleware, async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<UpdateProfileRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<UpdateProfileRequest>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!body.characterName) {
|
if (!body.characterName) {
|
||||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const characterName = body.characterName.trim().slice(0, 32);
|
const characterName = body.characterName.trim().slice(0, 32);
|
||||||
|
|
||||||
if (characterName === "") {
|
if (characterName === "") {
|
||||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
||||||
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
||||||
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
||||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||||
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
||||||
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 2 -- @preserve */
|
/* v8 ignore next 2 -- @preserve */
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
|
||||||
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
|
|
||||||
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
|
||||||
: "suffix";
|
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
||||||
const profileSettings: ProfileSettings = {
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
: "suffix";
|
||||||
numberFormat: numberFormat,
|
const profileSettings: ProfileSettings = {
|
||||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||||
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
numberFormat: numberFormat,
|
||||||
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||||
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||||
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
||||||
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
||||||
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
||||||
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
||||||
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
||||||
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
||||||
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
||||||
showPrestige: body.profileSettings.showPrestige ?? true,
|
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
||||||
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
||||||
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
||||||
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
showPrestige: body.profileSettings.showPrestige ?? true,
|
||||||
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
||||||
};
|
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
||||||
|
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
||||||
|
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
const activeTitle
|
const activeTitle
|
||||||
= typeof body.activeTitle === "string"
|
= typeof body.activeTitle === "string"
|
||||||
? body.activeTitle.slice(0, 64)
|
? body.activeTitle.slice(0, 64)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const updated = await prisma.player.update({
|
const updated = await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
bio: bio,
|
bio: bio,
|
||||||
characterClass: characterClass,
|
characterClass: characterClass,
|
||||||
characterName: characterName,
|
characterName: characterName,
|
||||||
characterRace: characterRace,
|
characterRace: characterRace,
|
||||||
guildDescription: guildDescription,
|
guildDescription: guildDescription,
|
||||||
guildName: guildName,
|
guildName: guildName,
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
profileSettings: profileSettings as object,
|
profileSettings: profileSettings as object,
|
||||||
pronouns: pronouns,
|
pronouns: pronouns,
|
||||||
...activeTitle === undefined
|
...activeTitle === undefined
|
||||||
? {}
|
? {}
|
||||||
: { activeTitle },
|
: { activeTitle },
|
||||||
},
|
},
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
activeTitle: updated.activeTitle,
|
activeTitle: updated.activeTitle,
|
||||||
bio: updated.bio,
|
bio: updated.bio,
|
||||||
characterClass: updated.characterClass,
|
characterClass: updated.characterClass,
|
||||||
characterName: updated.characterName,
|
characterName: updated.characterName,
|
||||||
characterRace: updated.characterRace,
|
characterRace: updated.characterRace,
|
||||||
guildDescription: updated.guildDescription,
|
guildDescription: updated.guildDescription,
|
||||||
guildName: updated.guildName,
|
guildName: updated.guildName,
|
||||||
profileSettings: profileSettings,
|
profileSettings: profileSettings,
|
||||||
pronouns: updated.pronouns,
|
pronouns: updated.pronouns,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_update",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { profileRouter };
|
export { profileRouter };
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
/* 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 */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostTranscendenceState,
|
buildPostTranscendenceState,
|
||||||
computeTranscendenceMultipliers,
|
computeTranscendenceMultipliers,
|
||||||
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
|
|||||||
transcendenceRouter.use("*", authMiddleware);
|
transcendenceRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
transcendenceRouter.post("/", async(context) => {
|
transcendenceRouter.post("/", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForTranscendence(state)) {
|
if (!isEligibleForTranscendence(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for transcendence — defeat The Absolute One first",
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
|
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
echoesEarned,
|
||||||
|
transcendenceData,
|
||||||
|
transcendenceState,
|
||||||
|
} = buildPostTranscendenceState(state, state.player.characterName);
|
||||||
|
|
||||||
|
// Capture current-run stats before the nuclear reset
|
||||||
|
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: transcendenceState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
data: {
|
||||||
|
characterName: state.player.characterName,
|
||||||
|
|
||||||
|
lastSavedAt: now,
|
||||||
|
|
||||||
|
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||||
|
|
||||||
|
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||||
|
|
||||||
|
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||||
|
|
||||||
|
lifetimeClicks: { increment: state.player.totalClicks },
|
||||||
|
|
||||||
|
// Accumulate into lifetime totals
|
||||||
|
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||||
|
|
||||||
|
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||||
|
|
||||||
|
totalClicks: 0,
|
||||||
|
// Reset current-run counters (same as prestige)
|
||||||
|
totalGoldEarned: 0,
|
||||||
},
|
},
|
||||||
400,
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcendenceCount = transcendenceData.count;
|
||||||
|
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
|
||||||
|
void postMilestoneWebhook(discordId, "transcendence", {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
||||||
|
|
||||||
|
prestige: transcendenceState.prestige.count,
|
||||||
|
|
||||||
|
transcendence: transcendenceData.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
echoes: echoesEarned,
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
|
newTranscendenceCount: transcendenceData.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"transcendence",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
echoesEarned,
|
|
||||||
transcendenceData,
|
|
||||||
transcendenceState,
|
|
||||||
} = buildPostTranscendenceState(state, state.player.characterName);
|
|
||||||
|
|
||||||
// Capture current-run stats before the nuclear reset
|
|
||||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
|
||||||
return boss.status === "defeated";
|
|
||||||
}).length;
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 7 -- @preserve */
|
|
||||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
|
||||||
return quest.status === "completed";
|
|
||||||
}).length;
|
|
||||||
let runAdventurersRecruited = 0;
|
|
||||||
for (const adventurer of state.adventurers) {
|
|
||||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next 3 -- @preserve */
|
|
||||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
|
||||||
return achievement.unlockedAt !== null;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await prisma.gameState.update({
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
|
||||||
data: { state: transcendenceState as object, updatedAt: now },
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.player.update({
|
|
||||||
data: {
|
|
||||||
characterName: state.player.characterName,
|
|
||||||
|
|
||||||
lastSavedAt: now,
|
|
||||||
|
|
||||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
|
||||||
|
|
||||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
|
||||||
|
|
||||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
|
||||||
|
|
||||||
lifetimeClicks: { increment: state.player.totalClicks },
|
|
||||||
|
|
||||||
// Accumulate into lifetime totals
|
|
||||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
|
||||||
|
|
||||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
|
||||||
|
|
||||||
totalClicks: 0,
|
|
||||||
// Reset current-run counters (same as prestige)
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
},
|
|
||||||
where: { discordId },
|
|
||||||
});
|
|
||||||
|
|
||||||
void postMilestoneWebhook(discordId, "transcendence", {
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
|
||||||
/* v8 ignore next -- @preserve */
|
|
||||||
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
|
||||||
|
|
||||||
prestige: transcendenceState.prestige.count,
|
|
||||||
|
|
||||||
transcendence: transcendenceData.count,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
echoes: echoesEarned,
|
|
||||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
|
||||||
newTranscendenceCount: transcendenceData.count,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||||
const discordId = context.get("discordId");
|
try {
|
||||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||||
|
|
||||||
const { upgradeId } = body;
|
const { upgradeId } = body;
|
||||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
if (!upgradeId) {
|
if (!upgradeId) {
|
||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||||
return transcendenceUpgrade.id === upgradeId;
|
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||||
});
|
return transcendenceUpgrade.id === upgradeId;
|
||||||
if (!upgrade) {
|
});
|
||||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
if (!upgrade) {
|
||||||
}
|
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return context.json({ error: "No save found" }, 404);
|
return context.json({ error: "No save found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!state.transcendence) {
|
if (!state.transcendence) {
|
||||||
return context.json({ error: "No transcendence data found" }, 400);
|
return context.json({ error: "No transcendence data found" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||||
|
|
||||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (echoes < upgrade.cost) {
|
if (echoes < upgrade.cost) {
|
||||||
return context.json({ error: "Not enough echoes" }, 400);
|
return context.json({ error: "Not enough echoes" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedEchoes = echoes - upgrade.cost;
|
const updatedEchoes = echoes - upgrade.cost;
|
||||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
const updatedMultipliers
|
const updatedMultipliers
|
||||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||||
|
|
||||||
const updatedState: GameState = {
|
const updatedState: GameState = {
|
||||||
...state,
|
...state,
|
||||||
transcendence: {
|
transcendence: {
|
||||||
...state.transcendence,
|
...state.transcendence,
|
||||||
echoes: updatedEchoes,
|
echoes: updatedEchoes,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
...updatedMultipliers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("transcendence_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
|
return context.json({
|
||||||
|
echoesRemaining: updatedEchoes,
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
...updatedMultipliers,
|
...updatedMultipliers,
|
||||||
},
|
});
|
||||||
};
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
await prisma.gameState.update({
|
"transcendence_buy_upgrade",
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
error instanceof Error
|
||||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
? error
|
||||||
where: { discordId },
|
: new Error(String(error)),
|
||||||
});
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
return context.json({
|
}
|
||||||
echoesRemaining: updatedEchoes,
|
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
|
||||||
...updatedMultipliers,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { transcendenceRouter };
|
export { transcendenceRouter };
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* 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";
|
||||||
|
|
||||||
|
const discordClientId = "1479551654264049908";
|
||||||
|
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -30,38 +34,42 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
try {
|
||||||
body: parameters.toString(),
|
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
body: parameters.toString(),
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
});
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
||||||
|
return await (response.json() as Promise<DiscordTokenResponse>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_exchange_code",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
|
||||||
return await (response.json() as Promise<DiscordTokenResponse>);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,16 +81,60 @@ const exchangeCode = async(
|
|||||||
const fetchDiscordUser = async(
|
const fetchDiscordUser = async(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<DiscordUser> => {
|
): Promise<DiscordUser> => {
|
||||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
try {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||||
});
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
/**
|
||||||
return await (response.json() as Promise<DiscordUser>);
|
* Fetches a Discord user's profile by their Discord ID using the bot token.
|
||||||
|
* Returns null on any failure so callers are never blocked by Discord API issues.
|
||||||
|
* @param discordId - The Discord user ID to look up.
|
||||||
|
* @returns The Discord user object, or null if the fetch fails.
|
||||||
|
*/
|
||||||
|
const fetchDiscordUserById = async(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<DiscordUser | null> => {
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
if (botToken === undefined || botToken === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://discord.com/api/v10/users/${discordId}`,
|
||||||
|
{ headers: { Authorization: `Bot ${botToken}` } },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user_by_id",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,19 +143,9 @@ const fetchDiscordUser = 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",
|
||||||
});
|
});
|
||||||
@@ -112,4 +154,4 @@ const buildOAuthUrl = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type { DiscordTokenResponse, DiscordUser };
|
export type { DiscordTokenResponse, DiscordUser };
|
||||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @file Logger service for handling logging.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@nhcarrigan/logger";
|
||||||
|
|
||||||
|
const logger = new Logger("Elysium", process.env.LOG_TOKEN ?? "");
|
||||||
|
|
||||||
|
export { logger };
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
||||||
|
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
|
||||||
import { initialGameState } from "../data/initialState.js";
|
import { initialGameState } from "../data/initialState.js";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -205,10 +206,81 @@ const buildPostPrestigeState = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const freshState = initialGameState(currentState.player, characterName);
|
const freshState = initialGameState(currentState.player, characterName);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve first-kill (bounty claimed) status across the prestige reset so
|
||||||
|
* the one-time bounty is never re-awarded in subsequent runs.
|
||||||
|
*/
|
||||||
|
const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => {
|
||||||
|
const currentBoss = currentState.bosses.find((candidate) => {
|
||||||
|
return candidate.id === freshBoss.id;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
currentBoss?.bountyRunestonesClaimed === true
|
||||||
|
|| currentBoss?.status === "defeated"
|
||||||
|
) {
|
||||||
|
return { ...freshBoss, bountyRunestonesClaimed: true };
|
||||||
|
}
|
||||||
|
return freshBoss;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute current-run contributions to accumulate into lifetime totals
|
||||||
|
const runBossesDefeated = currentState.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = currentState.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of currentState.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
const runAchievementsUnlocked = currentState.achievements.filter(
|
||||||
|
(achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
},
|
||||||
|
).length;
|
||||||
|
|
||||||
const prestigeState: GameState = {
|
const prestigeState: GameState = {
|
||||||
...freshState,
|
...freshState,
|
||||||
|
|
||||||
|
// Achievements are permanent — earned achievements survive all prestiges
|
||||||
|
achievements: currentState.achievements,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve automation preferences across prestige — the player explicitly
|
||||||
|
* opted into these settings and would not expect them to silently reset.
|
||||||
|
*/
|
||||||
|
autoBoss: currentState.autoBoss ?? false,
|
||||||
|
|
||||||
|
autoQuest: currentState.autoQuest ?? false,
|
||||||
|
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||||
|
bosses: bossesWithBountyClaimed,
|
||||||
lastTickAt: Date.now(),
|
lastTickAt: Date.now(),
|
||||||
prestige: prestigeData,
|
|
||||||
|
/*
|
||||||
|
* Fold current-run totals into lifetime stats so the GameState reflects
|
||||||
|
* the true all-time values immediately after prestige.
|
||||||
|
*/
|
||||||
|
player: {
|
||||||
|
...freshState.player,
|
||||||
|
lifetimeAchievementsUnlocked:
|
||||||
|
freshState.player.lifetimeAchievementsUnlocked
|
||||||
|
+ runAchievementsUnlocked,
|
||||||
|
lifetimeAdventurersRecruited:
|
||||||
|
freshState.player.lifetimeAdventurersRecruited
|
||||||
|
+ runAdventurersRecruited,
|
||||||
|
lifetimeBossesDefeated:
|
||||||
|
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
|
||||||
|
lifetimeClicks:
|
||||||
|
freshState.player.lifetimeClicks + currentState.player.totalClicks,
|
||||||
|
lifetimeGoldEarned:
|
||||||
|
freshState.player.lifetimeGoldEarned
|
||||||
|
+ currentState.player.totalGoldEarned,
|
||||||
|
lifetimeQuestsCompleted:
|
||||||
|
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
|
||||||
|
},
|
||||||
|
prestige: prestigeData,
|
||||||
// Codex lore persists across prestiges — players keep their discovered entries
|
// Codex lore persists across prestiges — players keep their discovered entries
|
||||||
...currentState.codex === undefined
|
...currentState.codex === undefined
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
@@ -5,8 +5,59 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordApi = "https://discord.com/api/v10";
|
const discordApi = "https://discord.com/api/v10";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
|
||||||
|
* triggering desktop or mobile push notifications.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
@@ -15,26 +66,29 @@ const discordApi = "https://discord.com/api/v10";
|
|||||||
*/
|
*/
|
||||||
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: {
|
||||||
method: "PUT",
|
"Authorization": `Bot ${botToken}`,
|
||||||
|
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_apotheosis_role",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — role grant failure must not affect the apotheosis
|
// Graceful degradation — role grant failure must not affect the apotheosis
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,13 +131,22 @@ const postMilestoneWebhook = async(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(webhookUrl, {
|
await fetch(webhookUrl, {
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
flags: suppressNotifications,
|
||||||
|
}),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_milestone",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — webhook failure must not affect the game action
|
// Graceful degradation — webhook failure must not affect the game action
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { grantApotheosisRole, postMilestoneWebhook };
|
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
|
||||||
|
|||||||
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
|
|||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
||||||
|
const { app, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw "raw string error";
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post();
|
||||||
|
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 post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns apotheosis count on success", async () => {
|
it("returns apotheosis count on success", async () => {
|
||||||
// Need all 15 transcendence upgrades purchased for eligibility
|
// Need all 15 transcendence upgrades purchased for eligibility
|
||||||
const allUpgradeIds = [
|
const allUpgradeIds = [
|
||||||
|
|||||||
@@ -113,5 +113,14 @@ describe("auth route", () => {
|
|||||||
const location = res.headers.get("Location") ?? "";
|
const location = res.headers.get("Location") ?? "";
|
||||||
expect(location).toContain("error=auth_failed");
|
expect(location).toContain("error=auth_failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects with error when callback throws a non-Error value", async () => {
|
||||||
|
const { app, exchangeCode } = await makeApp();
|
||||||
|
exchangeCode.mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const location = res.headers.get("Location") ?? "";
|
||||||
|
expect(location).toContain("error=auth_failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,4 +293,37 @@ describe("boss route", () => {
|
|||||||
const body = await res.json() as { won: boolean };
|
const body = await res.json() as { won: boolean };
|
||||||
expect(body.won).toBe(true);
|
expect(body.won).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
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 challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss({
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
currentHp: 100,
|
||||||
|
damagePerSecond: 1,
|
||||||
|
maxHp: 100,
|
||||||
|
})] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
|
||||||
|
expect(body.won).toBe(true);
|
||||||
|
expect(body.rewards.bountyRunestones).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,4 +143,16 @@ describe("craft route", () => {
|
|||||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||||
expect(body.bonusType).toBe("gold_income");
|
expect(body.bonusType).toBe("gold_income");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_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 post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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({});
|
||||||
@@ -406,5 +499,31 @@ describe("explore route", () => {
|
|||||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||||
mockRandom.mockRestore();
|
mockRandom.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /start error path", () => {
|
||||||
|
it("returns 500 when the database throws on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("frontend route", () => {
|
||||||
|
let loggerMock: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
loggerMock = logger as typeof loggerMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeApp = async () => {
|
||||||
|
const { frontendRouter } = await import("../../src/routes/frontend.js");
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/frontend", frontendRouter);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postLog = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const postError = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POST /log", () => {
|
||||||
|
it("returns 200 when level is debug and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "debug", message: "test debug" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is info and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "info", message: "test info" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is warn and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "warn", message: "test warn" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is invalid", async () => {
|
||||||
|
const res = await postLog({ level: "error", message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("level and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is missing", async () => {
|
||||||
|
const res = await postLog({ message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message is missing", async () => {
|
||||||
|
const res = await postLog({ level: "info" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postLog("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postLog({ level: "info", message: "test" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /error", () => {
|
||||||
|
it("returns 200 with valid context and message", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when context field is missing", async () => {
|
||||||
|
const res = await postError({ message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("context and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message field is missing", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postError("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/discord.js", () => ({
|
||||||
|
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
const DISCORD_ID = "test_discord_id";
|
const DISCORD_ID = "test_discord_id";
|
||||||
const CURRENT_SCHEMA_VERSION = 1;
|
const CURRENT_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
@@ -200,6 +204,75 @@ describe("game route", () => {
|
|||||||
expect(body.offlineGold).toBeGreaterThan(0);
|
expect(body.offlineGold).toBeGreaterThan(0);
|
||||||
expect(body.offlineEssence).toBeGreaterThan(0);
|
expect(body.offlineEssence).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("syncs updated avatar from Discord into the returned state", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { state: GameState };
|
||||||
|
expect(body.state.player.avatar).toBe("new_hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading when the avatar DB update fails", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stored avatar when Discord returns null", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { state: GameState };
|
||||||
|
expect(body.state.player.avatar).toBe("stored_hash");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /save", () => {
|
describe("POST /save", () => {
|
||||||
@@ -420,6 +493,45 @@ describe("game route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /load error path", () => {
|
||||||
|
it("returns 500 when the database throws during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /save error path", () => {
|
||||||
|
const save = (body: Record<string, unknown>) =>
|
||||||
|
app.fetch(new Request("http://localhost/game/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /reset", () => {
|
describe("POST /reset", () => {
|
||||||
const reset = () =>
|
const reset = () =>
|
||||||
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
||||||
@@ -450,5 +562,17 @@ describe("game route", () => {
|
|||||||
const body = await res.json() as { signature: string | undefined };
|
const body = await res.json() as { signature: string | undefined };
|
||||||
expect(typeof body.signature).toBe("string");
|
expect(typeof body.signature).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
|
|||||||
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to 0 for game-state categories when state is missing", async () => {
|
it("defaults to 0 for game-state categories when state is missing", async () => {
|
||||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
dailyChallenges: {
|
dailyChallenges: {
|
||||||
@@ -152,5 +164,17 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||||
expect(body.purchasedUpgradeIds).toContain("income_1");
|
expect(body.purchasedUpgradeIds).toContain("income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,6 +182,18 @@ describe("profile route", () => {
|
|||||||
expect(unknown?.name).toBe("unknown_title_id");
|
expect(unknown?.name).toBe("unknown_title_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes completed story chapters in profile response", async () => {
|
it("includes completed story chapters in profile response", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
story: {
|
story: {
|
||||||
@@ -256,5 +268,23 @@ describe("profile route", () => {
|
|||||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||||
expect(body.profileSettings.numberFormat).toBe("suffix");
|
expect(body.profileSettings.numberFormat).toBe("suffix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
|
|||||||
expect(body.newTranscendenceCount).toBe(1);
|
expect(body.newTranscendenceCount).toBe(1);
|
||||||
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /buy-upgrade", () => {
|
describe("POST /buy-upgrade", () => {
|
||||||
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
|
|||||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
||||||
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -86,5 +66,69 @@ describe("discord service", () => {
|
|||||||
expect(result.id).toBe("123");
|
expect(result.id).toBe("123");
|
||||||
expect(result.username).toBe("testuser");
|
expect(result.username).toBe("testuser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||||
|
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exchangeCode non-Error throw", () => {
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchDiscordUserById", () => {
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is missing", async () => {
|
||||||
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is empty", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "";
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when response is not ok", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user on success", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" };
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toMatchObject({ id: "123456", avatar: "abc123" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,14 +13,24 @@ import {
|
|||||||
} from "../../src/services/prestige.js";
|
} from "../../src/services/prestige.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
const makePlayer = (totalGoldEarned: number) => ({
|
const makePlayer = (
|
||||||
discordId: "test_id",
|
totalGoldEarned: number,
|
||||||
username: "testuser",
|
lifetimeGoldEarned = 0,
|
||||||
discriminator: "0",
|
totalClicks = 0,
|
||||||
avatar: null,
|
) => ({
|
||||||
totalGoldEarned,
|
avatar: null,
|
||||||
totalClicks: 0,
|
characterName: "Tester",
|
||||||
characterName: "Tester",
|
discordId: "test_id",
|
||||||
|
discriminator: "0",
|
||||||
|
lifetimeAchievementsUnlocked: 0,
|
||||||
|
lifetimeAdventurersRecruited: 0,
|
||||||
|
lifetimeBossesDefeated: 0,
|
||||||
|
lifetimeClicks: 0,
|
||||||
|
lifetimeGoldEarned: lifetimeGoldEarned,
|
||||||
|
lifetimeQuestsCompleted: 0,
|
||||||
|
totalClicks: totalClicks,
|
||||||
|
totalGoldEarned: totalGoldEarned,
|
||||||
|
username: "testuser",
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||||
@@ -242,4 +252,152 @@ describe("buildPostPrestigeState", () => {
|
|||||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accumulates current-run gold into lifetime total", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
player: makePlayer(4_000_000, 1_000_000),
|
||||||
|
});
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates current-run clicks into lifetime total", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
player: makePlayer(4_000_000, 0, 500),
|
||||||
|
});
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeClicks).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates defeated bosses into lifetime total", () => {
|
||||||
|
const defeatedBoss = {
|
||||||
|
bountyRunestones: 0,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "boss_1",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Boss One",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ defeatedBoss ] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeBossesDefeated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => {
|
||||||
|
const claimedBoss = {
|
||||||
|
bountyRunestones: 5,
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "troll_king",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Troll King",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "verdant_vale",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||||||
|
return boss.id === "troll_king";
|
||||||
|
});
|
||||||
|
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
|
||||||
|
const legacyDefeatedBoss = {
|
||||||
|
bountyRunestones: 5,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "troll_king",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Troll King",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "verdant_vale",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ legacyDefeatedBoss ] as GameState["bosses"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||||||
|
return boss.id === "troll_king";
|
||||||
|
});
|
||||||
|
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates completed quests into lifetime total", () => {
|
||||||
|
const quest = {
|
||||||
|
id: "q_1",
|
||||||
|
name: "A Quest",
|
||||||
|
description: "Do the thing",
|
||||||
|
status: "completed" as const,
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates recruited adventurers into lifetime total", () => {
|
||||||
|
const adventurer = {
|
||||||
|
combatPower: 10,
|
||||||
|
count: 5,
|
||||||
|
essencePerSecond: 0,
|
||||||
|
goldPerSecond: 1,
|
||||||
|
id: "adv_1",
|
||||||
|
level: 1,
|
||||||
|
unlocked: true,
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves achievements from current state across prestige", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_persisted",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.achievements).toEqual([ achievement ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates unlocked achievements into lifetime total", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_1",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,42 +20,20 @@ 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,12 +41,74 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("grantElysianRole", () => {
|
||||||
|
it("does nothing when bot token is missing", async () => {
|
||||||
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user123");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when Discord API responds with ok", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user789");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "PUT",
|
||||||
|
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when Discord API responds with 204", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when Discord API responds with an error status", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and swallows fetch errors gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and swallows non-Error fetch rejections", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("postMilestoneWebhook", () => {
|
describe("postMilestoneWebhook", () => {
|
||||||
@@ -88,9 +128,10 @@ describe("webhook service", () => {
|
|||||||
await postMilestoneWebhook("user123", "prestige", counts);
|
await postMilestoneWebhook("user123", "prestige", counts);
|
||||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
expect(url).toBe("https://discord.com/webhook/abc");
|
expect(url).toBe("https://discord.com/webhook/abc");
|
||||||
const body = JSON.parse(options.body as string) as { content: string };
|
const body = JSON.parse(options.body as string) as { content: string; flags: number };
|
||||||
expect(body.content).toContain("<@user123>");
|
expect(body.content).toContain("<@user123>");
|
||||||
expect(body.content).toContain("prestiged");
|
expect(body.content).toContain("prestiged");
|
||||||
|
expect(body.flags).toBe(4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("posts transcendence message correctly", async () => {
|
it("posts transcendence message correctly", async () => {
|
||||||
@@ -119,5 +160,12 @@ describe("webhook service", () => {
|
|||||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Elysium — Idle RPG</title>
|
<title>Elysium — Idle RPG</title>
|
||||||
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
|
||||||
|
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
<meta property="og:site_name" content="Elysium" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
|
||||||
|
<!-- Plausible Analytics -->
|
||||||
|
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
|
||||||
|
|
||||||
|
<!-- Tree-Nation -->
|
||||||
|
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var interval = setInterval(function () {
|
||||||
|
if (typeof TreeNation !== "undefined") {
|
||||||
|
clearInterval(interval);
|
||||||
|
TreeNation.renderAll();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Google Ads -->
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.1.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"react-markdown": "10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhcarrigan/eslint-config": "5.2.0",
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
|
|||||||
@@ -17,16 +17,19 @@ import type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
LoadResponse,
|
LoadResponse,
|
||||||
PrestigeRequest,
|
PrestigeRequest,
|
||||||
PrestigeResponse,
|
PrestigeResponse,
|
||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
@@ -242,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.
|
||||||
@@ -256,6 +272,34 @@ const craftRecipe = async(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a request to fix any missing unlocks in the player's game state.
|
||||||
|
* @returns The corrected game state and counts of what was unlocked.
|
||||||
|
*/
|
||||||
|
const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
|
||||||
|
return await fetchJson<ForceUnlocksResponse>("/debug/force-unlocks", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs any content added after the player's save was created into their save.
|
||||||
|
* @returns The updated game state and counts of what was added per content type.
|
||||||
|
*/
|
||||||
|
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
|
||||||
|
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
||||||
|
* @returns The fresh game state as a LoadResponse.
|
||||||
|
*/
|
||||||
|
const debugHardReset = async(): Promise<LoadResponse> => {
|
||||||
|
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a public player profile by Discord ID.
|
* Fetches a public player profile by Discord ID.
|
||||||
* @param discordId - The Discord ID of the player to look up.
|
* @param discordId - The Discord ID of the player to look up.
|
||||||
@@ -286,8 +330,12 @@ export {
|
|||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
challengeBoss,
|
challengeBoss,
|
||||||
|
checkExplorationClaimable,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
|
debugHardReset,
|
||||||
|
forceUnlocks,
|
||||||
|
syncNewContent,
|
||||||
getAbout,
|
getAbout,
|
||||||
getAuthUrl,
|
getAuthUrl,
|
||||||
getPublicProfile,
|
getPublicProfile,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @file React Error Boundary for catching unhandled render-time errors.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProperties {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches unhandled render-time errors in the React tree, logs them to the
|
||||||
|
* backend telemetry service, and renders a fallback UI.
|
||||||
|
*/
|
||||||
|
class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProperties,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
// eslint-disable-next-line jsdoc/require-jsdoc -- React Error Boundary constructor is standard boilerplate
|
||||||
|
public constructor(properties: ErrorBoundaryProperties) {
|
||||||
|
super(properties);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates state so the next render shows the fallback UI.
|
||||||
|
* @returns The updated error boundary state.
|
||||||
|
*/
|
||||||
|
public static getDerivedStateFromError(): ErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the error to the backend telemetry service.
|
||||||
|
* @param error - The error that was thrown during render.
|
||||||
|
* @param info - React error info containing the component stack trace.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- React lifecycle method cannot be static
|
||||||
|
public override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
logError("react_error_boundary", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the fallback UI when an error is caught, otherwise renders children.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
public override render(): ReactNode {
|
||||||
|
const { hasError } = this.state;
|
||||||
|
const { children } = this.props;
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="error-screen">
|
||||||
|
<p>{"Something went wrong. Please refresh the page."}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
import { getAbout } from "../../api/client.js";
|
import { getAbout } from "../../api/client.js";
|
||||||
import type { AboutResponse } from "@elysium/types";
|
import type { AboutResponse } from "@elysium/types";
|
||||||
|
|
||||||
@@ -30,14 +31,24 @@ const howToPlay = [
|
|||||||
body:
|
body:
|
||||||
"Purchase upgrades to multiply the gold and essence output of specific"
|
"Purchase upgrades to multiply the gold and essence output of specific"
|
||||||
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
||||||
+ " for the current run and compound with each other.",
|
+ " for the current run and stack multiplicatively — two ×2 upgrades"
|
||||||
|
+ " targeting the same adventurer combine to give ×4, not ×3. Global"
|
||||||
|
+ " upgrades multiply on top of adventurer-specific ones, so stacking"
|
||||||
|
+ " both types compounds the effect significantly. Late in a run, look"
|
||||||
|
+ " for the Essence Infusion upgrades — five powerful global multipliers"
|
||||||
|
+ " purchasable purely with essence, giving that resource an ongoing"
|
||||||
|
+ " use when gold upgrades are all bought.",
|
||||||
title: "🔧 Upgrades",
|
title: "🔧 Upgrades",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Send your guild on quests that complete over time and reward gold,"
|
"Send your guild on quests that complete over time and reward gold,"
|
||||||
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
||||||
+ " simultaneously. Completing quests also unlocks new zones.",
|
+ " simultaneously. Completing quests also unlocks new zones."
|
||||||
|
+ " Each quest has a failure chance that increases in later zones"
|
||||||
|
+ " (from 10% in the starting zone up to 40% in the hardest zones)."
|
||||||
|
+ " If a quest fails, no rewards are granted and the quest resets —"
|
||||||
|
+ " your party must be sent again to retry it.",
|
||||||
title: "📜 Quests",
|
title: "📜 Quests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,10 +69,12 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
||||||
+ " bonuses to gold income, click power, or combat. Rarer equipment"
|
+ " bonuses to gold income, click power, or boss combat DPS. Rarer"
|
||||||
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
|
+ " equipment provides stronger bonuses. Note: combat bonuses only"
|
||||||
+ " named set) to unlock escalating set bonuses shown at the top of the"
|
+ " affect boss fights — quest combat power is determined solely by"
|
||||||
+ " Equipment panel.",
|
+ " your adventurers. Equip matching set pieces (2 or 3 of a named set)"
|
||||||
|
+ " to unlock escalating set bonuses shown at the top of the Equipment"
|
||||||
|
+ " panel.",
|
||||||
title: "🗡️ Equipment & Sets",
|
title: "🗡️ Equipment & Sets",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,7 +123,11 @@ const howToPlay = [
|
|||||||
+ " real-time and reward gold, essence, and crafting materials when"
|
+ " real-time and reward gold, essence, and crafting materials when"
|
||||||
+ " collected. Each area has a set duration — short explorations are"
|
+ " collected. Each area has a set duration — short explorations are"
|
||||||
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
||||||
+ " you've collected from at least once, unlocking a Codex entry.",
|
+ " you've collected from at least once, unlocking a Codex entry."
|
||||||
|
+ " Exploration zones are locked until the corresponding main-game"
|
||||||
|
+ " zone is unlocked — which requires defeating that zone's final boss"
|
||||||
|
+ " and completing its final quest. The Exploration tab shows the"
|
||||||
|
+ " specific boss and quest required for each locked zone.",
|
||||||
title: "🗺️ Exploration",
|
title: "🗺️ Exploration",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,10 +170,12 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
||||||
+ " Each item provides bonuses to gold income, combat power, or click"
|
+ " Each item provides bonuses to gold income, boss combat DPS, or click"
|
||||||
+ " power. Only one item per slot can be equipped at a time — visit the"
|
+ " power. Combat bonuses only affect boss fights — quest combat power"
|
||||||
+ " Equipment panel to manage your loadout. Your currently equipped"
|
+ " is determined solely by your adventurers. Only one item per slot"
|
||||||
+ " items are displayed on your character sheet and public profile.",
|
+ " can be equipped at a time — visit the Equipment panel to manage"
|
||||||
|
+ " your loadout. Your currently equipped items are displayed on your"
|
||||||
|
+ " character sheet and public profile.",
|
||||||
title: "🗡️ Equipment",
|
title: "🗡️ Equipment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -180,14 +199,16 @@ const howToPlay = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
|
"Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
|
||||||
+ " automatically sends your party on the highest-zone available quest"
|
+ " panels! Auto-Quest automatically sends your party on the"
|
||||||
+ " as soon as one completes, skipping quests whose combat power"
|
+ " highest-zone available quest as soon as one completes, skipping"
|
||||||
+ " requirement isn't met. Auto-Boss automatically challenges the"
|
+ " quests whose combat power requirement isn't met. Auto-Boss"
|
||||||
+ " highest available boss as soon as one is ready. Both can be toggled"
|
+ " automatically challenges the highest available boss as soon as one"
|
||||||
+ " on or off at any time using the 🤖 Auto button in each panel"
|
+ " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
|
||||||
+ " header.",
|
+ " runestones) automatically purchases the highest-tier adventurer you"
|
||||||
title: "🤖 Auto-Quest & Auto-Boss",
|
+ " can currently afford each tick, keeping your income growing after a"
|
||||||
|
+ " prestige without any manual clicks.",
|
||||||
|
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
@@ -331,7 +352,9 @@ const aboutPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expandedRelease === release.tag_name
|
{expandedRelease === release.tag_name
|
||||||
&& <pre className="about-release-body">{release.body}</pre>
|
&& <div className="about-release-body">
|
||||||
|
<Markdown>{release.body}</Markdown>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Achievement } from "@elysium/types";
|
import type { Achievement, GameState } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the plural form of a word based on a count.
|
* Returns the plural form of a word based on a count.
|
||||||
@@ -53,9 +54,50 @@ const conditionDescription = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the player's current progress value toward an achievement's unlock condition,
|
||||||
|
* mirroring the logic used by the tick engine's checkAchievements function.
|
||||||
|
* @param achievement - The achievement to evaluate progress for.
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns The current numeric progress toward the achievement condition.
|
||||||
|
*/
|
||||||
|
const getCurrentProgress = (
|
||||||
|
achievement: Achievement,
|
||||||
|
state: GameState,
|
||||||
|
): number => {
|
||||||
|
const { condition } = achievement;
|
||||||
|
switch (condition.type) {
|
||||||
|
case "totalGoldEarned":
|
||||||
|
return state.player.totalGoldEarned;
|
||||||
|
case "totalClicks":
|
||||||
|
return state.player.totalClicks;
|
||||||
|
case "bossesDefeated":
|
||||||
|
return state.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
case "questsCompleted":
|
||||||
|
return state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
case "adventurerTotal":
|
||||||
|
return state.adventurers.reduce((sum, adventurer) => {
|
||||||
|
return sum + adventurer.count;
|
||||||
|
}, 0);
|
||||||
|
case "prestigeCount":
|
||||||
|
return state.prestige.count;
|
||||||
|
case "equipmentOwned":
|
||||||
|
return state.equipment.filter((item) => {
|
||||||
|
return item.owned;
|
||||||
|
}).length;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface AchievementCardProperties {
|
interface AchievementCardProperties {
|
||||||
readonly achievement: Achievement;
|
readonly achievement: Achievement;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly progressValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,26 +105,47 @@ interface AchievementCardProperties {
|
|||||||
* @param props - The achievement card properties.
|
* @param props - The achievement card properties.
|
||||||
* @param props.achievement - The achievement to display.
|
* @param props.achievement - The achievement to display.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.progressValue - The player's current progress toward the unlock condition.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
|
||||||
const AchievementCard = ({
|
const AchievementCard = ({
|
||||||
achievement,
|
achievement,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
progressValue,
|
||||||
}: AchievementCardProperties): JSX.Element => {
|
}: AchievementCardProperties): JSX.Element => {
|
||||||
const isUnlocked = achievement.unlockedAt !== null;
|
const isUnlocked = achievement.unlockedAt !== null;
|
||||||
const crystals = achievement.reward?.crystals;
|
const crystals = achievement.reward?.crystals;
|
||||||
|
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`achievement-card ${isUnlocked
|
<div className={`achievement-card ${isUnlocked
|
||||||
? "unlocked"
|
? "unlocked"
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="achievement-icon">{achievement.icon}</div>
|
<img
|
||||||
|
alt={achievement.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("achievements", achievement.id)}
|
||||||
|
/>
|
||||||
<div className="achievement-info">
|
<div className="achievement-info">
|
||||||
<h3>{achievement.name}</h3>
|
<h3>{achievement.name}</h3>
|
||||||
<p>{achievement.description}</p>
|
<p>{achievement.description}</p>
|
||||||
<p className="achievement-condition">
|
<p className="achievement-condition">
|
||||||
{conditionDescription(achievement, formatNumber)}
|
{conditionDescription(achievement, formatNumber)}
|
||||||
</p>
|
</p>
|
||||||
|
{!isUnlocked
|
||||||
|
&& <div className="achievement-progress">
|
||||||
|
<progress
|
||||||
|
max={achievement.condition.amount}
|
||||||
|
value={cappedProgress}
|
||||||
|
/>
|
||||||
|
<span className="achievement-progress-label">
|
||||||
|
{formatNumber(progressValue)}
|
||||||
|
{" / "}
|
||||||
|
{formatNumber(achievement.condition.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
{crystals !== undefined
|
{crystals !== undefined
|
||||||
&& <p className="achievement-reward">
|
&& <p className="achievement-reward">
|
||||||
{"💎 +"}
|
{"💎 +"}
|
||||||
@@ -158,6 +221,7 @@ const AchievementPanel = (): JSX.Element => {
|
|||||||
achievement={achievement}
|
achievement={achievement}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
key={achievement.id}
|
key={achievement.id}
|
||||||
|
progressValue={getCurrentProgress(achievement, state)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,21 +9,38 @@
|
|||||||
/* eslint-disable complexity -- Complex component with many render paths */
|
/* eslint-disable complexity -- Complex component with many render paths */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Adventurer } from "@elysium/types";
|
import type { Adventurer } from "@elysium/types";
|
||||||
|
|
||||||
const iconByClass: Record<string, string> = {
|
|
||||||
cleric: "✝️",
|
|
||||||
mage: "🔮",
|
|
||||||
paladin: "🛡️",
|
|
||||||
ranger: "🏹",
|
|
||||||
rogue: "🗝️",
|
|
||||||
warrior: "🗡️",
|
|
||||||
};
|
|
||||||
|
|
||||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
|
||||||
|
* @param stored - The raw string from localStorage (or null if absent).
|
||||||
|
* @returns A valid BatchSize value.
|
||||||
|
*/
|
||||||
|
const parseBatchSize = (stored: string | null): BatchSize => {
|
||||||
|
if (stored === "max") {
|
||||||
|
return "max";
|
||||||
|
}
|
||||||
|
const numeric = Number(stored);
|
||||||
|
if (numeric === 5) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
if (numeric === 10) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if (numeric === 25) {
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
if (numeric === 100) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the total cost to buy a batch of adventurers.
|
* Computes the total cost to buy a batch of adventurers.
|
||||||
* @param adventurer - The adventurer to buy.
|
* @param adventurer - The adventurer to buy.
|
||||||
@@ -105,14 +122,15 @@ const AdventurerCard = ({
|
|||||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||||
: "🔒 Locked";
|
: "🔒 Locked";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
|
||||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`adventurer-card ${adventurer.unlocked
|
<div className={`adventurer-card ${adventurer.unlocked
|
||||||
? ""
|
? ""
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
<img
|
||||||
|
alt={adventurer.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("adventurers", adventurer.id)}
|
||||||
|
/>
|
||||||
<div className="adventurer-info">
|
<div className="adventurer-info">
|
||||||
<h3>{adventurer.name}</h3>
|
<h3>{adventurer.name}</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -125,6 +143,10 @@ const AdventurerCard = ({
|
|||||||
{" essence/s each"}
|
{" essence/s each"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
<p>
|
||||||
|
{formatNumber(adventurer.combatPower)}
|
||||||
|
{" combat power each"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="adventurer-count">
|
<div className="adventurer-count">
|
||||||
{"×"}
|
{"×"}
|
||||||
@@ -153,9 +175,11 @@ const AdventurerCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const AdventurerPanel = (): JSX.Element => {
|
const AdventurerPanel = (): JSX.Element => {
|
||||||
const { state, formatNumber } = useGame();
|
const { state, formatNumber, toggleAutoAdventurer } = useGame();
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
|
||||||
|
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
|
||||||
|
});
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -183,6 +207,11 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
|
||||||
|
"auto_adventurer",
|
||||||
|
);
|
||||||
|
const autoAdventurerOn = state.autoAdventurer === true;
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -193,16 +222,40 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
<section className="panel adventurer-panel">
|
<section className="panel adventurer-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>{"Adventurers"}</h2>
|
<h2>{"Adventurers"}</h2>
|
||||||
<LockToggle
|
<div className="panel-header-controls">
|
||||||
lockedCount={locked.length}
|
{autoAdventurerUnlocked
|
||||||
onToggle={handleToggle}
|
? <button
|
||||||
showLocked={showLocked}
|
className={`auto-toggle-btn ${
|
||||||
/>
|
autoAdventurerOn
|
||||||
|
? "auto-toggle-on"
|
||||||
|
: "auto-toggle-off"
|
||||||
|
}`}
|
||||||
|
onClick={toggleAutoAdventurer}
|
||||||
|
title={
|
||||||
|
"Automatically purchase the highest-tier"
|
||||||
|
+ " affordable adventurer"
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"🤖 Auto: "}
|
||||||
|
{autoAdventurerOn
|
||||||
|
? "ON"
|
||||||
|
: "OFF"}
|
||||||
|
</button>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<LockToggle
|
||||||
|
lockedCount={locked.length}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
showLocked={showLocked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-selector">
|
<div className="batch-selector">
|
||||||
{batchOptions.map((option) => {
|
{batchOptions.map((option) => {
|
||||||
function handleBatchSelect(): void {
|
function handleBatchSelect(): void {
|
||||||
setBatchSize(option);
|
setBatchSize(option);
|
||||||
|
localStorage.setItem("elysium_batch_size", String(option));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss, GameState } from "@elysium/types";
|
import type { Boss, GameState } from "@elysium/types";
|
||||||
@@ -56,6 +57,11 @@ const BossCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`boss-card boss-${boss.status}`}>
|
<div className={`boss-card boss-${boss.status}`}>
|
||||||
|
<img
|
||||||
|
alt={boss.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("bosses", boss.id)}
|
||||||
|
/>
|
||||||
<div className="boss-info">
|
<div className="boss-info">
|
||||||
<h3>{boss.name}</h3>
|
<h3>{boss.name}</h3>
|
||||||
<p>{boss.description}</p>
|
<p>{boss.description}</p>
|
||||||
@@ -120,7 +126,9 @@ const BossCard = ({
|
|||||||
{" Equipment"}
|
{" Equipment"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
{boss.status !== "defeated"
|
||||||
|
&& boss.bountyRunestones > 0
|
||||||
|
&& boss.bountyRunestonesClaimed !== true
|
||||||
&& <span className="boss-bounty">
|
&& <span className="boss-bounty">
|
||||||
{"🔮 "}
|
{"🔮 "}
|
||||||
{boss.bountyRunestones}
|
{boss.bountyRunestones}
|
||||||
@@ -220,11 +228,21 @@ const computePartyStats = (
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const BossPanel = (): JSX.Element => {
|
const BossPanel = (): JSX.Element => {
|
||||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
const {
|
||||||
|
state,
|
||||||
|
challengeBoss,
|
||||||
|
formatNumber,
|
||||||
|
toggleAutoBoss,
|
||||||
|
autoBossLastResult,
|
||||||
|
autoBossError,
|
||||||
|
bossError,
|
||||||
|
} = useGame();
|
||||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -249,6 +267,23 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
const zoneBosses = bosses.filter((boss) => {
|
const zoneBosses = bosses.filter((boss) => {
|
||||||
return boss.zoneId === activeZoneId;
|
return boss.zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -302,6 +337,11 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_boss_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -340,12 +380,57 @@ const BossPanel = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ "}
|
||||||
|
{bossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{autoBossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ Auto-boss stopped: "}
|
||||||
|
{autoBossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{autoBossLastResult !== null && autoBossError === null
|
||||||
|
? <p className="auto-boss-status">
|
||||||
|
{"🤖 Last fight: "}
|
||||||
|
{autoBossLastResult.bossName}
|
||||||
|
{autoBossLastResult.won
|
||||||
|
? " — ✅ Won"
|
||||||
|
: " — ❌ Lost"}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="party-combat-stats">
|
<div className="party-combat-stats">
|
||||||
<div className="combat-stat">
|
<div className="combat-stat">
|
||||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PublicProfileResponse,
|
type PublicProfileResponse,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface CharacterPageProperties {
|
interface CharacterPageProperties {
|
||||||
readonly discordId: string;
|
readonly discordId: string;
|
||||||
@@ -78,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
setCopied(true);
|
then(() => {
|
||||||
setTimeout(() => {
|
setCopied(true);
|
||||||
setCopied(false);
|
setTimeout(() => {
|
||||||
}, 2000);
|
setCopied(false);
|
||||||
});
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
@@ -238,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="character-page-equipment-item"
|
className="character-page-equipment-item"
|
||||||
key={item.type}
|
key={item.name}
|
||||||
>
|
>
|
||||||
<div className="character-page-equipment-header">
|
<div className="character-page-equipment-header">
|
||||||
<span className="character-page-equipment-slot">
|
<span className="character-page-equipment-slot">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||||
import { updateProfile } from "../../api/client.js";
|
import { updateProfile } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface EquippedItem {
|
interface EquippedItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -205,12 +206,16 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
function handleShareClick(): void {
|
function handleShareClick(): void {
|
||||||
const discordId = player?.discordId ?? "";
|
const discordId = player?.discordId ?? "";
|
||||||
const url = `${window.location.origin}/character/${discordId}`;
|
const url = `${window.location.origin}/character/${discordId}`;
|
||||||
void navigator.clipboard.writeText(url).then(() => {
|
void navigator.clipboard.writeText(url).
|
||||||
setCopied(true);
|
then(() => {
|
||||||
setTimeout(() => {
|
setCopied(true);
|
||||||
setCopied(false);
|
setTimeout(() => {
|
||||||
}, 2000);
|
setCopied(false);
|
||||||
});
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { CodexEntry } from "@elysium/types";
|
import type { CodexEntry } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
|||||||
zone: "🗺️",
|
zone: "🗺️",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
||||||
|
adventurer: "adventurers",
|
||||||
|
boss: "bosses",
|
||||||
|
equipment: "equipment",
|
||||||
|
exploration: "explorations",
|
||||||
|
prestige: "prestige-upgrades",
|
||||||
|
quest: "quests",
|
||||||
|
recipe: "recipes",
|
||||||
|
upgrade: "upgrades",
|
||||||
|
zone: "zones",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the codex panel with lore entries grouped by zone.
|
* Renders the codex panel with lore entries grouped by zone.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? <p className="codex-entry-content">{entry.content}</p>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={entry.title}
|
||||||
|
className="codex-entry-image"
|
||||||
|
src={cdnImage(
|
||||||
|
sourceTypeFolder[entry.sourceType],
|
||||||
|
entry.sourceId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="codex-entry-content">{entry.content}</p>
|
||||||
|
</>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
const bonusLabels: Record<string, string> = {
|
const bonusLabels: Record<string, string> = {
|
||||||
@@ -96,6 +97,11 @@ const CompanionCard = ({
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
>
|
>
|
||||||
<div className="companion-header">
|
<div className="companion-header">
|
||||||
|
<img
|
||||||
|
alt={companion.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("companions", companion.id)}
|
||||||
|
/>
|
||||||
<div className="companion-name-block">
|
<div className="companion-name-block">
|
||||||
<span className="companion-name">{companion.name}</span>
|
<span className="companion-name">{companion.name}</span>
|
||||||
<span className="companion-title">{companion.title}</span>
|
<span className="companion-title">{companion.title}</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
|
|||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { MATERIALS } from "../../data/materials.js";
|
import { MATERIALS } from "../../data/materials.js";
|
||||||
import { RECIPES } from "../../data/recipes.js";
|
import { RECIPES } from "../../data/recipes.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
|
|
||||||
const bonusLabel: Record<string, string> = {
|
const bonusLabel: Record<string, string> = {
|
||||||
@@ -25,7 +26,9 @@ const bonusLabel: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
const CraftingPanel = (): JSX.Element => {
|
const CraftingPanel = (): JSX.Element => {
|
||||||
const { state, craftRecipe, formatNumber } = useGame();
|
const { state, craftRecipe, formatNumber } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -67,6 +70,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_craft_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCraft(recipeId: string): Promise<void> {
|
async function handleCraft(recipeId: string): Promise<void> {
|
||||||
setPendingRecipeId(recipeId);
|
setPendingRecipeId(recipeId);
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +92,7 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -105,6 +113,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
}`}
|
}`}
|
||||||
key={material.id}
|
key={material.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={material.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("materials", material.id)}
|
||||||
|
/>
|
||||||
<div className="material-info">
|
<div className="material-info">
|
||||||
<span className="material-name">{material.name}</span>
|
<span className="material-name">{material.name}</span>
|
||||||
<span className="material-rarity">{material.rarity}</span>
|
<span className="material-rarity">{material.rarity}</span>
|
||||||
@@ -144,6 +157,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={recipe.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("recipes", recipe.id)}
|
||||||
|
/>
|
||||||
<div className="recipe-info">
|
<div className="recipe-info">
|
||||||
<h4>{recipe.name}</h4>
|
<h4>{recipe.name}</h4>
|
||||||
<p className="recipe-description">{recipe.description}</p>
|
<p className="recipe-description">{recipe.description}</p>
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @file Debug panel component with administrative tools for correcting player state.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Panel has multiple async handlers and conditional renders */
|
||||||
|
/* eslint-disable stylistic/max-len -- Debug descriptions require full explanatory text */
|
||||||
|
import { type JSX, useState } from "react";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||||
|
|
||||||
|
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||||
|
|
||||||
|
interface SyncNewContentResult {
|
||||||
|
achievementsAdded: number | undefined;
|
||||||
|
achievementsPatched: number | undefined;
|
||||||
|
adventurersAdded: number | undefined;
|
||||||
|
adventurerStatsPatched: number | undefined;
|
||||||
|
bossesAdded: number | undefined;
|
||||||
|
bossesPatched: number | undefined;
|
||||||
|
bossRewardsPatched: number | undefined;
|
||||||
|
craftingRecipesReapplied: number | undefined;
|
||||||
|
equipmentAdded: number | undefined;
|
||||||
|
equipmentPatched: number | undefined;
|
||||||
|
explorationAreasAdded: number | undefined;
|
||||||
|
questRewardsPatched: number | undefined;
|
||||||
|
questsAdded: number | undefined;
|
||||||
|
questsPatched: number | undefined;
|
||||||
|
upgradesAdded: number | undefined;
|
||||||
|
upgradesPatched: number | undefined;
|
||||||
|
zonesAdded: number | undefined;
|
||||||
|
zonesPatched: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeNumber = (value: number | undefined): number => {
|
||||||
|
return value ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a human-readable summary of what the sync-new-content operation added.
|
||||||
|
* @param result - The counts returned by the operation.
|
||||||
|
* @returns A message string describing what was added, or a confirmation nothing was needed.
|
||||||
|
*/
|
||||||
|
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||||
|
const entries: Array<[ number, string ]> = [
|
||||||
|
[ safeNumber(result.zonesAdded), "zone(s)" ],
|
||||||
|
[ safeNumber(result.questsAdded), "quest(s)" ],
|
||||||
|
[ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ],
|
||||||
|
[ safeNumber(result.bossesAdded), "boss(es)" ],
|
||||||
|
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
||||||
|
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
||||||
|
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
||||||
|
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
|
||||||
|
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
|
||||||
|
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
|
||||||
|
[ safeNumber(result.achievementsAdded), "achievement(s)" ],
|
||||||
|
[ safeNumber(result.questsPatched), "quest stat(s) patched" ],
|
||||||
|
[ safeNumber(result.bossesPatched), "boss stat(s) patched" ],
|
||||||
|
[ safeNumber(result.zonesPatched), "zone stat(s) patched" ],
|
||||||
|
[ safeNumber(result.upgradesPatched), "upgrade stat(s) patched" ],
|
||||||
|
[ safeNumber(result.equipmentPatched), "equipment stat(s) patched" ],
|
||||||
|
[ safeNumber(result.achievementsPatched), "achievement stat(s) patched" ],
|
||||||
|
[ safeNumber(result.craftingRecipesReapplied), "crafting recipe(s) reapplied" ],
|
||||||
|
];
|
||||||
|
const parts = entries.
|
||||||
|
filter(([ count ]) => {
|
||||||
|
return count > 0;
|
||||||
|
}).
|
||||||
|
map(([ count, label ]) => {
|
||||||
|
return `${String(count)} ${label}`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Your save is already up to date — no new content was found.";
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
|
return sum + count;
|
||||||
|
}, 0);
|
||||||
|
return `Synced ${String(total)} item(s): ${parts.join(", ")}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ForceUnlocksResult {
|
||||||
|
adventurersUnlocked: number | undefined;
|
||||||
|
bossesUnlocked: number | undefined;
|
||||||
|
equipmentUnlocked: number | undefined;
|
||||||
|
explorationUnlocked: number | undefined;
|
||||||
|
questsUnlocked: number | undefined;
|
||||||
|
storyUnlocked: number | undefined;
|
||||||
|
upgradesUnlocked: number | undefined;
|
||||||
|
zonesUnlocked: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a human-readable summary of what the force-unlock operation corrected.
|
||||||
|
* @param result - The counts returned by the force-unlock operation.
|
||||||
|
* @returns A message string describing what was fixed, or a confirmation that nothing needed fixing.
|
||||||
|
*/
|
||||||
|
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
|
||||||
|
const entries: Array<[ number, string ]> = [
|
||||||
|
[ safeNumber(result.zonesUnlocked), "zone(s)" ],
|
||||||
|
[ safeNumber(result.questsUnlocked), "quest(s)" ],
|
||||||
|
[ safeNumber(result.bossesUnlocked), "boss(es)" ],
|
||||||
|
[ safeNumber(result.explorationUnlocked), "exploration area(s)" ],
|
||||||
|
[ safeNumber(result.adventurersUnlocked), "adventurer tier(s)" ],
|
||||||
|
[ safeNumber(result.upgradesUnlocked), "upgrade(s)" ],
|
||||||
|
[ safeNumber(result.equipmentUnlocked), "equipment item(s)" ],
|
||||||
|
[ safeNumber(result.storyUnlocked), "story chapter(s)" ],
|
||||||
|
];
|
||||||
|
const parts = entries.
|
||||||
|
filter(([ count ]) => {
|
||||||
|
return count > 0;
|
||||||
|
}).
|
||||||
|
map(([ count, label ]) => {
|
||||||
|
return `${String(count)} ${label}`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Everything looks correct — no missing unlocks were found.";
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
|
return sum + count;
|
||||||
|
}, 0);
|
||||||
|
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the debug panel with tools for fixing stuck game state.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const DebugPanel = (): JSX.Element => {
|
||||||
|
const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
|
||||||
|
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
||||||
|
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
||||||
|
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleOpenForceUnlocks(): void {
|
||||||
|
setForceUnlocksResult(null);
|
||||||
|
setActiveModal("force-unlocks");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenSyncNewContent(): void {
|
||||||
|
setSyncNewContentResult(null);
|
||||||
|
setActiveModal("sync-new-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenHardReset(): void {
|
||||||
|
setActiveModal("hard-reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmForceUnlocks(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void (async(): Promise<void> => {
|
||||||
|
const result = await forceUnlocks();
|
||||||
|
setForceUnlocksResult(buildForceUnlocksMessage(result));
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmSyncNewContent(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void (async(): Promise<void> => {
|
||||||
|
const result = await syncNewContent();
|
||||||
|
setSyncNewContentResult(buildSyncNewContentMessage(result));
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmHardReset(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void debugHardReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h2>{"🔧 Debug Tools"}</h2>
|
||||||
|
<p className="panel-description">
|
||||||
|
{
|
||||||
|
"These tools are intended to fix broken game state. Use them with care — some operations are irreversible."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="debug-actions">
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"🔓 Force Unlocks"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"Scans your game state and unlocks any zones, quests, and bosses that you have earned but that are still incorrectly locked."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenForceUnlocks}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Force Unlocks"}
|
||||||
|
</button>
|
||||||
|
{forceUnlocksResult !== null
|
||||||
|
&& <p className="debug-result-message">{forceUnlocksResult}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"🔄 Sync New Content"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenSyncNewContent}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Sync New Content"}
|
||||||
|
</button>
|
||||||
|
{syncNewContentResult !== null
|
||||||
|
&& <p className="debug-result-message">{syncNewContentResult}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"💀 Hard Reset"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"Completely wipes all progress and resets your account to a brand-new state. This cannot be undone."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button action-button-danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenHardReset}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Hard Reset"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeModal === "force-unlocks"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Force Unlocks"
|
||||||
|
description="This will scan your save data and grant access to any zones, quests, and bosses that you have already earned but are incorrectly locked. This operation is safe and non-destructive."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmForceUnlocks}
|
||||||
|
title="Force Unlocks"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{activeModal === "sync-new-content"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Sync New Content"
|
||||||
|
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmSyncNewContent}
|
||||||
|
title="Sync New Content"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{activeModal === "hard-reset"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Wipe Everything"
|
||||||
|
description="This will permanently delete all of your current progress — gold, adventurers, upgrades, bosses, quests, and zones — and reset your account to a brand-new state. Lifetime stats are preserved, but everything else will be gone forever."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmHardReset}
|
||||||
|
title="⚠️ Hard Reset — This Cannot Be Undone"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DebugPanel };
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||||
|
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||||
|
|
||||||
@@ -20,12 +22,6 @@ const rarityLabel: Record<string, string> = {
|
|||||||
rare: "Rare",
|
rare: "Rare",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcon: Record<EquipmentType, string> = {
|
|
||||||
armour: "🛡️",
|
|
||||||
trinket: "💍",
|
|
||||||
weapon: "⚔️",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes a human-readable bonus description for a piece of equipment.
|
* Computes a human-readable bonus description for a piece of equipment.
|
||||||
* @param item - The equipment item.
|
* @param item - The equipment item.
|
||||||
@@ -35,7 +31,7 @@ const bonusDescription = (item: Equipment): string => {
|
|||||||
const parts: Array<string> = [];
|
const parts: Array<string> = [];
|
||||||
if (item.bonus.combatMultiplier !== undefined) {
|
if (item.bonus.combatMultiplier !== undefined) {
|
||||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||||
parts.push(`+${String(pct)}% Combat`);
|
parts.push(`+${String(pct)}% Boss Combat`);
|
||||||
}
|
}
|
||||||
if (item.bonus.goldMultiplier !== undefined) {
|
if (item.bonus.goldMultiplier !== undefined) {
|
||||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||||
@@ -128,7 +124,11 @@ const EquipmentCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||||
>
|
>
|
||||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
<img
|
||||||
|
alt={item.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("equipment", item.id)}
|
||||||
|
/>
|
||||||
<div className="equipment-info">
|
<div className="equipment-info">
|
||||||
<div className="equipment-name-row">
|
<div className="equipment-name-row">
|
||||||
<h3>{item.name}</h3>
|
<h3>{item.name}</h3>
|
||||||
@@ -189,6 +189,20 @@ const EquipmentCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a combined power score for sorting — sum of all bonus multipliers.
|
||||||
|
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
|
||||||
|
* @param item - The equipment piece whose bonus multipliers are summed.
|
||||||
|
* @returns The combined bonus value.
|
||||||
|
*/
|
||||||
|
const equipmentPower = (item: Equipment): number => {
|
||||||
|
return (
|
||||||
|
(item.bonus.combatMultiplier ?? 1)
|
||||||
|
+ (item.bonus.goldMultiplier ?? 1)
|
||||||
|
+ (item.bonus.clickMultiplier ?? 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||||
const slotLabel: Record<EquipmentType, string> = {
|
const slotLabel: Record<EquipmentType, string> = {
|
||||||
armour: "🛡️ Armour",
|
armour: "🛡️ Armour",
|
||||||
@@ -262,7 +276,7 @@ const EquipmentPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
if (bonus.combatMultiplier !== undefined) {
|
if (bonus.combatMultiplier !== undefined) {
|
||||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
|
||||||
}
|
}
|
||||||
if (bonus.clickMultiplier !== undefined) {
|
if (bonus.clickMultiplier !== undefined) {
|
||||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||||
@@ -321,6 +335,8 @@ const EquipmentPanel = (): JSX.Element => {
|
|||||||
{slotOrder.map((slotType) => {
|
{slotOrder.map((slotType) => {
|
||||||
const items = equipment.filter((item) => {
|
const items = equipment.filter((item) => {
|
||||||
return item.type === slotType && (showLocked || item.owned);
|
return item.type === slotType && (showLocked || item.owned);
|
||||||
|
}).sort((a, b) => {
|
||||||
|
return equipmentPower(a) - equipmentPower(b);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="equipment-slot-section" key={slotType}>
|
<div className="equipment-slot-section" key={slotType}>
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
*/
|
*/
|
||||||
/* 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 */
|
||||||
import { type JSX, useState } from "react";
|
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||||
|
/* 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 { 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.
|
||||||
@@ -45,11 +52,21 @@ const formatDuration = (seconds: number): string => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the time remaining for an exploration in progress.
|
* Computes the time remaining for an exploration in progress.
|
||||||
|
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
|
||||||
|
* Falls back to startedAt + durationSeconds for saves predating the endsAt field.
|
||||||
|
* @param endsAt - The server-computed completion timestamp, if available.
|
||||||
* @param startedAt - The timestamp when exploration started.
|
* @param startedAt - The timestamp when exploration started.
|
||||||
* @param durationSeconds - The total duration in seconds.
|
* @param durationSeconds - The total duration in seconds.
|
||||||
* @returns The remaining seconds.
|
* @returns The remaining seconds.
|
||||||
*/
|
*/
|
||||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
const timeRemaining = (
|
||||||
|
endsAt: number | undefined,
|
||||||
|
startedAt: number,
|
||||||
|
durationSeconds: number,
|
||||||
|
): number => {
|
||||||
|
if (endsAt !== undefined) {
|
||||||
|
return Math.max(0, (endsAt - Date.now()) / 1000);
|
||||||
|
}
|
||||||
const elapsed = (Date.now() - startedAt) / 1000;
|
const elapsed = (Date.now() - startedAt) / 1000;
|
||||||
return Math.max(0, durationSeconds - elapsed);
|
return Math.max(0, durationSeconds - elapsed);
|
||||||
};
|
};
|
||||||
@@ -66,9 +83,66 @@ interface CollectResult {
|
|||||||
const ExplorationPanel = (): JSX.Element => {
|
const ExplorationPanel = (): JSX.Element => {
|
||||||
const { state, startExploration, collectExploration, formatNumber }
|
const { state, startExploration, collectExploration, formatNumber }
|
||||||
= useGame();
|
= useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
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 (
|
||||||
@@ -78,7 +152,24 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { zones, exploration: explorationState } = state;
|
const { zones, exploration: explorationState, bosses, quests } = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
|
|
||||||
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
||||||
return area.zoneId === activeZoneId;
|
return area.zoneId === activeZoneId;
|
||||||
@@ -103,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);
|
||||||
}
|
}
|
||||||
@@ -115,6 +211,7 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
function handleZoneSelect(id: string): void {
|
function handleZoneSelect(id: string): void {
|
||||||
setActiveZoneId(id);
|
setActiveZoneId(id);
|
||||||
setLastResult(null);
|
setLastResult(null);
|
||||||
|
sessionStorage.setItem("elysium_explore_zone", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
||||||
@@ -206,6 +303,27 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock exploration by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="exploration-list">
|
<div className="exploration-list">
|
||||||
{zoneAreas.map((area) => {
|
{zoneAreas.map((area) => {
|
||||||
const areaState = explorationState?.areas.find((explorationArea) => {
|
const areaState = explorationState?.areas.find((explorationArea) => {
|
||||||
@@ -213,9 +331,10 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
const status = areaState?.status ?? "locked";
|
const status = areaState?.status ?? "locked";
|
||||||
const startedAt = areaState?.startedAt ?? 0;
|
const startedAt = areaState?.startedAt ?? 0;
|
||||||
|
const endsAt = areaState?.endsAt;
|
||||||
const isReady
|
const isReady
|
||||||
= status === "in_progress"
|
= status === "in_progress"
|
||||||
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
&& claimableAreaIds.has(area.id);
|
||||||
const isPending = pendingAreaId === area.id;
|
const isPending = pendingAreaId === area.id;
|
||||||
|
|
||||||
function handleStartClick(): void {
|
function handleStartClick(): void {
|
||||||
@@ -230,6 +349,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
className={`exploration-card exploration-${status}`}
|
className={`exploration-card exploration-${status}`}
|
||||||
key={area.id}
|
key={area.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={area.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("explorations", area.id)}
|
||||||
|
/>
|
||||||
<div className="exploration-info">
|
<div className="exploration-info">
|
||||||
<h3>
|
<h3>
|
||||||
{area.name}
|
{area.name}
|
||||||
@@ -267,9 +391,8 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
{status === "in_progress" && !isReady
|
{status === "in_progress" && !isReady
|
||||||
&& <span className="quest-badge active">
|
&& <span className="quest-badge active">
|
||||||
{"⏳ "}
|
{"⏳ "}
|
||||||
{formatDuration(
|
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
||||||
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
||||||
)}
|
|
||||||
{" remaining"}
|
{" remaining"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import { CodexToast } from "./codexToast.js";
|
|||||||
import { CompanionPanel } from "./companionPanel.js";
|
import { CompanionPanel } from "./companionPanel.js";
|
||||||
import { CraftingPanel } from "./craftingPanel.js";
|
import { CraftingPanel } from "./craftingPanel.js";
|
||||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||||
|
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";
|
||||||
@@ -57,7 +59,8 @@ type Tab =
|
|||||||
| "crafting"
|
| "crafting"
|
||||||
| "character"
|
| "character"
|
||||||
| "companions"
|
| "companions"
|
||||||
| "story";
|
| "story"
|
||||||
|
| "debug";
|
||||||
|
|
||||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||||
@@ -78,6 +81,7 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
|
|||||||
{ id: "story", label: "📖 Story" },
|
{ id: "story", label: "📖 Story" },
|
||||||
{ id: "codex", label: "🗺️ Codex" },
|
{ id: "codex", label: "🗺️ Codex" },
|
||||||
{ id: "about", label: "ℹ️ About" },
|
{ id: "about", label: "ℹ️ About" },
|
||||||
|
{ id: "debug", label: "🔧 Debug" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +136,6 @@ const GameLayout = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileUrl = `/profile/${state.player.discordId}`;
|
|
||||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||||
|
|
||||||
@@ -157,12 +160,12 @@ const GameLayout = (): JSX.Element => {
|
|||||||
onEditProfile={handleOpenEditProfile}
|
onEditProfile={handleOpenEditProfile}
|
||||||
onForceSync={forceSync}
|
onForceSync={forceSync}
|
||||||
prestigeCount={state.prestige.count}
|
prestigeCount={state.prestige.count}
|
||||||
profileUrl={profileUrl}
|
|
||||||
resources={state.resources}
|
resources={state.resources}
|
||||||
runestones={state.prestige.runestones}
|
runestones={state.prestige.runestones}
|
||||||
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}
|
||||||
@@ -189,6 +192,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
<div className="game-main">
|
<div className="game-main">
|
||||||
<aside className="game-sidebar">
|
<aside className="game-sidebar">
|
||||||
<ClickArea />
|
<ClickArea />
|
||||||
|
<div id="tree-nation-offset-website" />
|
||||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -241,6 +245,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
{activeTab === "story" && <StoryPanel />}
|
{activeTab === "story" && <StoryPanel />}
|
||||||
{activeTab === "codex" && <CodexPanel />}
|
{activeTab === "codex" && <CodexPanel />}
|
||||||
{activeTab === "about" && <AboutPanel />}
|
{activeTab === "about" && <AboutPanel />}
|
||||||
|
{activeTab === "debug" && <DebugPanel />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -156,6 +156,9 @@ const LeaderboardPage = (): JSX.Element => {
|
|||||||
<p className="leaderboard-subtitle">
|
<p className="leaderboard-subtitle">
|
||||||
{"The mightiest adventurers in Elysium"}
|
{"The mightiest adventurers in Elysium"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="leaderboard-update-note">
|
||||||
|
{"🔄 Rankings update when you prestige."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="leaderboard-tabs">
|
<div className="leaderboard-tabs">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PRESTIGE_UPGRADES,
|
PRESTIGE_UPGRADES,
|
||||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/prestigeUpgrades.js";
|
} from "../../data/prestigeUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { sendNotification } from "../../utils/notification.js";
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
import { playSound } from "../../utils/sound.js";
|
import { playSound } from "../../utils/sound.js";
|
||||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||||
@@ -88,6 +89,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
enableSounds,
|
enableSounds,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
triggerPrestigeToast,
|
triggerPrestigeToast,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
@@ -109,7 +111,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prestige: prestigeData, player } = state;
|
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||||
const threshold = calculateThreshold(prestigeData.count);
|
const threshold = calculateThreshold(prestigeData.count);
|
||||||
const isEligible = player.totalGoldEarned >= threshold;
|
const isEligible = player.totalGoldEarned >= threshold;
|
||||||
const runestonePreview = calculateRunestonePreview(
|
const runestonePreview = calculateRunestonePreview(
|
||||||
@@ -172,6 +174,10 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
void handlePrestige();
|
void handlePrestige();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAutoAdventurerToggle(): void {
|
||||||
|
toggleAutoAdventurer();
|
||||||
|
}
|
||||||
|
|
||||||
function handleAutoPrestigeToggle(): void {
|
function handleAutoPrestigeToggle(): void {
|
||||||
toggleAutoPrestige();
|
toggleAutoPrestige();
|
||||||
}
|
}
|
||||||
@@ -346,6 +352,9 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
= prestigeData.runestones >= upgrade.runestonesCost;
|
= prestigeData.runestones >= upgrade.runestonesCost;
|
||||||
const isLoading = buyingId === upgrade.id;
|
const isLoading = buyingId === upgrade.id;
|
||||||
|
|
||||||
|
const isAutoAdventurerToggle
|
||||||
|
= upgrade.id === "auto_adventurer" && purchased;
|
||||||
|
const autoAdventurerEnabled = autoAdventurer ?? false;
|
||||||
const isAutoPrestigeToggle
|
const isAutoPrestigeToggle
|
||||||
= upgrade.id === "auto_prestige" && purchased;
|
= upgrade.id === "auto_prestige" && purchased;
|
||||||
const autoPrestigeEnabled
|
const autoPrestigeEnabled
|
||||||
@@ -366,6 +375,11 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("prestige-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
@@ -375,6 +389,21 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isAutoAdventurerToggle
|
||||||
|
? <button
|
||||||
|
className={`auto-prestige-toggle ${
|
||||||
|
autoAdventurerEnabled
|
||||||
|
? "enabled"
|
||||||
|
: "disabled"
|
||||||
|
}`}
|
||||||
|
onClick={handleAutoAdventurerToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{autoAdventurerEnabled
|
||||||
|
? "⚡ Auto ON"
|
||||||
|
: "⏸ Auto OFF"}
|
||||||
|
</button>
|
||||||
|
: null}
|
||||||
{isAutoPrestigeToggle
|
{isAutoPrestigeToggle
|
||||||
? <button
|
? <button
|
||||||
className={`auto-prestige-toggle ${
|
className={`auto-prestige-toggle ${
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||||
import { useEffect, useState, type JSX } from "react";
|
import { useEffect, useState, type JSX } from "react";
|
||||||
import { formatNumber } from "../../utils/format.js";
|
import { formatNumber } from "../../utils/format.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
import type { PublicProfileResponse } from "@elysium/types";
|
import type { PublicProfileResponse } from "@elysium/types";
|
||||||
|
|
||||||
interface ProfilePageProperties {
|
interface ProfilePageProperties {
|
||||||
@@ -52,12 +53,16 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
setCopied(true);
|
then(() => {
|
||||||
setTimeout(() => {
|
setCopied(true);
|
||||||
setCopied(false);
|
setTimeout(() => {
|
||||||
}, 2000);
|
setCopied(false);
|
||||||
});
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
|
||||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||||
/* 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 -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { zoneFailureChance } from "../../engine/tick.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Quest } from "@elysium/types";
|
import type { Quest } from "@elysium/types";
|
||||||
@@ -81,6 +84,11 @@ const QuestCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`quest-card quest-${quest.status}`}>
|
<div className={`quest-card quest-${quest.status}`}>
|
||||||
|
<img
|
||||||
|
alt={quest.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("quests", quest.id)}
|
||||||
|
/>
|
||||||
<div className="quest-info">
|
<div className="quest-info">
|
||||||
<h3>{quest.name}</h3>
|
<h3>{quest.name}</h3>
|
||||||
<p>{quest.description}</p>
|
<p>{quest.description}</p>
|
||||||
@@ -102,9 +110,9 @@ const QuestCard = ({
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<div className="quest-rewards">
|
<div className="quest-rewards">
|
||||||
{quest.rewards.map((reward) => {
|
{quest.rewards.map((reward, rewardIndex) => {
|
||||||
return (
|
return (
|
||||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
||||||
{reward.type === "gold"
|
{reward.type === "gold"
|
||||||
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "essence"
|
{reward.type === "essence"
|
||||||
@@ -137,8 +145,17 @@ const QuestCard = ({
|
|||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
{quest.status === "available"
|
||||||
|
&& <p className="quest-failure-chance">
|
||||||
|
{"🎲 "}
|
||||||
|
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
||||||
|
{"% failure chance"}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
&& <p className="quest-failed-hint">
|
||||||
|
{"⚠️ Last attempt failed — no rewards were granted."}
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
{quest.status === "available"
|
{quest.status === "available"
|
||||||
&& <button
|
&& <button
|
||||||
@@ -178,7 +195,9 @@ const QuestCard = ({
|
|||||||
*/
|
*/
|
||||||
const QuestPanel = (): JSX.Element => {
|
const QuestPanel = (): JSX.Element => {
|
||||||
const { state, toggleAutoQuest } = useGame();
|
const { state, toggleAutoQuest } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -189,7 +208,24 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { adventurers, autoQuest, quests, zones } = state;
|
const { adventurers, autoQuest, bosses, quests, zones } = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
let partyCombatPower = 0;
|
let partyCombatPower = 0;
|
||||||
for (const adventurer of adventurers) {
|
for (const adventurer of adventurers) {
|
||||||
const contribution = adventurer.combatPower * adventurer.count;
|
const contribution = adventurer.combatPower * adventurer.count;
|
||||||
@@ -237,6 +273,11 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_quest_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -279,10 +320,35 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
<p className="quest-failure-note">
|
||||||
|
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="quest-list">
|
<div className="quest-list">
|
||||||
{visibleQuests.map((quest) => {
|
{visibleQuests.map((quest) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { STORY_CHAPTERS } from "@elysium/types";
|
import { STORY_CHAPTERS } from "@elysium/types";
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitutes the character name placeholder in story text.
|
* Substitutes the character name placeholder in story text.
|
||||||
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
|
|||||||
: <div className="story-chapter-view">
|
: <div className="story-chapter-view">
|
||||||
{isUnlocked
|
{isUnlocked
|
||||||
? <>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={activeChapter.title}
|
||||||
|
className="story-chapter-banner"
|
||||||
|
src={cdnImage("story-chapters", activeChapter.id)}
|
||||||
|
/>
|
||||||
<h2 className="story-chapter-title">
|
<h2 className="story-chapter-title">
|
||||||
{"Chapter "}
|
{"Chapter "}
|
||||||
{activeChapterIndex + 1}
|
{activeChapterIndex + 1}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
/* 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 -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||||
|
/* eslint-disable max-lines -- Transcendence panel with CDN images exceeds line limit */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import {
|
import {
|
||||||
TRANSCENDENCE_UPGRADES,
|
TRANSCENDENCE_UPGRADES,
|
||||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/transcendenceUpgrades.js";
|
} from "../../data/transcendenceUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const echoFormulaConstant = 853;
|
const echoFormulaConstant = 853;
|
||||||
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("transcendence-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Upgrade } from "@elysium/types";
|
import type { Adventurer, Upgrade } from "@elysium/types";
|
||||||
|
|
||||||
interface UpgradeCardProperties {
|
interface UpgradeCardProperties {
|
||||||
readonly upgrade: Upgrade;
|
readonly upgrade: Upgrade;
|
||||||
@@ -19,6 +20,7 @@ interface UpgradeCardProperties {
|
|||||||
readonly currentCrystals: number;
|
readonly currentCrystals: number;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly adventurers: ReadonlyArray<Adventurer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,7 @@ interface UpgradeCardProperties {
|
|||||||
* @param props.currentCrystals - The current crystals amount.
|
* @param props.currentCrystals - The current crystals amount.
|
||||||
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.adventurers - The list of adventurers, used to resolve the affected adventurer name.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const UpgradeCard = ({
|
const UpgradeCard = ({
|
||||||
@@ -39,8 +42,14 @@ const UpgradeCard = ({
|
|||||||
currentCrystals,
|
currentCrystals,
|
||||||
unlockHint,
|
unlockHint,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
adventurers,
|
||||||
}: UpgradeCardProperties): JSX.Element => {
|
}: UpgradeCardProperties): JSX.Element => {
|
||||||
const { buyUpgrade } = useGame();
|
const { buyUpgrade } = useGame();
|
||||||
|
const adventurerName = upgrade.adventurerId === undefined
|
||||||
|
? undefined
|
||||||
|
: adventurers.find((adventurer) => {
|
||||||
|
return adventurer.id === upgrade.adventurerId;
|
||||||
|
})?.name;
|
||||||
const canAfford
|
const canAfford
|
||||||
= currentGold >= upgrade.costGold
|
= currentGold >= upgrade.costGold
|
||||||
&& currentEssence >= upgrade.costEssence
|
&& currentEssence >= upgrade.costEssence
|
||||||
@@ -53,11 +62,23 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked && upgrade.purchased) {
|
if (upgrade.unlocked && upgrade.purchased) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card purchased">
|
<div className="upgrade-card purchased">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<span className="upgrade-name">
|
<span className="upgrade-name">
|
||||||
{"✅ "}
|
{"✅ "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="upgrade-desc">{upgrade.description}</span>
|
<span className="upgrade-desc">{upgrade.description}</span>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <span className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,9 +86,21 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked) {
|
if (upgrade.unlocked) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card">
|
<div className="upgrade-card">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>{upgrade.name}</h3>
|
<h3>{upgrade.name}</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p className="upgrade-multiplier">
|
<p className="upgrade-multiplier">
|
||||||
{"×"}
|
{"×"}
|
||||||
{upgrade.multiplier}
|
{upgrade.multiplier}
|
||||||
@@ -108,12 +141,24 @@ const UpgradeCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card locked">
|
<div className="upgrade-card locked">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>
|
<h3>
|
||||||
{"🔒 "}
|
{"🔒 "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p className="upgrade-multiplier">
|
<p className="upgrade-multiplier">
|
||||||
{"×"}
|
{"×"}
|
||||||
{upgrade.multiplier}
|
{upgrade.multiplier}
|
||||||
@@ -165,7 +210,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bosses, quests, upgrades, resources } = state;
|
const { adventurers, bosses, quests, upgrades, resources } = state;
|
||||||
const purchased = upgrades.filter((upgrade) => {
|
const purchased = upgrades.filter((upgrade) => {
|
||||||
return upgrade.purchased;
|
return upgrade.purchased;
|
||||||
});
|
});
|
||||||
@@ -216,6 +261,10 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{upgrades.length}
|
{upgrades.length}
|
||||||
{" purchased"}
|
{" purchased"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="upgrade-stacking-note">
|
||||||
|
{"💡 Upgrade multipliers stack multiplicatively — two ×2 upgrades"
|
||||||
|
+ " combine to give ×4, not ×3."}
|
||||||
|
</p>
|
||||||
{upgrades.length === 0
|
{upgrades.length === 0
|
||||||
? <p className="empty-state">
|
? <p className="empty-state">
|
||||||
{"No upgrades available yet — keep adventuring!"}
|
{"No upgrades available yet — keep adventuring!"}
|
||||||
@@ -224,6 +273,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{available.map((upgrade) => {
|
{available.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
@@ -237,6 +287,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{purchased.map((upgrade) => {
|
{purchased.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
@@ -251,6 +302,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
? locked.map((upgrade) => {
|
? locked.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { Zone } from "@elysium/types";
|
import type { Zone } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
@@ -44,7 +45,11 @@ const ZoneSelector = ({
|
|||||||
title={zone.description}
|
title={zone.description}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="zone-emoji">{zone.emoji}</span>
|
<img
|
||||||
|
alt={zone.name}
|
||||||
|
className="zone-tab-image"
|
||||||
|
src={cdnImage("zones", zone.id)}
|
||||||
|
/>
|
||||||
<span className="zone-name">{zone.name}</span>
|
<span className="zone-name">{zone.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Reusable confirmation modal component for destructive operations.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
|
interface ConfirmationModalProperties {
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly confirmLabel: string;
|
||||||
|
readonly onConfirm: ()=> void;
|
||||||
|
readonly onCancel: ()=> void;
|
||||||
|
readonly isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a confirmation modal for destructive operations.
|
||||||
|
* @param props - The modal properties.
|
||||||
|
* @param props.title - The modal heading.
|
||||||
|
* @param props.description - Warning text explaining what the operation does.
|
||||||
|
* @param props.confirmLabel - Label for the confirm button.
|
||||||
|
* @param props.onConfirm - Callback fired when the player confirms.
|
||||||
|
* @param props.onCancel - Callback fired when the player cancels.
|
||||||
|
* @param props.isLoading - Whether the operation is currently in progress.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const ConfirmationModal = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
isLoading,
|
||||||
|
}: ConfirmationModalProperties): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
<p className="modal-note">{"Are you sure you want to do this?"}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="modal-close-button modal-button-danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Working..."
|
||||||
|
: confirmLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ConfirmationModal };
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- Resource bar has many resource and action elements */
|
||||||
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
||||||
|
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
|
||||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||||
|
import { useState, type FocusEvent, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
|
||||||
import type { Resource } from "@elysium/types";
|
import type { Resource } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
|
||||||
|
|
||||||
interface ResourceBarProperties {
|
interface ResourceBarProperties {
|
||||||
readonly resources: Resource;
|
readonly resources: Resource;
|
||||||
@@ -17,7 +19,6 @@ interface ResourceBarProperties {
|
|||||||
readonly prestigeCount: number;
|
readonly prestigeCount: number;
|
||||||
readonly transcendenceCount: number;
|
readonly transcendenceCount: number;
|
||||||
readonly apotheosisCount: number;
|
readonly apotheosisCount: number;
|
||||||
readonly profileUrl: string;
|
|
||||||
readonly onEditProfile: ()=> void;
|
readonly onEditProfile: ()=> void;
|
||||||
readonly lastSavedAt: number | null;
|
readonly lastSavedAt: number | null;
|
||||||
readonly isSyncing: boolean;
|
readonly isSyncing: boolean;
|
||||||
@@ -58,7 +59,6 @@ const resourceFullTooltip = [
|
|||||||
* @param props.prestigeCount - The number of prestiges completed.
|
* @param props.prestigeCount - The number of prestiges completed.
|
||||||
* @param props.transcendenceCount - The number of transcendences completed.
|
* @param props.transcendenceCount - The number of transcendences completed.
|
||||||
* @param props.apotheosisCount - The number of apotheoses completed.
|
* @param props.apotheosisCount - The number of apotheoses completed.
|
||||||
* @param props.profileUrl - The URL of the player's public profile.
|
|
||||||
* @param props.onEditProfile - Callback to open the edit profile modal.
|
* @param props.onEditProfile - Callback to open the edit profile modal.
|
||||||
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
||||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||||
@@ -71,70 +71,168 @@ const ResourceBar = ({
|
|||||||
prestigeCount,
|
prestigeCount,
|
||||||
transcendenceCount,
|
transcendenceCount,
|
||||||
apotheosisCount,
|
apotheosisCount,
|
||||||
profileUrl,
|
|
||||||
onEditProfile,
|
onEditProfile,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
onForceSync,
|
onForceSync,
|
||||||
}: ResourceBarProperties): JSX.Element => {
|
}: ResourceBarProperties): JSX.Element => {
|
||||||
const { formatNumber, syncError } = useGame();
|
const { formatNumber, syncError, state } = useGame();
|
||||||
|
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||||
|
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||||
|
|
||||||
const { gold, essence, crystals } = resources;
|
const { gold, essence, crystals } = resources;
|
||||||
const resourceValues = [ gold, essence, crystals ];
|
let partyCombatPower = 0;
|
||||||
const anyFull = resourceValues.some((v) => {
|
let goldPerSecond = 0;
|
||||||
return v >= RESOURCE_CAP;
|
if (state !== null) {
|
||||||
});
|
for (const adventurer of state.adventurers) {
|
||||||
|
const contribution = adventurer.combatPower * adventurer.count;
|
||||||
|
partyCombatPower = partyCombatPower + contribution;
|
||||||
|
}
|
||||||
|
goldPerSecond = computeGoldPerSecond(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (state !== null) {
|
||||||
|
avatarUrl = state.player.avatar === null
|
||||||
|
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
|
||||||
|
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
|
||||||
|
}
|
||||||
|
const profileUrl = state === null
|
||||||
|
? "#"
|
||||||
|
: `/profile/${state.player.discordId}`;
|
||||||
|
|
||||||
const goldFull = gold >= RESOURCE_CAP;
|
const goldFull = gold >= RESOURCE_CAP;
|
||||||
const essenceFull = essence >= RESOURCE_CAP;
|
const essenceFull = essence >= RESOURCE_CAP;
|
||||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||||
|
const anyFull = goldFull || essenceFull || crystalsFull;
|
||||||
|
const hiddenResourcesFull = essenceFull || crystalsFull;
|
||||||
|
|
||||||
function handleForceSync(): void {
|
function handleForceSync(): void {
|
||||||
void onForceSync();
|
void onForceSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleResources(): void {
|
||||||
|
setIsResourcesOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResourceBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsResourcesOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleProfile(): void {
|
||||||
|
setIsProfileOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditProfile(): void {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
onEditProfile();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="resource-bar">
|
<header className="resource-bar">
|
||||||
<div className={`resource${goldFull
|
<div
|
||||||
? " resource-full"
|
className="resource-menu"
|
||||||
: ""}`}>
|
onBlur={handleResourceBlur}
|
||||||
<span className="resource-icon">{"🪙"}</span>
|
>
|
||||||
<span className="resource-value">{formatNumber(gold)}</span>
|
<button
|
||||||
<span className="resource-label">{"Gold"}</span>
|
className={`resource resource-toggle${goldFull
|
||||||
{goldFull
|
? " resource-full"
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
: ""}`}
|
||||||
{"FULL"}
|
onClick={handleToggleResources}
|
||||||
</span>
|
title="Click to see all resources"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="resource-icon">{"🪙"}</span>
|
||||||
|
<span className="resource-value">{formatNumber(gold)}</span>
|
||||||
|
<span className="resource-label">{"Gold"}</span>
|
||||||
|
{goldFull
|
||||||
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
|
{"FULL"}
|
||||||
|
</span>
|
||||||
|
: null}
|
||||||
|
{hiddenResourcesFull
|
||||||
|
? <span
|
||||||
|
className="resource-alert-dot"
|
||||||
|
title={"One or more resources are full!"}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
</button>
|
||||||
|
{isResourcesOpen
|
||||||
|
? <div className="resources-dropdown">
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"📈"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(goldPerSecond)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Gold/s"}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`resource${essenceFull
|
||||||
|
? " resource-full"
|
||||||
|
: ""}`}>
|
||||||
|
<span className="resource-icon">{"✨"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(essence)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Essence"}</span>
|
||||||
|
{essenceFull
|
||||||
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
|
{"FULL"}
|
||||||
|
</span>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className={`resource${crystalsFull
|
||||||
|
? " resource-full"
|
||||||
|
: ""}`}>
|
||||||
|
<span className="resource-icon">{"💎"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(crystals)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Crystals"}</span>
|
||||||
|
{crystalsFull
|
||||||
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
|
{"FULL"}
|
||||||
|
</span>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"🔮"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(runestones)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Runestones"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⚔️"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(partyCombatPower)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Combat Power"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className={`resource${essenceFull
|
|
||||||
? " resource-full"
|
|
||||||
: ""}`}>
|
|
||||||
<span className="resource-icon">{"✨"}</span>
|
|
||||||
<span className="resource-value">{formatNumber(essence)}</span>
|
|
||||||
<span className="resource-label">{"Essence"}</span>
|
|
||||||
{essenceFull
|
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
|
||||||
{"FULL"}
|
|
||||||
</span>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<div className={`resource${crystalsFull
|
|
||||||
? " resource-full"
|
|
||||||
: ""}`}>
|
|
||||||
<span className="resource-icon">{"💎"}</span>
|
|
||||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
|
||||||
<span className="resource-label">{"Crystals"}</span>
|
|
||||||
{crystalsFull
|
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
|
||||||
{"FULL"}
|
|
||||||
</span>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<div className="resource">
|
|
||||||
<span className="resource-icon">{"🔮"}</span>
|
|
||||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
|
||||||
<span className="resource-label">{"Runestones"}</span>
|
|
||||||
</div>
|
|
||||||
{apotheosisCount > 0
|
{apotheosisCount > 0
|
||||||
&& <div className="apotheosis-badge">
|
&& <div className="apotheosis-badge">
|
||||||
{"✨ Apotheosis "}
|
{"✨ Apotheosis "}
|
||||||
@@ -153,34 +251,7 @@ const ResourceBar = ({
|
|||||||
{prestigeCount}
|
{prestigeCount}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="profile-buttons">
|
<div className="resource-bar-actions">
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://donate.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Support the developer"
|
|
||||||
>
|
|
||||||
{"💜"} <span className="btn-label">{"Donate"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://chat.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Join our Discord"
|
|
||||||
>
|
|
||||||
{"💬"} <span className="btn-label">{"Discord"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://support.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Get support on our forum"
|
|
||||||
>
|
|
||||||
{"🆘"} <span className="btn-label">{"Support"}</span>
|
|
||||||
</a>
|
|
||||||
{syncError === null
|
{syncError === null
|
||||||
? null
|
? null
|
||||||
: <span className="save-status save-error" title={syncError}>
|
: <span className="save-status save-error" title={syncError}>
|
||||||
@@ -207,23 +278,69 @@ const ResourceBar = ({
|
|||||||
? "⏳"
|
? "⏳"
|
||||||
: "💾"}
|
: "💾"}
|
||||||
</button>
|
</button>
|
||||||
<a
|
{avatarUrl === null
|
||||||
className="profile-link-button"
|
? null
|
||||||
href={profileUrl}
|
: <div
|
||||||
rel="noreferrer"
|
className="profile-menu"
|
||||||
target="_blank"
|
onBlur={handleProfileBlur}
|
||||||
title="View your public profile"
|
>
|
||||||
>
|
<button
|
||||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
className="profile-avatar-button"
|
||||||
</a>
|
onClick={handleToggleProfile}
|
||||||
<button
|
title="Account"
|
||||||
className="profile-edit-button"
|
type="button"
|
||||||
onClick={onEditProfile}
|
>
|
||||||
title="Edit your profile"
|
<img
|
||||||
type="button"
|
alt="Profile"
|
||||||
>
|
className="profile-avatar-img"
|
||||||
{"✏️"}
|
src={avatarUrl}
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
{isProfileOpen
|
||||||
|
? <div className="profile-dropdown">
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href={profileUrl}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"👤 View Profile"}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"✏️ Edit Profile"}
|
||||||
|
</button>
|
||||||
|
<hr className="profile-dropdown-divider" />
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://donate.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💜 Donate"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://chat.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💬 Discord"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://support.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"🆘 Support"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{anyFull
|
{anyFull
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import {
|
|||||||
challengeBoss as challengeBossApi,
|
challengeBoss as challengeBossApi,
|
||||||
collectExploration as collectExplorationApi,
|
collectExploration as collectExplorationApi,
|
||||||
craftRecipe as craftRecipeApi,
|
craftRecipe as craftRecipeApi,
|
||||||
|
debugHardReset as debugHardResetApi,
|
||||||
|
forceUnlocks as forceUnlocksApi,
|
||||||
|
syncNewContent as syncNewContentApi,
|
||||||
loadGame,
|
loadGame,
|
||||||
prestige as prestigeApi,
|
prestige as prestigeApi,
|
||||||
resetProgress as resetProgressApi,
|
resetProgress as resetProgressApi,
|
||||||
@@ -50,7 +53,6 @@ import {
|
|||||||
transcend as transcendApi,
|
transcend as transcendApi,
|
||||||
} from "../api/client.js";
|
} from "../api/client.js";
|
||||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
|
||||||
import { RECIPES } from "../data/recipes.js";
|
import { RECIPES } from "../data/recipes.js";
|
||||||
import {
|
import {
|
||||||
RESOURCE_CAP,
|
RESOURCE_CAP,
|
||||||
@@ -59,6 +61,7 @@ import {
|
|||||||
} from "../engine/tick.js";
|
} from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
import { sendNotification } from "../utils/notification.js";
|
import { sendNotification } from "../utils/notification.js";
|
||||||
import { playSound } from "../utils/sound.js";
|
import { playSound } from "../utils/sound.js";
|
||||||
|
|
||||||
@@ -240,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.
|
||||||
*/
|
*/
|
||||||
@@ -445,6 +453,11 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
toggleAutoBoss: ()=> void;
|
toggleAutoBoss: ()=> void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the auto-adventurer setting on/off (requires auto_adventurer prestige upgrade).
|
||||||
|
*/
|
||||||
|
toggleAutoAdventurer: ()=> void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue of newly unlocked codex entry IDs (for toast notifications).
|
* Queue of newly unlocked codex entry IDs (for toast notifications).
|
||||||
*/
|
*/
|
||||||
@@ -544,6 +557,71 @@ interface GameContextValue {
|
|||||||
* Reset all progress to a fresh save state (resolves schema outdated).
|
* Reset all progress to a fresh save state (resolves schema outdated).
|
||||||
*/
|
*/
|
||||||
resetProgress: ()=> Promise<void>;
|
resetProgress: ()=> Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-unlock any zones, quests, and bosses the player has earned but that
|
||||||
|
* are still incorrectly locked due to a state bug.
|
||||||
|
* @returns Counts of what was corrected.
|
||||||
|
*/
|
||||||
|
forceUnlocks: ()=> Promise<{
|
||||||
|
adventurersUnlocked: number;
|
||||||
|
bossesUnlocked: number;
|
||||||
|
equipmentUnlocked: number;
|
||||||
|
explorationUnlocked: number;
|
||||||
|
questsUnlocked: number;
|
||||||
|
storyUnlocked: number;
|
||||||
|
upgradesUnlocked: number;
|
||||||
|
zonesUnlocked: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completely wipe the player's progress back to a brand-new save via the
|
||||||
|
* debug endpoint.
|
||||||
|
*/
|
||||||
|
debugHardReset: ()=> Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs any content added to the game after the player's save was created.
|
||||||
|
* @returns Counts of what was added per content type.
|
||||||
|
*/
|
||||||
|
syncNewContent: ()=> Promise<{
|
||||||
|
achievementsAdded: number;
|
||||||
|
achievementsPatched: number;
|
||||||
|
adventurerStatsPatched: number;
|
||||||
|
adventurersAdded: number;
|
||||||
|
bossRewardsPatched: number;
|
||||||
|
bossesAdded: number;
|
||||||
|
bossesPatched: number;
|
||||||
|
craftingRecipesReapplied: number;
|
||||||
|
equipmentAdded: number;
|
||||||
|
equipmentPatched: number;
|
||||||
|
explorationAreasAdded: number;
|
||||||
|
questRewardsPatched: number;
|
||||||
|
questsAdded: number;
|
||||||
|
questsPatched: number;
|
||||||
|
upgradesAdded: number;
|
||||||
|
upgradesPatched: number;
|
||||||
|
zonesAdded: number;
|
||||||
|
zonesPatched: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last auto-boss fight result — null until the first auto fight completes or
|
||||||
|
* when auto-boss is toggled off.
|
||||||
|
*/
|
||||||
|
autoBossLastResult: { bossName: string; won: boolean; at: number } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message set when auto-boss stopped due to a critical failure (null
|
||||||
|
* when no error). Cleared automatically when the player re-enables auto-boss.
|
||||||
|
*/
|
||||||
|
autoBossError: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message from the most recent manual boss challenge (null when no
|
||||||
|
* error). Cleared automatically when a new challenge is initiated.
|
||||||
|
*/
|
||||||
|
bossError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleResult {
|
export interface BattleResult {
|
||||||
@@ -587,6 +665,13 @@ export const GameProvider = ({
|
|||||||
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
|
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
|
||||||
const [ isSyncing, setIsSyncing ] = useState(false);
|
const [ isSyncing, setIsSyncing ] = useState(false);
|
||||||
const [ syncError, setSyncError ] = useState<string | null>(null);
|
const [ syncError, setSyncError ] = useState<string | null>(null);
|
||||||
|
const [ autoBossLastResult, setAutoBossLastResult ] = useState<{
|
||||||
|
bossName: string;
|
||||||
|
won: boolean;
|
||||||
|
at: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
|
||||||
|
const [ bossError, setBossError ] = useState<string | null>(null);
|
||||||
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
|
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -614,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>
|
||||||
>([]);
|
>([]);
|
||||||
@@ -651,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}`).
|
||||||
@@ -1028,6 +1115,38 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-adventurer: buy one of the highest-tier affordable unlocked adventurer per tick
|
||||||
|
if (
|
||||||
|
next.autoAdventurer === true
|
||||||
|
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
|
||||||
|
) {
|
||||||
|
const [ bestAdventurer ] = next.adventurers.
|
||||||
|
filter((adventurer) => {
|
||||||
|
const cost
|
||||||
|
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
|
||||||
|
return adventurer.unlocked && next.resources.gold >= cost;
|
||||||
|
}).
|
||||||
|
sort((adventurerA, adventurerB) => {
|
||||||
|
return adventurerB.combatPower - adventurerA.combatPower;
|
||||||
|
});
|
||||||
|
if (bestAdventurer !== undefined) {
|
||||||
|
const purchaseCost
|
||||||
|
= bestAdventurer.baseCost * Math.pow(1.15, bestAdventurer.count);
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
adventurers: next.adventurers.map((adventurer) => {
|
||||||
|
return adventurer.id === bestAdventurer.id
|
||||||
|
? { ...adventurer, count: adventurer.count + 1 }
|
||||||
|
: adventurer;
|
||||||
|
}),
|
||||||
|
resources: {
|
||||||
|
...next.resources,
|
||||||
|
gold: next.resources.gold - purchaseCost,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect newly unlocked achievements
|
// Detect newly unlocked achievements
|
||||||
unlockedAchievementsReference.current = next.achievements.filter(
|
unlockedAchievementsReference.current = next.achievements.filter(
|
||||||
(a, index) => {
|
(a, index) => {
|
||||||
@@ -1131,6 +1250,11 @@ export const GameProvider = ({
|
|||||||
signatureReference.current = null;
|
signatureReference.current = null;
|
||||||
localStorage.removeItem("elysium_save_signature");
|
localStorage.removeItem("elysium_save_signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Network failures during background auto-save are expected on
|
||||||
|
* flaky connections — the next tick will retry, so no telemetry needed
|
||||||
|
*/
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,7 +1284,7 @@ export const GameProvider = ({
|
|||||||
}).
|
}).
|
||||||
catch(() => {
|
catch(() => {
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/* Silently ignore — eligibility is re-checked every tick */
|
||||||
}).
|
}).
|
||||||
finally(() => {
|
finally(() => {
|
||||||
isAutoPrestigingReference.current = false;
|
isAutoPrestigingReference.current = false;
|
||||||
@@ -1190,19 +1314,66 @@ export const GameProvider = ({
|
|||||||
if (availableBoss !== undefined) {
|
if (availableBoss !== undefined) {
|
||||||
const { id: bossId, name: bossName } = availableBoss;
|
const { id: bossId, name: bossName } = availableBoss;
|
||||||
isAutoBossingReference.current = true;
|
isAutoBossingReference.current = true;
|
||||||
void challengeBossApi({ bossId }).
|
const syncBeforeBoss
|
||||||
|
= stateReference.current !== null && !isSyncingReference.current
|
||||||
|
? saveGame({
|
||||||
|
state: stateReference.current,
|
||||||
|
...signatureReference.current === null
|
||||||
|
? {}
|
||||||
|
: { signature: signatureReference.current },
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.signature !== undefined) {
|
||||||
|
signatureReference.current = response.signature;
|
||||||
|
localStorage.setItem(
|
||||||
|
"elysium_save_signature",
|
||||||
|
response.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: Promise.resolve();
|
||||||
|
void syncBeforeBoss.then(async() => {
|
||||||
|
return await challengeBossApi({ bossId });
|
||||||
|
}).
|
||||||
then((result) => {
|
then((result) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
return applyBossResult(previous, bossId, result);
|
const afterBoss = applyBossResult(previous, bossId, result);
|
||||||
|
// Defeat — turn off auto-boss so the player can reassess
|
||||||
|
if (!result.won) {
|
||||||
|
return { ...afterBoss, autoBoss: false };
|
||||||
|
}
|
||||||
|
return afterBoss;
|
||||||
|
});
|
||||||
|
setAutoBossLastResult({
|
||||||
|
at: Date.now(),
|
||||||
|
bossName: bossName,
|
||||||
|
won: result.won,
|
||||||
});
|
});
|
||||||
setBattleResult({ bossName, result });
|
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch((error_: unknown) => {
|
||||||
|
const message
|
||||||
|
= error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: String(error_);
|
||||||
|
|
||||||
/* Silently ignore — will retry next tick */
|
/*
|
||||||
|
* "Boss is not currently available" is an expected race condition
|
||||||
|
* when the client is ahead of the server save — silently skip and
|
||||||
|
* let the next tick retry rather than halting automation.
|
||||||
|
*/
|
||||||
|
if (message === "Boss is not currently available") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logError("auto_boss", error_);
|
||||||
|
setAutoBossError(message);
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return { ...previous, autoBoss: false };
|
||||||
|
});
|
||||||
}).
|
}).
|
||||||
finally(() => {
|
finally(() => {
|
||||||
isAutoBossingReference.current = false;
|
isAutoBossingReference.current = false;
|
||||||
@@ -1521,35 +1692,46 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("buy_prestige_upgrade", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const transcend = useCallback(async() => {
|
const transcend = useCallback(async() => {
|
||||||
const result = await transcendApi({});
|
try {
|
||||||
setShowTranscendenceToast(true);
|
const result = await transcendApi({});
|
||||||
if (enableSoundsReference.current) {
|
setShowTranscendenceToast(true);
|
||||||
playSound("transcendence");
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("transcendence");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("🌌 Transcendence!", "You have transcended reality!");
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("transcend", error_);
|
||||||
|
throw error_;
|
||||||
}
|
}
|
||||||
if (enableNotificationsReference.current) {
|
|
||||||
sendNotification("🌌 Transcendence!", "You have transcended reality!");
|
|
||||||
}
|
|
||||||
await reload();
|
|
||||||
return result;
|
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const apotheosis = useCallback(async() => {
|
const apotheosis = useCallback(async() => {
|
||||||
const result = await achieveApotheosisApi({});
|
try {
|
||||||
setShowApotheosisToast(true);
|
const result = await achieveApotheosisApi({});
|
||||||
if (enableSoundsReference.current) {
|
setShowApotheosisToast(true);
|
||||||
playSound("apotheosis");
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("apotheosis");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
return result;
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
logError("apotheosis", error_);
|
||||||
|
throw error_;
|
||||||
}
|
}
|
||||||
if (enableNotificationsReference.current) {
|
|
||||||
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
|
|
||||||
}
|
|
||||||
await reload();
|
|
||||||
return result;
|
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
|
||||||
@@ -1575,21 +1757,14 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
// Silently ignore server errors
|
logError("buy_echo_upgrade", error_);
|
||||||
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startExploration = useCallback(async(areaId: string) => {
|
const startExploration = useCallback(async(areaId: string) => {
|
||||||
const response = await startExplorationApi({ areaId });
|
const response = await startExplorationApi({ areaId });
|
||||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
|
||||||
return a.id === areaId;
|
|
||||||
});
|
|
||||||
if (areaData === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
|
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -1600,7 +1775,11 @@ export const GameProvider = ({
|
|||||||
...previous.exploration,
|
...previous.exploration,
|
||||||
areas: previous.exploration.areas.map((a) => {
|
areas: previous.exploration.areas.map((a) => {
|
||||||
return a.id === areaId
|
return a.id === areaId
|
||||||
? { ...a, startedAt: startedAt, status: "in_progress" as const }
|
? {
|
||||||
|
...a,
|
||||||
|
endsAt: response.endsAt,
|
||||||
|
status: "in_progress" as const,
|
||||||
|
}
|
||||||
: a;
|
: a;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -1668,13 +1847,13 @@ export const GameProvider = ({
|
|||||||
player: {
|
player: {
|
||||||
...previous.player,
|
...previous.player,
|
||||||
totalGoldEarned:
|
totalGoldEarned:
|
||||||
previous.player.totalGoldEarned
|
previous.player.totalGoldEarned
|
||||||
+ Math.max(0, result.event?.goldChange ?? 0),
|
+ Math.max(0, result.event?.goldChange ?? 0),
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
...previous.resources,
|
...previous.resources,
|
||||||
essence:
|
essence:
|
||||||
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
previous.resources.essence + (result.event?.essenceChange ?? 0),
|
||||||
gold: Math.max(
|
gold: Math.max(
|
||||||
0,
|
0,
|
||||||
previous.resources.gold + (result.event?.goldChange ?? 0),
|
previous.resources.gold + (result.event?.goldChange ?? 0),
|
||||||
@@ -1694,35 +1873,32 @@ export const GameProvider = ({
|
|||||||
if (recipe === undefined) {
|
if (recipe === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await craftRecipeApi({ recipeId });
|
try {
|
||||||
setState((previous) => {
|
const result = await craftRecipeApi({ recipeId });
|
||||||
if (previous?.exploration === undefined) {
|
setState((previous) => {
|
||||||
return previous;
|
if (previous?.exploration === undefined) {
|
||||||
}
|
return previous;
|
||||||
let materials = [ ...previous.exploration.materials ];
|
}
|
||||||
for (const request of recipe.requiredMaterials) {
|
return {
|
||||||
materials = materials.map((mat) => {
|
...previous,
|
||||||
return mat.materialId === request.materialId
|
exploration: {
|
||||||
? { ...mat, quantity: mat.quantity - request.quantity }
|
...previous.exploration,
|
||||||
: mat;
|
craftedClickMultiplier: result.craftedClickMultiplier,
|
||||||
});
|
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
||||||
}
|
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
|
||||||
return {
|
craftedGoldMultiplier: result.craftedGoldMultiplier,
|
||||||
...previous,
|
craftedRecipeIds: [
|
||||||
exploration: {
|
...previous.exploration.craftedRecipeIds,
|
||||||
...previous.exploration,
|
recipeId,
|
||||||
craftedClickMultiplier: result.craftedClickMultiplier,
|
],
|
||||||
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
materials: result.materials,
|
||||||
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
|
},
|
||||||
craftedGoldMultiplier: result.craftedGoldMultiplier,
|
};
|
||||||
craftedRecipeIds: [
|
});
|
||||||
...previous.exploration.craftedRecipeIds,
|
} catch (error_: unknown) {
|
||||||
recipeId,
|
logError("craft_recipe", error_);
|
||||||
],
|
throw error_;
|
||||||
materials: materials,
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleAutoPrestige = useCallback(() => {
|
const toggleAutoPrestige = useCallback(() => {
|
||||||
@@ -1750,6 +1926,8 @@ export const GameProvider = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleAutoBoss = useCallback(() => {
|
const toggleAutoBoss = useCallback(() => {
|
||||||
|
setAutoBossError(null);
|
||||||
|
setAutoBossLastResult(null);
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -1758,6 +1936,18 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleAutoAdventurer = useCallback(() => {
|
||||||
|
setState((previous) => {
|
||||||
|
if (previous === null) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
autoAdventurer: previous.autoAdventurer !== true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setActiveCompanion = useCallback((companionId: string | null) => {
|
const setActiveCompanion = useCallback((companionId: string | null) => {
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous === null) {
|
if (previous === null) {
|
||||||
@@ -1789,6 +1979,14 @@ export const GameProvider = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBossError(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Flush any pending state (e.g. newly equipped items) to the server before
|
||||||
|
* the fight so the server-side calculation uses the player's live stats.
|
||||||
|
*/
|
||||||
|
await forceSync();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await challengeBossApi({ bossId });
|
const result = await challengeBossApi({ bossId });
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
@@ -1798,10 +1996,24 @@ export const GameProvider = ({
|
|||||||
return applyBossResult(previous, bossId, result);
|
return applyBossResult(previous, bossId, result);
|
||||||
});
|
});
|
||||||
setBattleResult({ bossName: boss.name, result: result });
|
setBattleResult({ bossName: boss.name, result: result });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
const bossErrorMessage
|
||||||
|
= error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to challenge boss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Boss is not currently available" is an expected server rejection
|
||||||
|
* (race condition between UI state and server state) — suppress telemetry
|
||||||
|
*/
|
||||||
|
if (bossErrorMessage !== "Boss is not currently available") {
|
||||||
|
logError("challenge_boss", error_);
|
||||||
|
}
|
||||||
|
setBossError(
|
||||||
|
bossErrorMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ forceSync ]);
|
||||||
|
|
||||||
const dismissOfflineGold = useCallback(() => {
|
const dismissOfflineGold = useCallback(() => {
|
||||||
setOfflineGold(0);
|
setOfflineGold(0);
|
||||||
@@ -1927,6 +2139,126 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const forceUnlocks = useCallback(async() => {
|
||||||
|
try {
|
||||||
|
const data = await forceUnlocksApi();
|
||||||
|
setState(data.state);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adventurersUnlocked: data.adventurersUnlocked,
|
||||||
|
bossesUnlocked: data.bossesUnlocked,
|
||||||
|
equipmentUnlocked: data.equipmentUnlocked,
|
||||||
|
explorationUnlocked: data.explorationUnlocked,
|
||||||
|
questsUnlocked: data.questsUnlocked,
|
||||||
|
storyUnlocked: data.storyUnlocked,
|
||||||
|
upgradesUnlocked: data.upgradesUnlocked,
|
||||||
|
zonesUnlocked: data.zonesUnlocked,
|
||||||
|
};
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
setError(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to force unlocks",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
adventurersUnlocked: 0,
|
||||||
|
bossesUnlocked: 0,
|
||||||
|
equipmentUnlocked: 0,
|
||||||
|
explorationUnlocked: 0,
|
||||||
|
questsUnlocked: 0,
|
||||||
|
storyUnlocked: 0,
|
||||||
|
upgradesUnlocked: 0,
|
||||||
|
zonesUnlocked: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncNewContent = useCallback(async() => {
|
||||||
|
try {
|
||||||
|
const data = await syncNewContentApi();
|
||||||
|
setState(data.state);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
achievementsAdded: data.achievementsAdded,
|
||||||
|
achievementsPatched: data.achievementsPatched,
|
||||||
|
adventurerStatsPatched: data.adventurerStatsPatched,
|
||||||
|
adventurersAdded: data.adventurersAdded,
|
||||||
|
bossRewardsPatched: data.bossRewardsPatched,
|
||||||
|
bossesAdded: data.bossesAdded,
|
||||||
|
bossesPatched: data.bossesPatched,
|
||||||
|
craftingRecipesReapplied: data.craftingRecipesReapplied,
|
||||||
|
equipmentAdded: data.equipmentAdded,
|
||||||
|
equipmentPatched: data.equipmentPatched,
|
||||||
|
explorationAreasAdded: data.explorationAreasAdded,
|
||||||
|
questRewardsPatched: data.questRewardsPatched,
|
||||||
|
questsAdded: data.questsAdded,
|
||||||
|
questsPatched: data.questsPatched,
|
||||||
|
upgradesAdded: data.upgradesAdded,
|
||||||
|
upgradesPatched: data.upgradesPatched,
|
||||||
|
zonesAdded: data.zonesAdded,
|
||||||
|
zonesPatched: data.zonesPatched,
|
||||||
|
};
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
setError(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to sync new content",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
achievementsAdded: 0,
|
||||||
|
achievementsPatched: 0,
|
||||||
|
adventurerStatsPatched: 0,
|
||||||
|
adventurersAdded: 0,
|
||||||
|
bossRewardsPatched: 0,
|
||||||
|
bossesAdded: 0,
|
||||||
|
bossesPatched: 0,
|
||||||
|
craftingRecipesReapplied: 0,
|
||||||
|
equipmentAdded: 0,
|
||||||
|
equipmentPatched: 0,
|
||||||
|
explorationAreasAdded: 0,
|
||||||
|
questRewardsPatched: 0,
|
||||||
|
questsAdded: 0,
|
||||||
|
questsPatched: 0,
|
||||||
|
upgradesAdded: 0,
|
||||||
|
upgradesPatched: 0,
|
||||||
|
zonesAdded: 0,
|
||||||
|
zonesPatched: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debugHardReset = useCallback(async() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await debugHardResetApi();
|
||||||
|
setState(data.state);
|
||||||
|
setLastSavedAt(data.state.player.lastSavedAt);
|
||||||
|
setSchemaOutdated(false);
|
||||||
|
setOfflineGold(0);
|
||||||
|
setOfflineEssence(0);
|
||||||
|
setLoginBonus(null);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
setError(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to reset progress",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dismissLoginBonus = useCallback(() => {
|
const dismissLoginBonus = useCallback(() => {
|
||||||
setLoginBonus(null);
|
setLoginBonus(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1941,7 +2273,10 @@ export const GameProvider = ({
|
|||||||
const contextValue = useMemo<GameContextValue>(() => {
|
const contextValue = useMemo<GameContextValue>(() => {
|
||||||
return {
|
return {
|
||||||
apotheosis,
|
apotheosis,
|
||||||
|
autoBossError,
|
||||||
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
|
bossError,
|
||||||
buyAdventurer,
|
buyAdventurer,
|
||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyEquipment,
|
buyEquipment,
|
||||||
@@ -1953,6 +2288,7 @@ export const GameProvider = ({
|
|||||||
completedQuestToasts,
|
completedQuestToasts,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
debugHardReset,
|
||||||
dismissAchievement,
|
dismissAchievement,
|
||||||
dismissApotheosisToast,
|
dismissApotheosisToast,
|
||||||
dismissBattle,
|
dismissBattle,
|
||||||
@@ -1971,8 +2307,10 @@ export const GameProvider = ({
|
|||||||
failedQuestToasts,
|
failedQuestToasts,
|
||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
forceSync,
|
forceSync,
|
||||||
|
forceUnlocks,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
handleClick,
|
handleClick,
|
||||||
|
inGuild,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
@@ -1996,6 +2334,8 @@ export const GameProvider = ({
|
|||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
syncNewContent,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
@@ -2007,7 +2347,10 @@ export const GameProvider = ({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
apotheosis,
|
apotheosis,
|
||||||
|
autoBossError,
|
||||||
|
autoBossLastResult,
|
||||||
battleResult,
|
battleResult,
|
||||||
|
bossError,
|
||||||
completedQuestToasts,
|
completedQuestToasts,
|
||||||
failedQuestToasts,
|
failedQuestToasts,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -2021,6 +2364,7 @@ export const GameProvider = ({
|
|||||||
completeChapter,
|
completeChapter,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
debugHardReset,
|
||||||
dismissAchievement,
|
dismissAchievement,
|
||||||
dismissApotheosisToast,
|
dismissApotheosisToast,
|
||||||
dismissBattle,
|
dismissBattle,
|
||||||
@@ -2038,6 +2382,8 @@ export const GameProvider = ({
|
|||||||
error,
|
error,
|
||||||
flushBossLoreToasts,
|
flushBossLoreToasts,
|
||||||
forceSync,
|
forceSync,
|
||||||
|
inGuild,
|
||||||
|
forceUnlocks,
|
||||||
handleClick,
|
handleClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
@@ -2062,6 +2408,8 @@ export const GameProvider = ({
|
|||||||
startQuest,
|
startQuest,
|
||||||
state,
|
state,
|
||||||
syncError,
|
syncError,
|
||||||
|
syncNewContent,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoBoss,
|
toggleAutoBoss,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
toggleAutoQuest,
|
toggleAutoQuest,
|
||||||
|
|||||||
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
|
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
|
||||||
id: "upgrade_mage_1",
|
id: "upgrade_apprentice_1",
|
||||||
sourceId: "mage_1",
|
sourceId: "apprentice_1",
|
||||||
sourceType: "upgrade",
|
sourceType: "upgrade",
|
||||||
title: "Arcane Tomes: The Written Knowledge",
|
title: "Arcane Tomes: The Written Knowledge",
|
||||||
zoneId: "guild_library",
|
zoneId: "guild_library",
|
||||||
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
|
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
|
||||||
id: "upgrade_cleric_1",
|
id: "upgrade_acolyte_1",
|
||||||
sourceId: "cleric_1",
|
sourceId: "acolyte_1",
|
||||||
sourceType: "upgrade",
|
sourceType: "upgrade",
|
||||||
title: "Holy Rites: The Sacred Routine",
|
title: "Holy Rites: The Sacred Routine",
|
||||||
zoneId: "guild_library",
|
zoneId: "guild_library",
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
|
|||||||
runestonesCost: 1200,
|
runestonesCost: 1200,
|
||||||
},
|
},
|
||||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
category: "utility",
|
||||||
|
description:
|
||||||
|
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
|
||||||
|
id: "auto_adventurer",
|
||||||
|
multiplier: 1,
|
||||||
|
name: "Autonomous Recruitment",
|
||||||
|
runestonesCost: 50,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "utility",
|
category: "utility",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const RESOURCE_CAP = 1e300;
|
|||||||
* On failure the quest resets to "available" with no rewards; the player must wait the
|
* On failure the quest resets to "available" with no rewards; the player must wait the
|
||||||
* full duration again on their next attempt.
|
* full duration again on their next attempt.
|
||||||
*/
|
*/
|
||||||
const zoneFailureChance: Record<string, number> = {
|
export const zoneFailureChance: Record<string, number> = {
|
||||||
abyssal_trench: 0.24,
|
abyssal_trench: 0.24,
|
||||||
astral_void: 0.2,
|
astral_void: 0.2,
|
||||||
celestial_reaches: 0.22,
|
celestial_reaches: 0.22,
|
||||||
@@ -123,6 +123,78 @@ const capResource = (value: number): number => {
|
|||||||
return Math.min(value, RESOURCE_CAP);
|
return Math.min(value, RESOURCE_CAP);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function — applies one game tick to the state.
|
||||||
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
|
* Returns a new GameState (does not mutate the original).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
||||||
|
* @returns A new GameState with the tick applied.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Computes the effective gold earned per second across all adventurers,
|
||||||
|
* including all active multipliers (upgrades, prestige, equipment, etc.).
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns Gold per second as a number.
|
||||||
|
*/
|
||||||
|
export const computeGoldPerSecond = (state: GameState): number => {
|
||||||
|
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
||||||
|
return item.equipped;
|
||||||
|
});
|
||||||
|
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||||
|
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||||
|
}, 1);
|
||||||
|
const setGoldMultiplier = computeSetBonuses(
|
||||||
|
equippedItems.map((item) => {
|
||||||
|
return item.id;
|
||||||
|
}),
|
||||||
|
EQUIPMENT_SETS,
|
||||||
|
).goldMultiplier;
|
||||||
|
|
||||||
|
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||||
|
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||||
|
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||||
|
const companionBonus = getActiveCompanionBonus(
|
||||||
|
state.companions?.activeCompanionId,
|
||||||
|
state.companions?.unlockedCompanionIds ?? [],
|
||||||
|
);
|
||||||
|
const companionGoldMult
|
||||||
|
= companionBonus?.type === "passiveGold"
|
||||||
|
? 1 + companionBonus.value
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
let goldPerSecond = 0;
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const upgradeMultiplier = state.upgrades.
|
||||||
|
filter((upgrade) => {
|
||||||
|
const isGlobal = upgrade.target === "global";
|
||||||
|
const isThisAdventurer
|
||||||
|
= upgrade.target === "adventurer"
|
||||||
|
&& upgrade.adventurerId === adventurer.id;
|
||||||
|
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||||
|
}).
|
||||||
|
reduce((mult, upgrade) => {
|
||||||
|
return mult * upgrade.multiplier;
|
||||||
|
}, 1);
|
||||||
|
const contribution
|
||||||
|
= adventurer.goldPerSecond
|
||||||
|
* adventurer.count
|
||||||
|
* upgradeMultiplier
|
||||||
|
* state.prestige.productionMultiplier
|
||||||
|
* runestonesIncome
|
||||||
|
* echoIncome
|
||||||
|
* equipmentGoldMultiplier
|
||||||
|
* setGoldMultiplier
|
||||||
|
* craftedGoldMultiplier
|
||||||
|
* companionGoldMult;
|
||||||
|
goldPerSecond = goldPerSecond + contribution;
|
||||||
|
}
|
||||||
|
return goldPerSecond;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function — applies one game tick to the state.
|
* Pure function — applies one game tick to the state.
|
||||||
* DeltaSeconds: time elapsed since last tick.
|
* DeltaSeconds: time elapsed since last tick.
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./app.js";
|
import { App } from "./app.js";
|
||||||
|
import { ErrorBoundary } from "./components/errorBoundary.js";
|
||||||
|
import { initialiseFrontendLogger } from "./utils/logger.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
initialiseFrontendLogger();
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
@@ -18,6 +22,8 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
+332
-48
@@ -26,6 +26,7 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--font: "Segoe UI", system-ui, sans-serif;
|
--font: "Segoe UI", system-ui, sans-serif;
|
||||||
|
--resource-bar-height: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -33,6 +34,20 @@ body {
|
|||||||
color: var(--colour-text);
|
color: var(--colour-text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-image: url("https://cdn.nhcarrigan.com/elysium/background.jpg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.15;
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== RESOURCE BAR ===================== */
|
/* ===================== RESOURCE BAR ===================== */
|
||||||
@@ -101,6 +116,66 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Resource toggle + dropdown ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.resource-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-toggle {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-toggle:hover {
|
||||||
|
background: rgba(147, 51, 234, 0.2);
|
||||||
|
border-color: var(--colour-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-alert-dot {
|
||||||
|
background: var(--colour-warning, #f59e0b);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 0.45rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-dropdown {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-dropdown .resource {
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-dropdown .resource:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== GAME LAYOUT ===================== */
|
/* ===================== GAME LAYOUT ===================== */
|
||||||
.game-layout {
|
.game-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -122,6 +197,10 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: var(--resource-bar-height);
|
||||||
|
height: calc(100vh - var(--resource-bar-height));
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-content {
|
.game-content {
|
||||||
@@ -1473,57 +1552,87 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */
|
/* ── Resource bar actions (save + profile menu) ─────────────────────────── */
|
||||||
|
|
||||||
.profile-buttons {
|
.resource-bar-actions {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-link-button {
|
.profile-menu {
|
||||||
align-items: center;
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(147, 51, 234, 0.4);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: var(--colour-text-muted);
|
|
||||||
display: flex;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-link-button:hover {
|
.profile-avatar-button {
|
||||||
background: rgba(147, 51, 234, 0.2);
|
background: none;
|
||||||
border-color: var(--colour-primary);
|
border: 2px solid rgba(147, 51, 234, 0.4);
|
||||||
color: var(--colour-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-edit-button {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(147, 51, 234, 0.4);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: var(--colour-text-muted);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
display: flex;
|
||||||
font-size: 0.85rem;
|
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
line-height: 1;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: all 0.2s;
|
transition: border-color 0.2s;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-edit-button:hover {
|
.profile-avatar-button:hover {
|
||||||
background: rgba(147, 51, 234, 0.2);
|
|
||||||
border-color: var(--colour-primary);
|
border-color: var(--colour-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-img {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dropdown {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dropdown-item {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dropdown-item:hover {
|
||||||
|
background: rgba(147, 51, 234, 0.15);
|
||||||
color: var(--colour-text);
|
color: var(--colour-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-dropdown-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(147, 51, 234, 0.2);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.save-status {
|
.save-status {
|
||||||
color: var(--colour-text-muted);
|
color: var(--colour-text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -2056,8 +2165,11 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-emoji {
|
.zone-tab-image {
|
||||||
font-size: 1.4rem;
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-name {
|
.zone-name {
|
||||||
@@ -2285,9 +2397,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-release-body {
|
.about-release-body {
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--colour-text-secondary, #b0b0b0);
|
color: var(--colour-text-secondary, #b0b0b0);
|
||||||
padding: 0 1rem 0.75rem;
|
padding: 0 1rem 0.75rem;
|
||||||
@@ -2295,6 +2404,81 @@ body {
|
|||||||
border-top: 1px solid var(--colour-border, #0f3460);
|
border-top: 1px solid var(--colour-border, #0f3460);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-release-body p {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body p:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body ul,
|
||||||
|
.about-release-body ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body li {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body h1,
|
||||||
|
.about-release-body h2,
|
||||||
|
.about-release-body h3,
|
||||||
|
.about-release-body h4 {
|
||||||
|
color: var(--colour-accent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body h1:first-child,
|
||||||
|
.about-release-body h2:first-child,
|
||||||
|
.about-release-body h3:first-child,
|
||||||
|
.about-release-body h4:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body code {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body pre {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body a {
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-release-body strong {
|
||||||
|
color: var(--colour-text);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.about-how-to-play {
|
.about-how-to-play {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3073,10 +3257,10 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Profile buttons fill their own row, aligned right */
|
/* Resource bar actions fill their own row, aligned right */
|
||||||
.profile-buttons {
|
.resource-bar-actions {
|
||||||
margin-left: 0;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3092,8 +3276,11 @@ body {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
height: auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3143,15 +3330,6 @@ body {
|
|||||||
|
|
||||||
/* --- Small mobile (≤ 480px) --------------------------- */
|
/* --- Small mobile (≤ 480px) --------------------------- */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* Icon-only profile link buttons to save horizontal space */
|
|
||||||
.btn-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-link-button {
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly smaller tab buttons */
|
/* Slightly smaller tab buttons */
|
||||||
.tab-button {
|
.tab-button {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -4393,3 +4571,109 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== CDN ASSET IMAGES ===================== */
|
||||||
|
.card-thumbnail {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-chapter-banner {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
height: 220px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry-image {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== ACTION BUTTONS ===================== */
|
||||||
|
.action-button {
|
||||||
|
background: var(--colour-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.55rem 1.25rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover:not(:disabled) {
|
||||||
|
background: var(--colour-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-danger {
|
||||||
|
background: var(--colour-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-danger:hover:not(:disabled) {
|
||||||
|
background: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== MODAL VARIANTS ===================== */
|
||||||
|
.modal-button-danger {
|
||||||
|
background: var(--colour-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-danger:hover:not(:disabled) {
|
||||||
|
background: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== DEBUG PANEL ===================== */
|
||||||
|
.debug-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-action-card {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-action-card h3 {
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-action-card > p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-result-message {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid var(--colour-success);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--colour-success);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @file CDN URL utility for Elysium game assets.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cdnBase = "https://cdn.nhcarrigan.com/elysium";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CDN URL for a game asset image.
|
||||||
|
* @param folder - The asset category folder (e.g. "bosses", "companions").
|
||||||
|
* @param id - The asset identifier (file name without extension).
|
||||||
|
* @returns The full CDN URL for the asset.
|
||||||
|
*/
|
||||||
|
const cdnImage = (folder: string, id: string): string => {
|
||||||
|
return `${cdnBase}/${folder}/${id}.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { cdnImage };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend error logging utility that forwards errors to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an error to the backend telemetry service.
|
||||||
|
* Accepts the same arguments as console.error — conventionally a context string
|
||||||
|
* followed by the error value.
|
||||||
|
* @param logArguments - The values to log, forwarded directly to console.error.
|
||||||
|
*/
|
||||||
|
const logError = (...logArguments: Array<unknown>): void => {
|
||||||
|
console.error(...logArguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { logError };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logger that forwards console output to the backend telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable no-console -- This file intentionally overrides console methods */
|
||||||
|
|
||||||
|
type Level = "debug" | "info" | "warn";
|
||||||
|
|
||||||
|
const post = (path: string, body: object): void => {
|
||||||
|
void fetch(path, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header names use kebab-case
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
}).catch(() => {
|
||||||
|
// Intentionally swallowed — we cannot log logger failures without infinite recursion.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the global console.log and console.error methods so that all
|
||||||
|
* frontend log output is forwarded to the backend telemetry endpoints.
|
||||||
|
* Must be called once at application startup before any other code runs.
|
||||||
|
*/
|
||||||
|
const initialiseFrontendLogger = (): void => {
|
||||||
|
const originalLog = console.log.bind(console);
|
||||||
|
const originalError = console.error.bind(console);
|
||||||
|
|
||||||
|
console.log = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "info";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalError(...consoleArguments);
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
if (argument instanceof Error) {
|
||||||
|
return `${argument.message}\n${argument.stack ?? ""}`;
|
||||||
|
}
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
const context = "console.error";
|
||||||
|
post("/api/fe/error", { context, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...consoleArguments: Array<unknown>): void => {
|
||||||
|
originalLog(...consoleArguments);
|
||||||
|
const level: Level = "warn";
|
||||||
|
const message = consoleArguments.map((argument) => {
|
||||||
|
return typeof argument === "string"
|
||||||
|
? argument
|
||||||
|
: JSON.stringify(argument);
|
||||||
|
}).join(" ");
|
||||||
|
post("/api/fe/log", { level, message });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { initialiseFrontendLogger };
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests browser notification permission from the user.
|
* Requests browser notification permission from the user.
|
||||||
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
||||||
new Notification(title, { body: body, icon: "/favicon.ico" });
|
new Notification(title, { body: body, icon: "/favicon.ico" });
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("send_notification", error_);
|
||||||
// Silently ignore — notifications may fail silently
|
// Silently ignore — notifications may fail silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { logError } from "./logError.js";
|
||||||
|
|
||||||
type SoundEvent =
|
type SoundEvent =
|
||||||
| "achievement"
|
| "achievement"
|
||||||
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
|
|||||||
oscillator.start(startTime);
|
oscillator.start(startTime);
|
||||||
oscillator.stop(endTime);
|
oscillator.stop(endTime);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error_: unknown) {
|
||||||
|
logError("play_sound", error_);
|
||||||
// Silently ignore — audio may not be available in all environments
|
// Silently ignore — audio may not be available in all environments
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.1.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhcarrigan/typescript-config": "4.0.0",
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.1.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -55,11 +55,13 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
GiteaRelease,
|
GiteaRelease,
|
||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
@@ -71,6 +73,7 @@ export type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
|||||||
@@ -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,163 @@ interface CraftRecipeResponse {
|
|||||||
craftedEssenceMultiplier: number;
|
craftedEssenceMultiplier: number;
|
||||||
craftedClickMultiplier: number;
|
craftedClickMultiplier: number;
|
||||||
craftedCombatMultiplier: number;
|
craftedCombatMultiplier: number;
|
||||||
|
materials: Array<{ materialId: string; quantity: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForceUnlocksResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corrected game state after applying all missing unlocks.
|
||||||
|
*/
|
||||||
|
state: GameState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of zones that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
zonesUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of quests that were made available by this operation.
|
||||||
|
*/
|
||||||
|
questsUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of bosses that were made available by this operation.
|
||||||
|
*/
|
||||||
|
bossesUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of exploration areas that were made available by this operation.
|
||||||
|
*/
|
||||||
|
explorationUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of adventurer tiers that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
adventurersUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrades that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
upgradesUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of equipment items that were marked as owned by this operation.
|
||||||
|
*/
|
||||||
|
equipmentUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of story chapters that were unlocked by this operation.
|
||||||
|
*/
|
||||||
|
storyUnlocked: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncNewContentResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated game state after injecting all missing content entries.
|
||||||
|
*/
|
||||||
|
state: GameState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of adventurer tiers added to the save.
|
||||||
|
*/
|
||||||
|
adventurersAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||||
|
*/
|
||||||
|
adventurerStatsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrades added to the save.
|
||||||
|
*/
|
||||||
|
upgradesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of rewards patched onto existing quests.
|
||||||
|
*/
|
||||||
|
questRewardsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of quests added to the save.
|
||||||
|
*/
|
||||||
|
questsAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of bosses added to the save.
|
||||||
|
*/
|
||||||
|
bossesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrade reward IDs patched onto existing bosses.
|
||||||
|
*/
|
||||||
|
bossRewardsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of equipment items added to the save.
|
||||||
|
*/
|
||||||
|
equipmentAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of achievements added to the save.
|
||||||
|
*/
|
||||||
|
achievementsAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of zones added to the save.
|
||||||
|
*/
|
||||||
|
zonesAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of exploration areas added to the save.
|
||||||
|
*/
|
||||||
|
explorationAreasAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of achievements whose stats were updated to match current defaults.
|
||||||
|
*/
|
||||||
|
achievementsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of bosses whose stats were updated to match current defaults.
|
||||||
|
*/
|
||||||
|
bossesPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of crafted recipes whose multiplier contribution was reapplied during recompute.
|
||||||
|
*/
|
||||||
|
craftingRecipesReapplied: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of equipment items whose stats were updated to match current defaults.
|
||||||
|
*/
|
||||||
|
equipmentPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of quests whose stats were updated to match current defaults.
|
||||||
|
*/
|
||||||
|
questsPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of upgrades whose stats were updated to match current defaults.
|
||||||
|
*/
|
||||||
|
upgradesPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of zones whose stats were updated to match current defaults.
|
||||||
|
*/
|
||||||
|
zonesPatched: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -412,11 +579,13 @@ export type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
GiteaRelease,
|
GiteaRelease,
|
||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
@@ -428,6 +597,7 @@ export type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ interface Boss {
|
|||||||
* One-time runestone bounty awarded on first-ever defeat.
|
* One-time runestone bounty awarded on first-ever defeat.
|
||||||
*/
|
*/
|
||||||
bountyRunestones: number;
|
bountyRunestones: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the first-kill runestone bounty has already been claimed.
|
||||||
|
* Set to true on first defeat and preserved across all prestiges so the
|
||||||
|
* bounty is never re-awarded in subsequent runs.
|
||||||
|
*/
|
||||||
|
bountyRunestonesClaimed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Boss, BossStatus };
|
export type { Boss, BossStatus };
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ interface ExplorationAreaState {
|
|||||||
*/
|
*/
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix timestamp when the exploration will complete (server-computed, used for
|
||||||
|
* accurate client-side countdown that is immune to client/server clock drift).
|
||||||
|
*/
|
||||||
|
endsAt?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True after the first successful collect — used for codex unlock detection.
|
* True after the first successful collect — used for codex unlock detection.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ interface GameState {
|
|||||||
*/
|
*/
|
||||||
autoBoss?: boolean;
|
autoBoss?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, the tick engine automatically purchases the highest-tier affordable adventurer.
|
||||||
|
*/
|
||||||
|
autoAdventurer?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Companion unlock and active selection state — optional for backwards compatibility.
|
* Companion unlock and active selection state — optional for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+713
-20
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user