generated from nhcarrigan/template
Compare commits
57 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 | |||
|
b604a4aa5c
|
|||
|
e10eabc8b5
|
|||
|
c3d79e0c11
|
|||
|
6e2cb45553
|
|||
|
5a065998b6
|
|||
|
f9c925b9fc
|
|||
|
290c06de83
|
|||
|
205b4136ce
|
|||
| 29c817230d |
+14
-3
@@ -18,6 +18,11 @@ jobs:
|
||||
|
||||
- name: Check dependency pins
|
||||
uses: naomi-lgbt/dependency-pin-check@main
|
||||
with:
|
||||
dev-dependencies: true
|
||||
peer-dependencies: true
|
||||
optional-dependencies: true
|
||||
language: javascript
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -32,6 +37,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: pnpm --filter @elysium/api exec prisma generate
|
||||
|
||||
- name: Build (types package)
|
||||
run: pnpm --filter @elysium/types build
|
||||
|
||||
- name: Lint (types package)
|
||||
run: pnpm --filter @elysium/types lint
|
||||
|
||||
@@ -41,15 +52,15 @@ jobs:
|
||||
- name: Lint (web)
|
||||
run: pnpm --filter @elysium/web lint
|
||||
|
||||
- name: Build (types package)
|
||||
run: pnpm --filter @elysium/types build
|
||||
|
||||
- name: Build (API)
|
||||
run: pnpm --filter @elysium/api build
|
||||
|
||||
- name: Build (web)
|
||||
run: pnpm --filter @elysium/web build
|
||||
|
||||
- name: Test (types package)
|
||||
run: pnpm --filter @elysium/types test
|
||||
|
||||
- name: Test (API)
|
||||
run: pnpm --filter @elysium/api test
|
||||
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# Elysium Project Notes
|
||||
|
||||
## CI Requirements
|
||||
|
||||
**Never commit without first confirming the full pipeline passes locally:**
|
||||
1. `pnpm lint` — zero errors, zero warnings
|
||||
2. `pnpm build` — all packages build cleanly
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Elysium — Content Ideas
|
||||
|
||||
A running list of planned features and content additions. Strike through items as they're completed!
|
||||
|
||||
---
|
||||
|
||||
## 🌟 New Systems
|
||||
|
||||
- [x] **Offline earnings** — When returning to the game, earn a percentage of what you'd have earned offline (cap at ~8–12 hours). Upgradeable via the prestige shop to increase the % and the time cap. Essential for an idle game!
|
||||
|
||||
- [ ] **Second prestige layer (Transcendence)** — Unlocked after ~10 prestiges. Sacrifice all runestones for a new currency ("Echoes"?). Echoes are permanent account-wide currency that persist across prestiges. Has its own upgrade tree with truly game-changing bonuses. Gives endgame players a long-term goal.
|
||||
|
||||
- [x] **Daily challenges** — Three rotating objectives each day (e.g. kill X boss, earn X gold this run, complete X quests). Reward bonus crystals. Encourages daily logins even when idling comfortably.
|
||||
|
||||
- [x] **Boss first-kill bounties** — Defeating a boss for the very first time grants a one-time runestone bonus. Rewards exploration and makes conquering a new zone feel extra satisfying.
|
||||
|
||||
- [x] **Auto-prestige toggle** — Unlockable via the prestige shop. Automatically prestiges the moment the threshold is reached. Late-game convenience that dedicated players will love.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Content Additions
|
||||
|
||||
- [x] **Equipment set bonuses** — Group existing equipment into named sets (e.g. "Shadow Infiltrator"). Wearing 2/3/4 pieces of a set grants escalating bonuses. Adds strategic depth without requiring lots of new items.
|
||||
|
||||
- [ ] **The Codex / Lore Book** — Defeating bosses and completing quests unlocks lore entries about the world. Pure flavour, but gives the world depth and a collection mechanic. Show a ✨ notification when new lore unlocks.
|
||||
|
||||
- [x] **Milestone prestige bonuses** — Every 5th prestige, earn a free prestige upgrade or a large runestone windfall. Gives players mini-goals within the prestige loop.
|
||||
|
||||
---
|
||||
|
||||
## 📊 UI / Statistics
|
||||
|
||||
- [x] **Statistics panel** — All-time totals: gold earned across all runs, total prestiges, bosses defeated, quests completed, time played. Idle game players love seeing big numbers about their big numbers.
|
||||
|
||||
- [x] **Last cloud save date + Force cloud save button** — Display when the last cloud save occurred (always visible, e.g. in the ResourceBar). Include a manual "Force Save" button for peace of mind.
|
||||
|
||||
---
|
||||
|
||||
## 💜 Priority Order (Suggested)
|
||||
|
||||
1. ~~Offline earnings~~ ✅
|
||||
2. ~~Statistics panel~~ ✅
|
||||
3. ~~Daily challenges~~ ✅
|
||||
4. ~~Boss first-kill bounties~~ ✅
|
||||
5. ~~Milestone prestige bonuses~~ ✅
|
||||
6. ~~Equipment set bonuses~~ ✅
|
||||
7. ~~Auto-prestige toggle~~ ✅
|
||||
8. The Codex / Lore Book (flavour, lower priority)
|
||||
9. Second prestige layer / Transcendence (big feature, save for later)
|
||||
@@ -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,3 +1,3 @@
|
||||
import { NaomisConfig } from "@nhcarrigan/eslint-config";
|
||||
import config from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...NaomisConfig];
|
||||
export default [...config];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/api",
|
||||
"version": "0.0.0",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"@hono/node-server": "1.13.7",
|
||||
"@nhcarrigan/logger": "1.1.1",
|
||||
"@prisma/client": "6.5.0",
|
||||
"hono": "4.7.4",
|
||||
"prisma": "6.5.0"
|
||||
|
||||
@@ -13,13 +13,29 @@ model Player {
|
||||
username String
|
||||
discriminator String
|
||||
avatar String?
|
||||
characterName String @default("")
|
||||
bio String @default("")
|
||||
profileSettings Json?
|
||||
characterName String @default("")
|
||||
pronouns String @default("")
|
||||
characterRace String @default("")
|
||||
characterClass String @default("")
|
||||
bio String @default("")
|
||||
guildName String @default("")
|
||||
guildDescription String @default("")
|
||||
profileSettings Json?
|
||||
unlockedTitles Json?
|
||||
activeTitle String @default("")
|
||||
createdAt Float
|
||||
lastSavedAt Float
|
||||
totalGoldEarned Float @default(0)
|
||||
totalClicks Float @default(0)
|
||||
totalGoldEarned Float @default(0)
|
||||
totalClicks Float @default(0)
|
||||
lifetimeGoldEarned Float @default(0)
|
||||
lifetimeClicks Float @default(0)
|
||||
lifetimeBossesDefeated Float @default(0)
|
||||
lifetimeQuestsCompleted Float @default(0)
|
||||
lifetimeAdventurersRecruited Float @default(0)
|
||||
lifetimeAchievementsUnlocked Float @default(0)
|
||||
lastLoginDate String?
|
||||
loginStreak Int @default(1)
|
||||
inGuild Boolean @default(false)
|
||||
}
|
||||
|
||||
model GameState {
|
||||
|
||||
+5
-2
@@ -1,6 +1,9 @@
|
||||
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
|
||||
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
||||
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
|
||||
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
||||
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
||||
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
||||
PORT="op://Environment Variables - Naomi/Elysium/port"
|
||||
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||
+240
-233
@@ -1,359 +1,366 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
|
||||
export const defaultAchievements: Array<Achievement> = [
|
||||
// Click milestones
|
||||
{
|
||||
id: "first_click",
|
||||
name: "First Strike",
|
||||
condition: { amount: 1, type: "totalClicks" },
|
||||
description: "Click the Guild Hall for the first time.",
|
||||
icon: "👆",
|
||||
condition: { type: "totalClicks", amount: 1 },
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
icon: "👆",
|
||||
id: "first_click",
|
||||
name: "First Strike",
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_enthusiast",
|
||||
name: "Click Enthusiast",
|
||||
condition: { amount: 100, type: "totalClicks" },
|
||||
description: "Click the Guild Hall 100 times.",
|
||||
icon: "🖱️",
|
||||
condition: { type: "totalClicks", amount: 100 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
icon: "🖱️",
|
||||
id: "click_enthusiast",
|
||||
name: "Click Enthusiast",
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_master",
|
||||
name: "Click Master",
|
||||
condition: { amount: 1000, type: "totalClicks" },
|
||||
description: "Click the Guild Hall 1,000 times.",
|
||||
icon: "⚡",
|
||||
condition: { type: "totalClicks", amount: 1_000 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
icon: "⚡",
|
||||
id: "click_master",
|
||||
name: "Click Master",
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_legend",
|
||||
name: "Click Legend",
|
||||
condition: { amount: 10_000, type: "totalClicks" },
|
||||
description: "Click the Guild Hall 10,000 times.",
|
||||
icon: "🌩️",
|
||||
condition: { type: "totalClicks", amount: 10_000 },
|
||||
reward: { crystals: 300 },
|
||||
unlockedAt: null,
|
||||
icon: "🌩️",
|
||||
id: "click_legend",
|
||||
name: "Click Legend",
|
||||
reward: { crystals: 300 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Gold milestones
|
||||
{
|
||||
id: "first_gold",
|
||||
name: "First Gold",
|
||||
condition: { amount: 100, type: "totalGoldEarned" },
|
||||
description: "Earn your first 100 gold.",
|
||||
icon: "🪙",
|
||||
condition: { type: "totalGoldEarned", amount: 100 },
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
icon: "🪙",
|
||||
id: "first_gold",
|
||||
name: "First Gold",
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "wealthy",
|
||||
name: "Wealthy",
|
||||
condition: { amount: 10_000, type: "totalGoldEarned" },
|
||||
description: "Earn 10,000 gold in total.",
|
||||
icon: "💰",
|
||||
condition: { type: "totalGoldEarned", amount: 10_000 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
icon: "💰",
|
||||
id: "wealthy",
|
||||
name: "Wealthy",
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "rich",
|
||||
name: "Rich",
|
||||
condition: { amount: 1_000_000, type: "totalGoldEarned" },
|
||||
description: "Earn 1,000,000 gold in total.",
|
||||
icon: "👑",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
icon: "👑",
|
||||
id: "rich",
|
||||
name: "Rich",
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "billionaire",
|
||||
name: "Billionaire",
|
||||
condition: { amount: 1_000_000_000, type: "totalGoldEarned" },
|
||||
description: "Earn 1,000,000,000 gold in total.",
|
||||
icon: "🏦",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
|
||||
reward: { crystals: 500 },
|
||||
unlockedAt: null,
|
||||
icon: "🏦",
|
||||
id: "billionaire",
|
||||
name: "Billionaire",
|
||||
reward: { crystals: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "trillionaire",
|
||||
name: "Trillionaire",
|
||||
condition: { amount: 1_000_000_000_000, type: "totalGoldEarned" },
|
||||
description: "Earn 1,000,000,000,000 gold in total.",
|
||||
icon: "💎",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000_000_000 },
|
||||
reward: { crystals: 2_000 },
|
||||
unlockedAt: null,
|
||||
icon: "💎",
|
||||
id: "trillionaire",
|
||||
name: "Trillionaire",
|
||||
reward: { crystals: 2000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Quest milestones
|
||||
{
|
||||
id: "first_quest",
|
||||
name: "Adventurous Spirit",
|
||||
condition: { amount: 1, type: "questsCompleted" },
|
||||
description: "Complete your first quest.",
|
||||
icon: "📜",
|
||||
condition: { type: "questsCompleted", amount: 1 },
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
icon: "📜",
|
||||
id: "first_quest",
|
||||
name: "Adventurous Spirit",
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_veteran",
|
||||
name: "Quest Veteran",
|
||||
condition: { amount: 5, type: "questsCompleted" },
|
||||
description: "Complete 5 quests.",
|
||||
icon: "📚",
|
||||
condition: { type: "questsCompleted", amount: 5 },
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
icon: "📚",
|
||||
id: "quest_veteran",
|
||||
name: "Quest Veteran",
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_master",
|
||||
name: "Quest Master",
|
||||
condition: { amount: 15, type: "questsCompleted" },
|
||||
description: "Complete 15 quests.",
|
||||
icon: "🗺️",
|
||||
condition: { type: "questsCompleted", amount: 15 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
icon: "🗺️",
|
||||
id: "quest_master",
|
||||
name: "Quest Master",
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Boss milestones
|
||||
{
|
||||
id: "boss_slayer",
|
||||
name: "Boss Slayer",
|
||||
condition: { amount: 1, type: "bossesDefeated" },
|
||||
description: "Defeat your first boss.",
|
||||
icon: "⚔️",
|
||||
condition: { type: "bossesDefeated", amount: 1 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
icon: "⚔️",
|
||||
id: "boss_slayer",
|
||||
name: "Boss Slayer",
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "boss_veteran",
|
||||
name: "Boss Veteran",
|
||||
condition: { amount: 5, type: "bossesDefeated" },
|
||||
description: "Defeat 5 bosses.",
|
||||
icon: "🗡️",
|
||||
condition: { type: "bossesDefeated", amount: 5 },
|
||||
reward: { crystals: 150 },
|
||||
unlockedAt: null,
|
||||
icon: "🗡️",
|
||||
id: "boss_veteran",
|
||||
name: "Boss Veteran",
|
||||
reward: { crystals: 150 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "legendary_hunter",
|
||||
name: "Legendary Hunter",
|
||||
condition: { amount: 10, type: "bossesDefeated" },
|
||||
description: "Defeat 10 bosses.",
|
||||
icon: "🏆",
|
||||
condition: { type: "bossesDefeated", amount: 10 },
|
||||
reward: { crystals: 500 },
|
||||
unlockedAt: null,
|
||||
icon: "🏆",
|
||||
id: "legendary_hunter",
|
||||
name: "Legendary Hunter",
|
||||
reward: { crystals: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "devourer_slayer",
|
||||
name: "World Saver",
|
||||
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
|
||||
icon: "🌟",
|
||||
condition: { type: "bossesDefeated", amount: 18 },
|
||||
reward: { crystals: 2_000 },
|
||||
unlockedAt: null,
|
||||
condition: { amount: 18, type: "bossesDefeated" },
|
||||
description: "Defeat all 18 bosses across the first six zones.",
|
||||
icon: "🌟",
|
||||
id: "devourer_slayer",
|
||||
name: "World Saver",
|
||||
reward: { crystals: 2000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Adventurer milestones
|
||||
{
|
||||
id: "guild_master",
|
||||
name: "Guild Master",
|
||||
condition: { amount: 50, type: "adventurerTotal" },
|
||||
description: "Recruit a total of 50 adventurers.",
|
||||
icon: "🏰",
|
||||
condition: { type: "adventurerTotal", amount: 50 },
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
icon: "🏰",
|
||||
id: "guild_master",
|
||||
name: "Guild Master",
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "army_commander",
|
||||
name: "Army Commander",
|
||||
condition: { amount: 500, type: "adventurerTotal" },
|
||||
description: "Recruit a total of 500 adventurers.",
|
||||
icon: "🛡️",
|
||||
condition: { type: "adventurerTotal", amount: 500 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
icon: "🛡️",
|
||||
id: "army_commander",
|
||||
name: "Army Commander",
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "army_legend",
|
||||
name: "Legendary Commander",
|
||||
condition: { amount: 5000, type: "adventurerTotal" },
|
||||
description: "Recruit a total of 5,000 adventurers.",
|
||||
icon: "⚜️",
|
||||
condition: { type: "adventurerTotal", amount: 5_000 },
|
||||
reward: { crystals: 750 },
|
||||
unlockedAt: null,
|
||||
icon: "⚜️",
|
||||
id: "army_legend",
|
||||
name: "Legendary Commander",
|
||||
reward: { crystals: 750 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Prestige milestones
|
||||
{
|
||||
id: "first_prestige",
|
||||
name: "Born Again",
|
||||
condition: { amount: 1, type: "prestigeCount" },
|
||||
description: "Prestige for the first time.",
|
||||
icon: "⭐",
|
||||
condition: { type: "prestigeCount", amount: 1 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
icon: "⭐",
|
||||
id: "first_prestige",
|
||||
name: "Born Again",
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Collection milestones
|
||||
{
|
||||
id: "collector",
|
||||
name: "Collector",
|
||||
condition: { amount: 4, type: "equipmentOwned" },
|
||||
description: "Acquire your first piece of boss-dropped equipment.",
|
||||
icon: "🎒",
|
||||
condition: { type: "equipmentOwned", amount: 4 },
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
icon: "🎒",
|
||||
id: "collector",
|
||||
name: "Collector",
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "arsenal",
|
||||
name: "Arsenal",
|
||||
condition: { amount: 12, type: "equipmentOwned" },
|
||||
description: "Own 12 pieces of equipment.",
|
||||
icon: "🗃️",
|
||||
condition: { type: "equipmentOwned", amount: 12 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
icon: "🗃️",
|
||||
id: "arsenal",
|
||||
name: "Arsenal",
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "well_armed",
|
||||
name: "Well Armed",
|
||||
condition: { amount: 25, type: "equipmentOwned" },
|
||||
description: "Own 25 pieces of equipment.",
|
||||
icon: "⚔️",
|
||||
condition: { type: "equipmentOwned", amount: 25 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
icon: "⚔️",
|
||||
id: "well_armed",
|
||||
name: "Well Armed",
|
||||
reward: { crystals: 1000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "fully_equipped",
|
||||
name: "Fully Equipped",
|
||||
description: "Own 40 pieces of equipment.",
|
||||
icon: "🛡️",
|
||||
condition: { type: "equipmentOwned", amount: 40 },
|
||||
reward: { crystals: 10_000 },
|
||||
unlockedAt: null,
|
||||
condition: { amount: 65, type: "equipmentOwned" },
|
||||
description: "Own all 65 pieces of equipment.",
|
||||
icon: "🛡️",
|
||||
id: "fully_equipped",
|
||||
name: "Fully Equipped",
|
||||
reward: { crystals: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher click milestones
|
||||
{
|
||||
id: "click_obsessed",
|
||||
name: "Click Obsessed",
|
||||
condition: { amount: 100_000, type: "totalClicks" },
|
||||
description: "Click the Guild Hall 100,000 times.",
|
||||
icon: "💥",
|
||||
condition: { type: "totalClicks", amount: 100_000 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
icon: "💥",
|
||||
id: "click_obsessed",
|
||||
name: "Click Obsessed",
|
||||
reward: { crystals: 1000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_deity",
|
||||
name: "Click Deity",
|
||||
condition: { amount: 1_000_000, type: "totalClicks" },
|
||||
description: "Click the Guild Hall 1,000,000 times.",
|
||||
icon: "☄️",
|
||||
condition: { type: "totalClicks", amount: 1_000_000 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
icon: "☄️",
|
||||
id: "click_deity",
|
||||
name: "Click Deity",
|
||||
reward: { crystals: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Endgame gold milestones
|
||||
{
|
||||
id: "quadrillionaire",
|
||||
name: "Quadrillionaire",
|
||||
condition: { amount: 1e15, type: "totalGoldEarned" },
|
||||
description: "Earn 1 quadrillion gold in total.",
|
||||
icon: "✨",
|
||||
condition: { type: "totalGoldEarned", amount: 1e15 },
|
||||
reward: { crystals: 10_000 },
|
||||
unlockedAt: null,
|
||||
icon: "✨",
|
||||
id: "quadrillionaire",
|
||||
name: "Quadrillionaire",
|
||||
reward: { crystals: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "void_hoarder",
|
||||
name: "Void Hoarder",
|
||||
condition: { amount: 1e18, type: "totalGoldEarned" },
|
||||
description: "Earn 1 quintillion gold in total.",
|
||||
icon: "🌀",
|
||||
condition: { type: "totalGoldEarned", amount: 1e18 },
|
||||
reward: { crystals: 50_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🌀",
|
||||
id: "void_hoarder",
|
||||
name: "Void Hoarder",
|
||||
reward: { crystals: 50_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher quest milestones
|
||||
{
|
||||
id: "quest_champion",
|
||||
name: "Quest Champion",
|
||||
condition: { amount: 30, type: "questsCompleted" },
|
||||
description: "Complete 30 quests.",
|
||||
icon: "🏅",
|
||||
condition: { type: "questsCompleted", amount: 30 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🏅",
|
||||
id: "quest_champion",
|
||||
name: "Quest Champion",
|
||||
reward: { crystals: 1000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_grandmaster",
|
||||
name: "Quest Grandmaster",
|
||||
condition: { amount: 50, type: "questsCompleted" },
|
||||
description: "Complete 50 quests.",
|
||||
icon: "🎖️",
|
||||
condition: { type: "questsCompleted", amount: 50 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🎖️",
|
||||
id: "quest_grandmaster",
|
||||
name: "Quest Grandmaster",
|
||||
reward: { crystals: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_eternal",
|
||||
name: "Quest Eternal",
|
||||
description: "Complete all 72 quests across the known multiverse.",
|
||||
icon: "🌌",
|
||||
condition: { type: "questsCompleted", amount: 72 },
|
||||
reward: { crystals: 25_000 },
|
||||
unlockedAt: null,
|
||||
condition: { amount: 95, type: "questsCompleted" },
|
||||
description: "Complete all 95 quests across the known multiverse.",
|
||||
icon: "🌌",
|
||||
id: "quest_eternal",
|
||||
name: "Quest Eternal",
|
||||
reward: { crystals: 25_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher boss milestones
|
||||
{
|
||||
id: "boss_champion",
|
||||
name: "Champion of the Realm",
|
||||
condition: { amount: 20, type: "bossesDefeated" },
|
||||
description: "Defeat 20 bosses.",
|
||||
icon: "🦁",
|
||||
condition: { type: "bossesDefeated", amount: 20 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🦁",
|
||||
id: "boss_champion",
|
||||
name: "Champion of the Realm",
|
||||
reward: { crystals: 1000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "boss_grandmaster",
|
||||
name: "Grandmaster Hunter",
|
||||
condition: { amount: 30, type: "bossesDefeated" },
|
||||
description: "Defeat 30 bosses.",
|
||||
icon: "🔱",
|
||||
condition: { type: "bossesDefeated", amount: 30 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🔱",
|
||||
id: "boss_grandmaster",
|
||||
name: "Grandmaster Hunter",
|
||||
reward: { crystals: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "boss_eternal",
|
||||
name: "Eternal Vanquisher",
|
||||
description: "Defeat all 60 bosses across every plane of existence.",
|
||||
icon: "💀",
|
||||
condition: { type: "bossesDefeated", amount: 60 },
|
||||
reward: { crystals: 50_000 },
|
||||
unlockedAt: null,
|
||||
condition: { amount: 72, type: "bossesDefeated" },
|
||||
description: "Defeat all 72 bosses across every plane of existence.",
|
||||
icon: "💀",
|
||||
id: "boss_eternal",
|
||||
name: "Eternal Vanquisher",
|
||||
reward: { crystals: 50_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher adventurer milestones
|
||||
{
|
||||
id: "army_titan",
|
||||
name: "Titan Commander",
|
||||
condition: { amount: 50_000, type: "adventurerTotal" },
|
||||
description: "Recruit a total of 50,000 adventurers.",
|
||||
icon: "⚡",
|
||||
condition: { type: "adventurerTotal", amount: 50_000 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
icon: "⚡",
|
||||
id: "army_titan",
|
||||
name: "Titan Commander",
|
||||
reward: { crystals: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher prestige milestones
|
||||
{
|
||||
id: "prestige_veteran",
|
||||
name: "Veteran of Ages",
|
||||
condition: { amount: 5, type: "prestigeCount" },
|
||||
description: "Prestige 5 times.",
|
||||
icon: "🌟",
|
||||
condition: { type: "prestigeCount", amount: 5 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🌟",
|
||||
id: "prestige_veteran",
|
||||
name: "Veteran of Ages",
|
||||
reward: { crystals: 1000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "prestige_master",
|
||||
name: "Master of Cycles",
|
||||
condition: { amount: 10, type: "prestigeCount" },
|
||||
description: "Prestige 10 times.",
|
||||
icon: "💫",
|
||||
condition: { type: "prestigeCount", amount: 10 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
icon: "💫",
|
||||
id: "prestige_master",
|
||||
name: "Master of Cycles",
|
||||
reward: { crystals: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "prestige_legend",
|
||||
name: "Legend of Eternity",
|
||||
condition: { amount: 25, type: "prestigeCount" },
|
||||
description: "Prestige 25 times.",
|
||||
icon: "🌠",
|
||||
condition: { type: "prestigeCount", amount: 25 },
|
||||
reward: { crystals: 25_000 },
|
||||
unlockedAt: null,
|
||||
icon: "🌠",
|
||||
id: "prestige_legend",
|
||||
name: "Legend of Eternity",
|
||||
reward: { crystals: 25_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
+311
-260
@@ -1,356 +1,407 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
export const defaultAdventurers: Array<Adventurer> = [
|
||||
{
|
||||
id: "peasant",
|
||||
name: "Peasant",
|
||||
class: "warrior",
|
||||
level: 1,
|
||||
goldPerSecond: 0.1,
|
||||
baseCost: 10,
|
||||
class: "warrior",
|
||||
combatPower: 1,
|
||||
count: 0,
|
||||
essencePerSecond: 0,
|
||||
combatPower: 1,
|
||||
count: 0,
|
||||
unlocked: true,
|
||||
goldPerSecond: 0.1,
|
||||
id: "peasant",
|
||||
level: 1,
|
||||
name: "Peasant",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "militia",
|
||||
name: "Militia",
|
||||
class: "warrior",
|
||||
level: 2,
|
||||
goldPerSecond: 0.5,
|
||||
baseCost: 100,
|
||||
class: "warrior",
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
essencePerSecond: 0,
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 0.5,
|
||||
id: "militia",
|
||||
level: 2,
|
||||
name: "Militia",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "apprentice",
|
||||
name: "Apprentice Mage",
|
||||
class: "mage",
|
||||
level: 3,
|
||||
goldPerSecond: 1.5,
|
||||
baseCost: 750,
|
||||
class: "mage",
|
||||
combatPower: 8,
|
||||
count: 0,
|
||||
essencePerSecond: 0.01,
|
||||
combatPower: 8,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 1.5,
|
||||
id: "apprentice",
|
||||
level: 3,
|
||||
name: "Apprentice Mage",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "scout",
|
||||
name: "Scout",
|
||||
class: "rogue",
|
||||
level: 4,
|
||||
goldPerSecond: 4,
|
||||
baseCost: 5000,
|
||||
class: "rogue",
|
||||
combatPower: 20,
|
||||
count: 0,
|
||||
essencePerSecond: 0.02,
|
||||
combatPower: 20,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 4,
|
||||
id: "scout",
|
||||
level: 4,
|
||||
name: "Scout",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "acolyte",
|
||||
name: "Acolyte",
|
||||
class: "cleric",
|
||||
level: 5,
|
||||
goldPerSecond: 10,
|
||||
baseCost: 35_000,
|
||||
class: "cleric",
|
||||
combatPower: 50,
|
||||
count: 0,
|
||||
essencePerSecond: 0.05,
|
||||
combatPower: 50,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 10,
|
||||
id: "acolyte",
|
||||
level: 5,
|
||||
name: "Acolyte",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "ranger",
|
||||
name: "Ranger",
|
||||
class: "ranger",
|
||||
level: 6,
|
||||
goldPerSecond: 25,
|
||||
baseCost: 250_000,
|
||||
class: "ranger",
|
||||
combatPower: 120,
|
||||
count: 0,
|
||||
essencePerSecond: 0.1,
|
||||
combatPower: 120,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 25,
|
||||
id: "ranger",
|
||||
level: 6,
|
||||
name: "Ranger",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "knight",
|
||||
name: "Knight",
|
||||
class: "warrior",
|
||||
level: 7,
|
||||
goldPerSecond: 75,
|
||||
baseCost: 1_750_000,
|
||||
class: "warrior",
|
||||
combatPower: 300,
|
||||
count: 0,
|
||||
essencePerSecond: 0.2,
|
||||
combatPower: 300,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 75,
|
||||
id: "knight",
|
||||
level: 7,
|
||||
name: "Knight",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "archmage",
|
||||
name: "Archmage",
|
||||
class: "mage",
|
||||
level: 8,
|
||||
goldPerSecond: 200,
|
||||
baseCost: 12_000_000,
|
||||
class: "mage",
|
||||
combatPower: 800,
|
||||
count: 0,
|
||||
essencePerSecond: 0.5,
|
||||
combatPower: 800,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 200,
|
||||
id: "archmage",
|
||||
level: 8,
|
||||
name: "Archmage",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "paladin",
|
||||
name: "Paladin",
|
||||
class: "paladin",
|
||||
level: 9,
|
||||
goldPerSecond: 600,
|
||||
baseCost: 85_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 2000,
|
||||
count: 0,
|
||||
essencePerSecond: 1,
|
||||
combatPower: 2000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 600,
|
||||
id: "paladin",
|
||||
level: 9,
|
||||
name: "Paladin",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "dragon_rider",
|
||||
name: "Dragon Rider",
|
||||
class: "ranger",
|
||||
level: 10,
|
||||
goldPerSecond: 2000,
|
||||
baseCost: 600_000_000,
|
||||
class: "ranger",
|
||||
combatPower: 6000,
|
||||
count: 0,
|
||||
essencePerSecond: 3,
|
||||
combatPower: 6000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 2000,
|
||||
id: "dragon_rider",
|
||||
level: 10,
|
||||
name: "Dragon Rider",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "shadow_assassin",
|
||||
name: "Shadow Assassin",
|
||||
class: "rogue",
|
||||
level: 11,
|
||||
goldPerSecond: 5_000,
|
||||
baseCost: 2_600_000_000,
|
||||
class: "mage",
|
||||
combatPower: 13_000,
|
||||
count: 0,
|
||||
essencePerSecond: 6,
|
||||
combatPower: 18_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 4500,
|
||||
id: "arcane_scholar",
|
||||
level: 11,
|
||||
name: "Arcane Scholar",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "arcane_scholar",
|
||||
name: "Arcane Scholar",
|
||||
class: "mage",
|
||||
level: 12,
|
||||
goldPerSecond: 14_000,
|
||||
essencePerSecond: 15,
|
||||
combatPower: 45_000,
|
||||
count: 0,
|
||||
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",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_walker",
|
||||
name: "Void Walker",
|
||||
class: "rogue",
|
||||
level: 13,
|
||||
goldPerSecond: 40_000,
|
||||
baseCost: 47_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 60_000,
|
||||
count: 0,
|
||||
essencePerSecond: 20,
|
||||
goldPerSecond: 20_000,
|
||||
id: "dark_templar",
|
||||
level: 13,
|
||||
name: "Dark Templar",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 200_000_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 130_000,
|
||||
count: 0,
|
||||
essencePerSecond: 35,
|
||||
combatPower: 130_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 40_000,
|
||||
id: "void_walker",
|
||||
level: 14,
|
||||
name: "Void Walker",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "celestial_guard",
|
||||
name: "Celestial Guard",
|
||||
class: "paladin",
|
||||
level: 14,
|
||||
goldPerSecond: 120_000,
|
||||
baseCost: 1_400_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 400_000,
|
||||
count: 0,
|
||||
essencePerSecond: 100,
|
||||
combatPower: 400_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 120_000,
|
||||
id: "celestial_guard",
|
||||
level: 15,
|
||||
name: "Celestial Guard",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "divine_champion",
|
||||
name: "Divine Champion",
|
||||
class: "warrior",
|
||||
level: 15,
|
||||
goldPerSecond: 400_000,
|
||||
baseCost: 10_000_000_000_000,
|
||||
class: "warrior",
|
||||
combatPower: 1_200_000,
|
||||
count: 0,
|
||||
essencePerSecond: 300,
|
||||
combatPower: 1_200_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 400_000,
|
||||
id: "divine_champion",
|
||||
level: 16,
|
||||
name: "Divine Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "seraph_knight",
|
||||
name: "Seraph Knight",
|
||||
class: "paladin",
|
||||
level: 16,
|
||||
goldPerSecond: 1_200_000,
|
||||
baseCost: 70_000_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 4_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 800,
|
||||
combatPower: 4_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 1_200_000,
|
||||
id: "seraph_knight",
|
||||
level: 17,
|
||||
name: "Seraph Knight",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "abyss_diver",
|
||||
name: "Abyss Diver",
|
||||
class: "rogue",
|
||||
level: 17,
|
||||
goldPerSecond: 3_500_000,
|
||||
essencePerSecond: 2_000,
|
||||
combatPower: 12_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
baseCost: 500_000_000_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 12_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 2000,
|
||||
goldPerSecond: 3_500_000,
|
||||
id: "abyss_diver",
|
||||
level: 18,
|
||||
name: "Abyss Diver",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "infernal_warden",
|
||||
name: "Infernal Warden",
|
||||
class: "warrior",
|
||||
level: 18,
|
||||
goldPerSecond: 10_000_000,
|
||||
essencePerSecond: 5_000,
|
||||
combatPower: 35_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
baseCost: 3_500_000_000_000_000,
|
||||
class: "warrior",
|
||||
combatPower: 35_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 5000,
|
||||
goldPerSecond: 10_000_000,
|
||||
id: "infernal_warden",
|
||||
level: 19,
|
||||
name: "Infernal Warden",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "crystal_sage",
|
||||
name: "Crystal Sage",
|
||||
class: "mage",
|
||||
level: 19,
|
||||
goldPerSecond: 30_000_000,
|
||||
baseCost: 25_000_000_000_000_000,
|
||||
class: "mage",
|
||||
combatPower: 100_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 12_000,
|
||||
combatPower: 100_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 30_000_000,
|
||||
id: "crystal_sage",
|
||||
level: 20,
|
||||
name: "Crystal Sage",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_sentinel",
|
||||
name: "Void Sentinel",
|
||||
class: "rogue",
|
||||
level: 20,
|
||||
goldPerSecond: 90_000_000,
|
||||
baseCost: 175_000_000_000_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 300_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 30_000,
|
||||
combatPower: 300_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 90_000_000,
|
||||
id: "void_sentinel",
|
||||
level: 21,
|
||||
name: "Void Sentinel",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "eternal_champion",
|
||||
name: "Eternal Champion",
|
||||
class: "warrior",
|
||||
level: 21,
|
||||
goldPerSecond: 270_000_000,
|
||||
baseCost: 1_200_000_000_000_000_000,
|
||||
class: "warrior",
|
||||
combatPower: 900_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 80_000,
|
||||
combatPower: 900_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 270_000_000,
|
||||
id: "eternal_champion",
|
||||
level: 22,
|
||||
name: "Eternal Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "aether_weaver",
|
||||
name: "Aether Weaver",
|
||||
class: "mage",
|
||||
level: 22,
|
||||
goldPerSecond: 800_000_000,
|
||||
baseCost: 8_500_000_000_000_000_000,
|
||||
class: "mage",
|
||||
combatPower: 2_700_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 220_000,
|
||||
combatPower: 2_700_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 800_000_000,
|
||||
id: "aether_weaver",
|
||||
level: 23,
|
||||
name: "Aether Weaver",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "titan_warrior",
|
||||
name: "Titan Warrior",
|
||||
class: "warrior",
|
||||
level: 23,
|
||||
goldPerSecond: 2_500_000_000,
|
||||
baseCost: 60_000_000_000_000_000_000,
|
||||
class: "warrior",
|
||||
combatPower: 8_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 600_000,
|
||||
combatPower: 8_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 2_500_000_000,
|
||||
id: "titan_warrior",
|
||||
level: 24,
|
||||
name: "Titan Warrior",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "nexus_sage",
|
||||
name: "Nexus Sage",
|
||||
class: "mage",
|
||||
level: 24,
|
||||
goldPerSecond: 7_500_000_000,
|
||||
baseCost: 420_000_000_000_000_000_000,
|
||||
class: "mage",
|
||||
combatPower: 24_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 1_600_000,
|
||||
combatPower: 24_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 7_500_000_000,
|
||||
id: "nexus_sage",
|
||||
level: 25,
|
||||
name: "Nexus Sage",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "cosmos_knight",
|
||||
name: "Cosmos Knight",
|
||||
class: "paladin",
|
||||
level: 25,
|
||||
goldPerSecond: 22_000_000_000,
|
||||
baseCost: 3_000_000_000_000_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 72_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 4_500_000,
|
||||
combatPower: 72_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 22_000_000_000,
|
||||
id: "cosmos_knight",
|
||||
level: 26,
|
||||
name: "Cosmos Knight",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "astral_sovereign",
|
||||
name: "Astral Sovereign",
|
||||
class: "warrior",
|
||||
level: 26,
|
||||
goldPerSecond: 65_000_000_000,
|
||||
baseCost: 21_000_000_000_000_000_000_000,
|
||||
class: "warrior",
|
||||
combatPower: 200_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 12_000_000,
|
||||
combatPower: 200_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 65_000_000_000,
|
||||
id: "astral_sovereign",
|
||||
level: 27,
|
||||
name: "Astral Sovereign",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "primordial_mage",
|
||||
name: "Primordial Mage",
|
||||
class: "mage",
|
||||
level: 27,
|
||||
goldPerSecond: 200_000_000_000,
|
||||
baseCost: 150_000_000_000_000_000_000_000,
|
||||
class: "mage",
|
||||
combatPower: 600_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 35_000_000,
|
||||
combatPower: 600_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 200_000_000_000,
|
||||
id: "primordial_mage",
|
||||
level: 28,
|
||||
name: "Primordial Mage",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "reality_warden",
|
||||
name: "Reality Warden",
|
||||
class: "paladin",
|
||||
level: 28,
|
||||
goldPerSecond: 600_000_000_000,
|
||||
baseCost: 1_000_000_000_000_000_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 1_800_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 100_000_000,
|
||||
combatPower: 1_800_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 600_000_000_000,
|
||||
id: "reality_warden",
|
||||
level: 29,
|
||||
name: "Reality Warden",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "infinity_ranger",
|
||||
name: "Infinity Ranger",
|
||||
class: "ranger",
|
||||
level: 29,
|
||||
goldPerSecond: 1_800_000_000_000,
|
||||
baseCost: 7_000_000_000_000_000_000_000_000,
|
||||
class: "ranger",
|
||||
combatPower: 5_500_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 300_000_000,
|
||||
combatPower: 5_500_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 1_800_000_000_000,
|
||||
id: "infinity_ranger",
|
||||
level: 30,
|
||||
name: "Infinity Ranger",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "oblivion_paladin",
|
||||
name: "Oblivion Paladin",
|
||||
class: "paladin",
|
||||
level: 30,
|
||||
goldPerSecond: 5_500_000_000_000,
|
||||
baseCost: 50_000_000_000_000_000_000_000_000,
|
||||
class: "paladin",
|
||||
combatPower: 16_000_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 850_000_000,
|
||||
combatPower: 16_000_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 5_500_000_000_000,
|
||||
id: "oblivion_paladin",
|
||||
level: 31,
|
||||
name: "Oblivion Paladin",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "transcendent_rogue",
|
||||
name: "Transcendent Rogue",
|
||||
class: "rogue",
|
||||
level: 31,
|
||||
goldPerSecond: 16_000_000_000_000,
|
||||
baseCost: 350_000_000_000_000_000_000_000_000,
|
||||
class: "rogue",
|
||||
combatPower: 50_000_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 2_500_000_000,
|
||||
combatPower: 50_000_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 16_000_000_000_000,
|
||||
id: "transcendent_rogue",
|
||||
level: 32,
|
||||
name: "Transcendent Rogue",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "omniversal_champion",
|
||||
name: "Omniversal Champion",
|
||||
class: "warrior",
|
||||
level: 32,
|
||||
goldPerSecond: 50_000_000_000_000,
|
||||
baseCost: 2_500_000_000_000_000_000_000_000_000,
|
||||
class: "warrior",
|
||||
combatPower: 150_000_000_000_000,
|
||||
count: 0,
|
||||
essencePerSecond: 7_000_000_000,
|
||||
combatPower: 150_000_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
goldPerSecond: 50_000_000_000_000,
|
||||
id: "omniversal_champion",
|
||||
level: 33,
|
||||
name: "Omniversal Champion",
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
+946
-938
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,71 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { DailyChallengeType } from "@elysium/types";
|
||||
|
||||
interface DailyChallengeTemplate {
|
||||
type: DailyChallengeType;
|
||||
label: string;
|
||||
target: number;
|
||||
type: DailyChallengeType;
|
||||
label: string;
|
||||
target: number;
|
||||
rewardCrystals: number;
|
||||
}
|
||||
|
||||
export const DAILY_CHALLENGE_TEMPLATES: DailyChallengeTemplate[] = [
|
||||
export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
|
||||
// Clicks — always requires active play
|
||||
{ type: "clicks", label: "Click 500 times", target: 500, rewardCrystals: 50 },
|
||||
{ type: "clicks", label: "Click 1,000 times", target: 1_000, rewardCrystals: 100 },
|
||||
{ type: "clicks", label: "Click 5,000 times", target: 5_000, rewardCrystals: 300 },
|
||||
{ label: "Click 500 times", rewardCrystals: 50, target: 500, type: "clicks" },
|
||||
{
|
||||
label: "Click 1,000 times",
|
||||
rewardCrystals: 100,
|
||||
target: 1000,
|
||||
type: "clicks",
|
||||
},
|
||||
{
|
||||
label: "Click 5,000 times",
|
||||
rewardCrystals: 300,
|
||||
target: 5000,
|
||||
type: "clicks",
|
||||
},
|
||||
// Boss defeats — requires active combat
|
||||
{ type: "bossesDefeated", label: "Defeat 1 boss", target: 1, rewardCrystals: 75 },
|
||||
{ type: "bossesDefeated", label: "Defeat 3 bosses", target: 3, rewardCrystals: 200 },
|
||||
{ type: "bossesDefeated", label: "Defeat 5 bosses", target: 5, rewardCrystals: 400 },
|
||||
{
|
||||
label: "Defeat 1 boss",
|
||||
rewardCrystals: 75,
|
||||
target: 1,
|
||||
type: "bossesDefeated",
|
||||
},
|
||||
{
|
||||
label: "Defeat 3 bosses",
|
||||
rewardCrystals: 200,
|
||||
target: 3,
|
||||
type: "bossesDefeated",
|
||||
},
|
||||
{
|
||||
label: "Defeat 5 bosses",
|
||||
rewardCrystals: 400,
|
||||
target: 5,
|
||||
type: "bossesDefeated",
|
||||
},
|
||||
// Quest completions — requires starting quests
|
||||
{ type: "questsCompleted", label: "Complete 3 quests", target: 3, rewardCrystals: 100 },
|
||||
{ type: "questsCompleted", label: "Complete 5 quests", target: 5, rewardCrystals: 200 },
|
||||
{ type: "questsCompleted", label: "Complete 10 quests", target: 10, rewardCrystals: 400 },
|
||||
{
|
||||
label: "Complete 3 quests",
|
||||
rewardCrystals: 100,
|
||||
target: 3,
|
||||
type: "questsCompleted",
|
||||
},
|
||||
{
|
||||
label: "Complete 5 quests",
|
||||
rewardCrystals: 200,
|
||||
target: 5,
|
||||
type: "questsCompleted",
|
||||
},
|
||||
{
|
||||
label: "Complete 10 quests",
|
||||
rewardCrystals: 400,
|
||||
target: 10,
|
||||
type: "questsCompleted",
|
||||
},
|
||||
// Prestige — the big one
|
||||
{ type: "prestige", label: "Prestige once", target: 1, rewardCrystals: 750 },
|
||||
{ label: "Prestige once", rewardCrystals: 750, target: 1, type: "prestige" },
|
||||
];
|
||||
|
||||
+538
-470
File diff suppressed because it is too large
Load Diff
@@ -1,94 +1,111 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Numeric keys required by EquipmentSet type */
|
||||
import type { EquipmentSet } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_EQUIPMENT_SETS: EquipmentSet[] = [
|
||||
export const defaultEquipmentSets: Array<EquipmentSet> = [
|
||||
{
|
||||
id: "iron_vanguard",
|
||||
name: "Iron Vanguard",
|
||||
description: "The armaments of a seasoned guild soldier — proven steel, reliable gold.",
|
||||
pieces: ["iron_sword", "chainmail", "mages_focus"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.1 },
|
||||
3: { combatMultiplier: 1.1 },
|
||||
},
|
||||
description:
|
||||
"The armaments of a seasoned guild soldier — proven steel, reliable gold.",
|
||||
id: "iron_vanguard",
|
||||
name: "Iron Vanguard",
|
||||
pieces: [ "iron_sword", "chainmail", "mages_focus" ],
|
||||
},
|
||||
{
|
||||
id: "shadow_infiltrator",
|
||||
name: "Shadow Infiltrator",
|
||||
description: "Gear forged from the Shadow Marshes themselves — unseen, unstoppable.",
|
||||
pieces: ["shadow_dagger", "void_shroud", "void_compass"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.15 },
|
||||
3: { clickMultiplier: 1.2 },
|
||||
},
|
||||
description:
|
||||
"Gear forged from the Shadow Marshes themselves — unseen, unstoppable.",
|
||||
id: "shadow_infiltrator",
|
||||
name: "Shadow Infiltrator",
|
||||
pieces: [ "shadow_dagger", "void_shroud", "void_compass" ],
|
||||
},
|
||||
{
|
||||
id: "volcanic_forger",
|
||||
name: "Volcanic Forger",
|
||||
description: "Weapons and armour tempered in the depths of the Volcanic Reaches.",
|
||||
pieces: ["flame_lance", "volcanic_plate", "crystal_shard"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.15 },
|
||||
3: { goldMultiplier: 1.15 },
|
||||
},
|
||||
description:
|
||||
"Weapons and armour tempered in the depths of the Volcanic Reaches.",
|
||||
id: "volcanic_forger",
|
||||
name: "Volcanic Forger",
|
||||
pieces: [ "flame_lance", "volcanic_plate", "crystal_shard" ],
|
||||
},
|
||||
{
|
||||
id: "celestial_guardian",
|
||||
name: "Celestial Guardian",
|
||||
description: "Relics of the Celestial Reaches — divine power made manifest.",
|
||||
pieces: ["seraph_wing", "celestial_armour", "angels_halo"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.2 },
|
||||
3: { goldMultiplier: 1.2 },
|
||||
},
|
||||
description:
|
||||
"Relics of the Celestial Reaches — divine power made manifest.",
|
||||
id: "celestial_guardian",
|
||||
name: "Celestial Guardian",
|
||||
pieces: [ "seraph_wing", "celestial_armour", "angels_halo" ],
|
||||
},
|
||||
{
|
||||
id: "abyssal_predator",
|
||||
name: "Abyssal Predator",
|
||||
description: "Trophies reclaimed from the deepest trenches of the Abyssal Reaches.",
|
||||
pieces: ["depth_blade", "pressure_plate", "leviathan_eye"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.2 },
|
||||
3: { clickMultiplier: 1.25 },
|
||||
},
|
||||
description:
|
||||
"Trophies reclaimed from the deepest trenches of the Abyssal Reaches.",
|
||||
id: "abyssal_predator",
|
||||
name: "Abyssal Predator",
|
||||
pieces: [ "depth_blade", "pressure_plate", "leviathan_eye" ],
|
||||
},
|
||||
{
|
||||
id: "infernal_conqueror",
|
||||
name: "Infernal Conqueror",
|
||||
description: "Forged in the heart of the Infernal Court from the essence of the defeated.",
|
||||
pieces: ["hellfire_edge", "demon_hide", "soul_gem"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.25 },
|
||||
3: { goldMultiplier: 1.25 },
|
||||
},
|
||||
description:
|
||||
"Forged in the heart of the Infernal Court from the essence of the defeated.",
|
||||
id: "infernal_conqueror",
|
||||
name: "Infernal Conqueror",
|
||||
pieces: [ "hellfire_edge", "demon_hide", "soul_gem" ],
|
||||
},
|
||||
{
|
||||
id: "crystal_domain",
|
||||
name: "Crystal Domain",
|
||||
description: "Instruments of the Crystalline Spire — reality refracted into absolute efficiency.",
|
||||
pieces: ["prism_blade", "faceted_armour", "prism_eye"],
|
||||
bonuses: {
|
||||
2: { clickMultiplier: 1.25 },
|
||||
3: { goldMultiplier: 1.25 },
|
||||
},
|
||||
description:
|
||||
"Instruments of the Crystalline Spire — reality refracted into absolute efficiency.",
|
||||
id: "crystal_domain",
|
||||
name: "Crystal Domain",
|
||||
pieces: [ "prism_blade", "faceted_armour", "prism_eye" ],
|
||||
},
|
||||
{
|
||||
id: "void_emperor",
|
||||
name: "Void Emperor",
|
||||
description: "The regalia of the Void Sanctum's lord — power carved from absolute nothingness.",
|
||||
pieces: ["void_annihilator", "eternal_shroud", "void_heart_gem"],
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.3 },
|
||||
3: { combatMultiplier: 1.3 },
|
||||
},
|
||||
description:
|
||||
"The regalia of the Void Sanctum's lord — power carved from absolute nothingness.",
|
||||
id: "void_emperor",
|
||||
name: "Void Emperor",
|
||||
pieces: [ "void_annihilator", "eternal_shroud", "void_heart_gem" ],
|
||||
},
|
||||
{
|
||||
id: "eternal_throne",
|
||||
name: "Eternal Throne",
|
||||
description: "The armaments of the Eternal Throne — weapons and armour that have endured all of time.",
|
||||
pieces: ["throne_blade", "eternal_armour", "eternity_stone"],
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.35, goldMultiplier: 1.25 },
|
||||
3: { clickMultiplier: 1.35 },
|
||||
},
|
||||
description:
|
||||
"The armaments of the Eternal Throne — weapons and armour that have endured all of time.",
|
||||
id: "eternal_throne",
|
||||
name: "Eternal Throne",
|
||||
pieces: [ "throne_blade", "eternal_armour", "eternity_stone" ],
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +1,108 @@
|
||||
import type { GameState, Player, PrestigeData } from "@elysium/types";
|
||||
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
|
||||
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
|
||||
import { DEFAULT_BOSSES } from "./bosses.js";
|
||||
import { DEFAULT_EQUIPMENT } from "./equipment.js";
|
||||
import { DEFAULT_QUESTS } from "./quests.js";
|
||||
import { DEFAULT_UPGRADES } from "./upgrades.js";
|
||||
import { DEFAULT_ZONES } from "./zones.js";
|
||||
/**
|
||||
* @file Initial game state data.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { defaultAchievements } from "./achievements.js";
|
||||
import { defaultAdventurers } from "./adventurers.js";
|
||||
import { defaultBosses } from "./bosses.js";
|
||||
import { defaultEquipment } from "./equipment.js";
|
||||
import { defaultExplorations } from "./explorations.js";
|
||||
import { defaultQuests } from "./quests.js";
|
||||
import { currentSchemaVersion } from "./schemaVersion.js";
|
||||
import { defaultUpgrades } from "./upgrades.js";
|
||||
import { defaultZones } from "./zones.js";
|
||||
import type {
|
||||
ApotheosisData,
|
||||
ExplorationState,
|
||||
GameState,
|
||||
Player,
|
||||
PrestigeData,
|
||||
TranscendenceData,
|
||||
} from "@elysium/types";
|
||||
|
||||
export const INITIAL_PRESTIGE: PrestigeData = {
|
||||
count: 0,
|
||||
runestones: 0,
|
||||
const initialPrestige: PrestigeData = {
|
||||
count: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
purchasedUpgradeIds: [],
|
||||
runestones: 0,
|
||||
};
|
||||
|
||||
export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameState => ({
|
||||
player: {
|
||||
...player,
|
||||
characterName,
|
||||
totalGoldEarned: 0,
|
||||
totalClicks: 0,
|
||||
},
|
||||
resources: {
|
||||
gold: 0,
|
||||
essence: 0,
|
||||
crystals: 0,
|
||||
runestones: 0,
|
||||
},
|
||||
adventurers: structuredClone(DEFAULT_ADVENTURERS),
|
||||
upgrades: structuredClone(DEFAULT_UPGRADES),
|
||||
quests: structuredClone(DEFAULT_QUESTS),
|
||||
bosses: structuredClone(DEFAULT_BOSSES),
|
||||
equipment: structuredClone(DEFAULT_EQUIPMENT),
|
||||
achievements: structuredClone(DEFAULT_ACHIEVEMENTS),
|
||||
prestige: INITIAL_PRESTIGE,
|
||||
zones: structuredClone(DEFAULT_ZONES),
|
||||
baseClickPower: 1,
|
||||
lastTickAt: Date.now(),
|
||||
});
|
||||
const initialTranscendence: TranscendenceData = {
|
||||
count: 0,
|
||||
echoCombatMultiplier: 1,
|
||||
echoIncomeMultiplier: 1,
|
||||
echoMetaMultiplier: 1,
|
||||
echoPrestigeRunestoneMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1,
|
||||
echoes: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
};
|
||||
|
||||
const initialApotheosis: ApotheosisData = {
|
||||
count: 0,
|
||||
};
|
||||
|
||||
const initialExploration: ExplorationState = {
|
||||
areas: defaultExplorations.map((area) => {
|
||||
return {
|
||||
id: area.id,
|
||||
status:
|
||||
area.zoneId === "verdant_vale"
|
||||
? ("available" as const)
|
||||
: ("locked" as const),
|
||||
};
|
||||
}),
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an initial game state for a new player.
|
||||
* @param player - The player data from Discord OAuth.
|
||||
* @param characterName - The character name chosen by the player.
|
||||
* @returns A fresh GameState object.
|
||||
*/
|
||||
const initialGameState = (
|
||||
player: Player,
|
||||
characterName: string,
|
||||
): GameState => {
|
||||
return {
|
||||
achievements: structuredClone(defaultAchievements),
|
||||
adventurers: structuredClone(defaultAdventurers),
|
||||
apotheosis: { ...initialApotheosis },
|
||||
autoBoss: false,
|
||||
autoQuest: false,
|
||||
baseClickPower: 1,
|
||||
bosses: structuredClone(defaultBosses),
|
||||
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
||||
equipment: structuredClone(defaultEquipment),
|
||||
exploration: structuredClone(initialExploration),
|
||||
lastTickAt: Date.now(),
|
||||
player: {
|
||||
...player,
|
||||
characterName: characterName,
|
||||
totalClicks: 0,
|
||||
totalGoldEarned: 0,
|
||||
},
|
||||
prestige: initialPrestige,
|
||||
quests: structuredClone(defaultQuests),
|
||||
resources: {
|
||||
crystals: 0,
|
||||
essence: 0,
|
||||
gold: 0,
|
||||
runestones: 0,
|
||||
},
|
||||
schemaVersion: currentSchemaVersion,
|
||||
transcendence: { ...initialTranscendence },
|
||||
upgrades: structuredClone(defaultUpgrades),
|
||||
zones: structuredClone(defaultZones),
|
||||
};
|
||||
};
|
||||
|
||||
export { initialExploration, initialGameState };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file Login bonus reward data.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
export interface DayReward {
|
||||
day: number;
|
||||
goldBase: number;
|
||||
crystals?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewards for days 1–7 of a login streak. The cycle repeats every 7 days
|
||||
* with a multiplier equal to the week number (week 1 = ×1, week 2 = ×2, etc.).
|
||||
*/
|
||||
export const dailyRewards: Array<DayReward> = [
|
||||
{ day: 1, goldBase: 500 },
|
||||
{ day: 2, goldBase: 1000 },
|
||||
{ day: 3, goldBase: 2500 },
|
||||
{ day: 4, goldBase: 5000 },
|
||||
{ day: 5, goldBase: 10_000 },
|
||||
{ day: 6, goldBase: 25_000 },
|
||||
{ crystals: 5, day: 7, goldBase: 50_000 },
|
||||
];
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { Material } from "@elysium/types";
|
||||
|
||||
export const defaultMaterials: Array<Material> = [
|
||||
// Zone 1: verdant_vale
|
||||
{
|
||||
description:
|
||||
"Sticky resin tapped from ancient heartwood trees. Smells faintly of spring rain and something older beneath.",
|
||||
id: "verdant_sap",
|
||||
name: "Verdant Sap",
|
||||
rarity: "common",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A translucent gem found buried near the roots of old-growth trees. Pulses with gentle life energy when held.",
|
||||
id: "forest_crystal",
|
||||
name: "Forest Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Bark from a tree that has stood since before the kingdom. Harder than iron and warmer to the touch than it has any right to be.",
|
||||
id: "elder_bark",
|
||||
name: "Elder Bark",
|
||||
rarity: "rare",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
|
||||
// Zone 2: shattered_ruins
|
||||
{
|
||||
description:
|
||||
"Fine powder ground from fallen masonry. Still carries traces of the civilisation it once was — if you know how to read the patterns.",
|
||||
id: "ruin_dust",
|
||||
name: "Ruin Dust",
|
||||
rarity: "common",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A shard of enchanted stonework. The enchantment is broken, but something lingers in the grain of the stone, waiting.",
|
||||
id: "cursed_fragment",
|
||||
name: "Cursed Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of scale shed during the elder dragon's long reign over the ruins. Resistant to fire and magic alike.",
|
||||
id: "dragonscale_chip",
|
||||
name: "Dragonscale Chip",
|
||||
rarity: "rare",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
|
||||
// Zone 3: frozen_peaks
|
||||
{
|
||||
description:
|
||||
"Ice from a glacier that has not moved in ten thousand years. Impossibly cold and perfectly clear, with something almost visible within.",
|
||||
id: "glacial_ice",
|
||||
name: "Glacial Ice",
|
||||
rarity: "common",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that formed inside the glacier itself over millennia. It never melts, not even near fire.",
|
||||
id: "frost_crystal",
|
||||
name: "Frost Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of the reality tear. It hums with wrongness that the fingers instinctively recognise before the mind does.",
|
||||
id: "void_shard",
|
||||
name: "Void Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
|
||||
// Zone 4: shadow_marshes
|
||||
{
|
||||
description:
|
||||
"Roots from the strangler plants that thrive in the fog-choked depths. Toxic without extensive preparation. Worth it, usually.",
|
||||
id: "marsh_root",
|
||||
name: "Marsh Root",
|
||||
rarity: "common",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Distilled darkness, caught in a vial before it could dissipate. Heavy and cold, and absolutely lightless.",
|
||||
id: "shadow_essence",
|
||||
name: "Shadow Essence",
|
||||
rarity: "uncommon",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Bone from something that died in the marsh so long ago it has become part of it. The curse runs deep through the marrow.",
|
||||
id: "cursed_bone",
|
||||
name: "Cursed Bone",
|
||||
rarity: "rare",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
|
||||
// Zone 5: volcanic_depths
|
||||
{
|
||||
description:
|
||||
"Cooled lava that retained its internal heat. Warm to the touch even centuries after solidifying from whatever it once was.",
|
||||
id: "magma_stone",
|
||||
name: "Magma Stone",
|
||||
rarity: "common",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal grown in the heart of a cooling magma chamber. Burns without being consumed, endlessly.",
|
||||
id: "ember_crystal",
|
||||
name: "Ember Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Ore from a seam that the fire elementals guard jealously. What it forges into is extraordinary by any measure.",
|
||||
id: "legendary_ore",
|
||||
name: "Legendary Ore",
|
||||
rarity: "rare",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
|
||||
// Zone 6: astral_void
|
||||
{
|
||||
description:
|
||||
"Particulate matter from dying stars, collected from the void between worlds. Glitters even in total darkness.",
|
||||
id: "stardust",
|
||||
name: "Stardust",
|
||||
rarity: "common",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Filaments of solidified probability. Handle with care — they remember every possible future they passed through.",
|
||||
id: "astral_thread",
|
||||
name: "Astral Thread",
|
||||
rarity: "uncommon",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that formed in the spaces between spaces. Technically exists in several places simultaneously. Don't think too hard about it.",
|
||||
id: "void_crystal",
|
||||
name: "Void Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
|
||||
// Zone 7: celestial_reaches
|
||||
{
|
||||
description:
|
||||
"Residue from the celestial host's passing. Warm as sunlight and infinitely patient, as if waiting for something to happen.",
|
||||
id: "celestial_dust",
|
||||
name: "Celestial Dust",
|
||||
rarity: "common",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A chip of something the celestials discarded as imperfect. By mortal standards, it is extraordinary beyond measure.",
|
||||
id: "divine_fragment",
|
||||
name: "Divine Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystallised harmonic from the celestial choir. Resonates with a sound felt in the chest rather than heard with the ears.",
|
||||
id: "choir_shard",
|
||||
name: "Choir Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
description:
|
||||
"Coral from the deepest trenches where no light reaches and no warmth remains. Black as the water around it.",
|
||||
id: "trench_coral",
|
||||
name: "Trench Coral",
|
||||
rarity: "common",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A gem compressed by aeons of unimaginable pressure at the bottom of all things. Impossibly dense for its size.",
|
||||
id: "pressure_gem",
|
||||
name: "Pressure Gem",
|
||||
rarity: "uncommon",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A tooth from whatever has been waiting in the trench since before your world was made. It is very large.",
|
||||
id: "ancient_tooth",
|
||||
name: "Ancient Tooth",
|
||||
rarity: "rare",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
|
||||
// Zone 9: infernal_court
|
||||
{
|
||||
description:
|
||||
"Sulphur residue from the court's perpetual fires. The smell never fully fades, no matter how carefully it is stored.",
|
||||
id: "brimstone_flake",
|
||||
name: "Brimstone Flake",
|
||||
rarity: "common",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Extracted from the court's refuse. Corrosive, powerful, and deeply unpleasant in every measurable way.",
|
||||
id: "demon_ichor",
|
||||
name: "Demon Ichor",
|
||||
rarity: "uncommon",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"What remains after a soul has been fully processed by the court. Carries faint echoes of what it was before.",
|
||||
id: "soul_residue",
|
||||
name: "Soul Residue",
|
||||
rarity: "rare",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
|
||||
// Zone 10: crystalline_spire
|
||||
{
|
||||
description:
|
||||
"Ground from the spire's outer facets. Each particle contains a compressed possibility that has not yet resolved.",
|
||||
id: "prism_dust",
|
||||
name: "Prism Dust",
|
||||
rarity: "common",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of the spire's core intelligence. Still running calculations on something that may or may not have an answer.",
|
||||
id: "calculation_shard",
|
||||
name: "Calculation Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that contains a future that never happened. Treat carefully. The future remembers being possible.",
|
||||
id: "possibility_crystal",
|
||||
name: "Possibility Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
description:
|
||||
"Matter that exists in the space between spaces. Lacks most standard properties in ways that should not be possible.",
|
||||
id: "null_matter",
|
||||
name: "Null Matter",
|
||||
rarity: "common",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A shard of the call that drew your guild here. Still resonant, still reaching toward something none of you can name.",
|
||||
id: "resonance_fragment",
|
||||
name: "Resonance Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"From the heart of the sanctum itself. What it does is undefined. What it is cannot be satisfactorily described.",
|
||||
id: "sanctum_core",
|
||||
name: "Sanctum Core",
|
||||
rarity: "rare",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
|
||||
// Zone 12: eternal_throne
|
||||
{
|
||||
description:
|
||||
"Residue from the base of the eternal throne. Old beyond any measurement that applies to things your guild understands.",
|
||||
id: "throne_dust",
|
||||
name: "Throne Dust",
|
||||
rarity: "common",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A chip from one of the throne's crown-like spires. Authority made into something your hands can hold.",
|
||||
id: "crown_fragment",
|
||||
name: "Crown Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"From the throne's arm. Carries the weight of every decision ever made here, compressed into splinter-form.",
|
||||
id: "eternity_splinter",
|
||||
name: "Eternity Splinter",
|
||||
rarity: "rare",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
|
||||
// Zone 13: primordial_chaos
|
||||
{
|
||||
description:
|
||||
"A solidified moment of chaos. Still undecided about its own properties, which change depending on how you look at it.",
|
||||
id: "chaos_fragment",
|
||||
name: "Chaos Fragment",
|
||||
rarity: "common",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment from when something was being made here. What was being made is unclear. Something important, probably.",
|
||||
id: "creation_shard",
|
||||
name: "Creation Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The raw stuff of creation, before it became anything specific. Handle with care. It wants to become things.",
|
||||
id: "primordial_essence",
|
||||
name: "Primordial Essence",
|
||||
rarity: "rare",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
|
||||
// Zone 14: infinite_expanse
|
||||
{
|
||||
description:
|
||||
"Gathered from somewhere in the expanse. Direction is uncertain. Distance from the collection point is uncertain.",
|
||||
id: "expanse_dust",
|
||||
name: "Expanse Dust",
|
||||
rarity: "common",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that contains compressed distance. It weighs more than its size suggests. Much more. Do not drop it.",
|
||||
id: "distance_crystal",
|
||||
name: "Distance Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of the expanse's edge, which the expanse does not technically have. This should not exist. It does anyway.",
|
||||
id: "infinity_shard",
|
||||
name: "Infinity Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
description:
|
||||
"Ash from the forge's fires. Contains fragments of unrealised realities that never quite made it to existence.",
|
||||
id: "forge_ash",
|
||||
name: "Forge Ash",
|
||||
rarity: "common",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A worn tool left by whatever worked here before your universe existed. Still functional in ways that are difficult to explain.",
|
||||
id: "creation_tool",
|
||||
name: "Creation Tool",
|
||||
rarity: "uncommon",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A flawed reality, discarded by the forge as below standard. Still contains everything a universe needs.",
|
||||
id: "reality_shard",
|
||||
name: "Reality Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
|
||||
// Zone 16: cosmic_maelstrom
|
||||
{
|
||||
description:
|
||||
"Debris from a galaxy that got too close to the maelstrom. Compressed to a size your guild can actually carry.",
|
||||
id: "maelstrom_debris",
|
||||
name: "Maelstrom Debris",
|
||||
rarity: "common",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that formed at the intersection of several fundamental forces that should never have been in the same place.",
|
||||
id: "force_crystal",
|
||||
name: "Force Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment from the maelstrom's eye. Impossibly calm. Whatever is at the centre has been there since the beginning.",
|
||||
id: "cosmic_fragment",
|
||||
name: "Cosmic Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
description:
|
||||
"Dust from the oldest place. Has been here since before the concept of 'here' had been invented.",
|
||||
id: "ancient_dust",
|
||||
name: "Ancient Dust",
|
||||
rarity: "common",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A shard of something that remembers the moment before the first moment. The memory is in the material itself.",
|
||||
id: "memory_shard",
|
||||
name: "Memory Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An artefact from the first thing to exist in this place. What it did is unknown. That it mattered is beyond doubt.",
|
||||
id: "primeval_relic",
|
||||
name: "Primeval Relic",
|
||||
rarity: "rare",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
|
||||
// Zone 18: the_absolute
|
||||
{
|
||||
description:
|
||||
"A fragment of the final truth. It is difficult to look at directly, and impossible to look away from once you start.",
|
||||
id: "absolute_fragment",
|
||||
name: "Absolute Fragment",
|
||||
rarity: "common",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"From the edge of everything. On one side: everything. On the other: nothing. This is from the very boundary between them.",
|
||||
id: "boundary_shard",
|
||||
name: "Boundary Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The last crystal. After this, there are no more. It knows this. You can tell from the way it sits in your hand.",
|
||||
id: "omega_crystal",
|
||||
name: "Omega Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
];
|
||||
@@ -1,216 +1,250 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { PrestigeUpgrade } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_PRESTIGE_UPGRADES: PrestigeUpgrade[] = [
|
||||
export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
||||
// ── Global Income Tiers ───────────────────────────────────────────────────
|
||||
{
|
||||
id: "income_1",
|
||||
name: "Runestone Blessing I",
|
||||
description: "The first runestone awakens dormant power in your guild. All production ×1.25.",
|
||||
category: "income",
|
||||
description:
|
||||
"The first runestone awakens dormant power in your guild. All production ×1.25.",
|
||||
id: "income_1",
|
||||
multiplier: 1.25,
|
||||
name: "Runestone Blessing I",
|
||||
runestonesCost: 10,
|
||||
multiplier: 1.25,
|
||||
},
|
||||
{
|
||||
id: "income_2",
|
||||
name: "Runestone Blessing II",
|
||||
description: "Deeper runestone resonance amplifies your workforce. All production ×1.5.",
|
||||
category: "income",
|
||||
description:
|
||||
"Deeper runestone resonance amplifies your workforce. All production ×1.5.",
|
||||
id: "income_2",
|
||||
multiplier: 1.5,
|
||||
name: "Runestone Blessing II",
|
||||
runestonesCost: 25,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
{
|
||||
id: "income_3",
|
||||
name: "Runestone Blessing III",
|
||||
description: "The runes sing with accumulated wisdom. All production ×2.",
|
||||
category: "income",
|
||||
category: "income",
|
||||
description: "The runes sing with accumulated wisdom. All production ×2.",
|
||||
id: "income_3",
|
||||
multiplier: 2,
|
||||
name: "Runestone Blessing III",
|
||||
runestonesCost: 60,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "income_4",
|
||||
name: "Runic Surge I",
|
||||
description: "Runestone energy surges through your guild's operations. All production ×3.",
|
||||
category: "income",
|
||||
description:
|
||||
"Runestone energy surges through your guild's operations. All production ×3.",
|
||||
id: "income_4",
|
||||
multiplier: 3,
|
||||
name: "Runic Surge I",
|
||||
runestonesCost: 150,
|
||||
multiplier: 3,
|
||||
},
|
||||
{
|
||||
id: "income_5",
|
||||
name: "Runic Surge II",
|
||||
description: "The surge intensifies, pushing limits thought impossible. All production ×5.",
|
||||
category: "income",
|
||||
description:
|
||||
"The surge intensifies, pushing limits thought impossible. All production ×5.",
|
||||
id: "income_5",
|
||||
multiplier: 5,
|
||||
name: "Runic Surge II",
|
||||
runestonesCost: 350,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "income_6",
|
||||
name: "Runic Surge III",
|
||||
description: "An overwhelming tide of runic energy floods your operations. All production ×10.",
|
||||
category: "income",
|
||||
description:
|
||||
"An overwhelming tide of runic energy floods your operations. All production ×10.",
|
||||
id: "income_6",
|
||||
multiplier: 10,
|
||||
name: "Runic Surge III",
|
||||
runestonesCost: 800,
|
||||
multiplier: 10,
|
||||
},
|
||||
{
|
||||
id: "income_7",
|
||||
name: "Ancient Inscription I",
|
||||
category: "income",
|
||||
description:
|
||||
"You decipher ancient runic inscriptions that unlock vast potential. All production ×25.",
|
||||
category: "income",
|
||||
runestonesCost: 2_000,
|
||||
multiplier: 25,
|
||||
id: "income_7",
|
||||
multiplier: 25,
|
||||
name: "Ancient Inscription I",
|
||||
runestonesCost: 2000,
|
||||
},
|
||||
{
|
||||
id: "income_8",
|
||||
name: "Ancient Inscription II",
|
||||
description: "Deeper inscriptions reveal secrets of primordial power. All production ×50.",
|
||||
category: "income",
|
||||
runestonesCost: 5_000,
|
||||
multiplier: 50,
|
||||
description:
|
||||
"Deeper inscriptions reveal secrets of primordial power. All production ×50.",
|
||||
id: "income_8",
|
||||
multiplier: 50,
|
||||
name: "Ancient Inscription II",
|
||||
runestonesCost: 5000,
|
||||
},
|
||||
{
|
||||
id: "income_9",
|
||||
name: "Ancient Inscription III",
|
||||
description: "The full inscription blazes with world-shaping power. All production ×100.",
|
||||
category: "income",
|
||||
description:
|
||||
"The full inscription blazes with world-shaping power. All production ×100.",
|
||||
id: "income_9",
|
||||
multiplier: 100,
|
||||
name: "Ancient Inscription III",
|
||||
runestonesCost: 12_000,
|
||||
multiplier: 100,
|
||||
},
|
||||
{
|
||||
id: "income_10",
|
||||
name: "Eternal Rune I",
|
||||
description:
|
||||
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
|
||||
category: "income",
|
||||
description:
|
||||
"The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
|
||||
id: "income_10",
|
||||
multiplier: 200,
|
||||
name: "Eternal Rune I",
|
||||
runestonesCost: 30_000,
|
||||
multiplier: 500,
|
||||
},
|
||||
{
|
||||
id: "income_11",
|
||||
name: "Eternal Rune II",
|
||||
description:
|
||||
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
|
||||
category: "income",
|
||||
description:
|
||||
"Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
|
||||
id: "income_11",
|
||||
multiplier: 500,
|
||||
name: "Eternal Rune II",
|
||||
runestonesCost: 80_000,
|
||||
multiplier: 1_000,
|
||||
},
|
||||
// ── Click Power ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "click_power_1",
|
||||
name: "Runic Strike I",
|
||||
description: "Infuse your personal strikes with runestone energy. Click power ×2.",
|
||||
category: "click",
|
||||
description:
|
||||
"Infuse your personal strikes with runestone energy. Click power ×2.",
|
||||
id: "click_power_1",
|
||||
multiplier: 2,
|
||||
name: "Runic Strike I",
|
||||
runestonesCost: 15,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "click_power_2",
|
||||
name: "Runic Strike II",
|
||||
description: "Your strikes crackle with compounded runic force. Click power ×5.",
|
||||
category: "click",
|
||||
description:
|
||||
"Your strikes crackle with compounded runic force. Click power ×5.",
|
||||
id: "click_power_2",
|
||||
multiplier: 5,
|
||||
name: "Runic Strike II",
|
||||
runestonesCost: 75,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "click_power_3",
|
||||
name: "Runic Strike III",
|
||||
description: "Every click channels the weight of all your past lives. Click power ×20.",
|
||||
category: "click",
|
||||
description:
|
||||
"Every click channels the weight of all your past lives. Click power ×20.",
|
||||
id: "click_power_3",
|
||||
multiplier: 20,
|
||||
name: "Runic Strike III",
|
||||
runestonesCost: 400,
|
||||
multiplier: 20,
|
||||
},
|
||||
{
|
||||
id: "click_power_4",
|
||||
name: "World-Breaker Click",
|
||||
description: "A single click now carries the force of a falling empire. Click power ×100.",
|
||||
category: "click",
|
||||
runestonesCost: 2_500,
|
||||
multiplier: 100,
|
||||
description:
|
||||
"A single click now carries the force of a falling empire. Click power ×100.",
|
||||
id: "click_power_4",
|
||||
multiplier: 100,
|
||||
name: "World-Breaker Click",
|
||||
runestonesCost: 2500,
|
||||
},
|
||||
// ── Essence Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "essence_1",
|
||||
name: "Essence Attunement I",
|
||||
description: "Runestone resonance amplifies your essence gathering. Essence production ×2.",
|
||||
category: "essence",
|
||||
description:
|
||||
"Runestone resonance amplifies your essence gathering. Essence production ×2.",
|
||||
id: "essence_1",
|
||||
multiplier: 2,
|
||||
name: "Essence Attunement I",
|
||||
runestonesCost: 20,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "essence_2",
|
||||
name: "Essence Attunement II",
|
||||
description: "Deep attunement draws essence from previously invisible sources. Essence production ×5.",
|
||||
category: "essence",
|
||||
description:
|
||||
"Deep attunement draws essence from previously invisible sources. Essence production ×5.",
|
||||
id: "essence_2",
|
||||
multiplier: 5,
|
||||
name: "Essence Attunement II",
|
||||
runestonesCost: 120,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "essence_3",
|
||||
name: "Essence Attunement III",
|
||||
description: "Your guild breathes essence as naturally as air. Essence production ×20.",
|
||||
category: "essence",
|
||||
description:
|
||||
"Your guild breathes essence as naturally as air. Essence production ×20.",
|
||||
id: "essence_3",
|
||||
multiplier: 20,
|
||||
name: "Essence Attunement III",
|
||||
runestonesCost: 700,
|
||||
multiplier: 20,
|
||||
},
|
||||
{
|
||||
id: "essence_4",
|
||||
name: "Essence Attunement IV",
|
||||
description: "Essence flows in torrents from every corner of every world. Essence production ×100.",
|
||||
category: "essence",
|
||||
runestonesCost: 4_000,
|
||||
multiplier: 100,
|
||||
description:
|
||||
"Essence flows in torrents from every corner of every world. Essence production ×100.",
|
||||
id: "essence_4",
|
||||
multiplier: 100,
|
||||
name: "Essence Attunement IV",
|
||||
runestonesCost: 4000,
|
||||
},
|
||||
// ── Crystal Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "crystal_1",
|
||||
name: "Crystal Resonance I",
|
||||
description: "Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
|
||||
category: "crystals",
|
||||
description:
|
||||
"Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
|
||||
id: "crystal_1",
|
||||
multiplier: 2,
|
||||
name: "Crystal Resonance I",
|
||||
runestonesCost: 30,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "crystal_2",
|
||||
name: "Crystal Resonance II",
|
||||
description: "The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
|
||||
category: "crystals",
|
||||
description:
|
||||
"The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
|
||||
id: "crystal_2",
|
||||
multiplier: 5,
|
||||
name: "Crystal Resonance II",
|
||||
runestonesCost: 200,
|
||||
multiplier: 5,
|
||||
},
|
||||
{
|
||||
id: "crystal_3",
|
||||
name: "Crystal Resonance III",
|
||||
description: "Pure resonance crystallises reality into abundance. Crystal rewards ×25.",
|
||||
category: "crystals",
|
||||
runestonesCost: 1_200,
|
||||
multiplier: 25,
|
||||
description:
|
||||
"Pure resonance crystallises reality into abundance. Crystal rewards ×25.",
|
||||
id: "crystal_3",
|
||||
multiplier: 25,
|
||||
name: "Crystal Resonance III",
|
||||
runestonesCost: 1200,
|
||||
},
|
||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||
{
|
||||
id: "auto_prestige",
|
||||
name: "Autonomous Ascension",
|
||||
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",
|
||||
description:
|
||||
"Unlock the Auto-Prestige toggle. When enabled, you will automatically ascend the moment you reach the prestige threshold — using your current character name.",
|
||||
category: "utility",
|
||||
id: "auto_prestige",
|
||||
multiplier: 1,
|
||||
name: "Autonomous Ascension",
|
||||
runestonesCost: 100,
|
||||
multiplier: 1,
|
||||
},
|
||||
// ── Runestone Meta-Upgrade ────────────────────────────────────────────────
|
||||
{
|
||||
id: "runestone_gain_1",
|
||||
name: "Runic Legacy",
|
||||
category: "runestones",
|
||||
description:
|
||||
"Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
id: "runestone_gain_1",
|
||||
multiplier: 1.25,
|
||||
name: "Runic Legacy",
|
||||
runestonesCost: 50,
|
||||
multiplier: 1.25,
|
||||
},
|
||||
{
|
||||
id: "runestone_gain_2",
|
||||
name: "Eternal Legacy",
|
||||
category: "runestones",
|
||||
description:
|
||||
"Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
id: "runestone_gain_2",
|
||||
multiplier: 1.5,
|
||||
name: "Eternal Legacy",
|
||||
runestonesCost: 500,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
];
|
||||
|
||||
+922
-835
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { CraftingRecipe } from "@elysium/types";
|
||||
|
||||
export const defaultRecipes: Array<CraftingRecipe> = [
|
||||
// Zone 1: verdant_vale
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.05 },
|
||||
description:
|
||||
"Sap from ancient heartwood trees, refined and bound with forest crystal. The resulting tincture accelerates the flow of wealth through your guild in ways the alchemists cannot fully explain.",
|
||||
id: "heartwood_tincture",
|
||||
name: "Heartwood Tincture",
|
||||
requiredMaterials: [
|
||||
{ materialId: "verdant_sap", quantity: 5 },
|
||||
{ materialId: "forest_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.08 },
|
||||
description:
|
||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
||||
id: "elder_bark_shield",
|
||||
name: "Elder Bark Shield",
|
||||
requiredMaterials: [
|
||||
{ materialId: "elder_bark", quantity: 2 },
|
||||
{ materialId: "verdant_sap", quantity: 8 },
|
||||
],
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
|
||||
// Zone 2: shattered_ruins
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.05 },
|
||||
description:
|
||||
"The ruin dust and cursed fragments, carefully worked into a binding that borrows the essence-drawing power of the fallen civilisation's final enchantments.",
|
||||
id: "runic_binding",
|
||||
name: "Runic Binding",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ruin_dust", quantity: 8 },
|
||||
{ materialId: "cursed_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.08 },
|
||||
description:
|
||||
"A charm set with a chip of the elder dragon's scale. The dragon would be furious if he knew. He would also be impressed.",
|
||||
id: "dragon_scale_charm",
|
||||
name: "Dragon Scale Charm",
|
||||
requiredMaterials: [
|
||||
{ materialId: "dragonscale_chip", quantity: 2 },
|
||||
{ materialId: "ruin_dust", quantity: 10 },
|
||||
],
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
|
||||
// Zone 3: frozen_peaks
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.08 },
|
||||
description:
|
||||
"Glacial ice ground and shaped into a lens that clarifies and focuses. Holding it, your guild's actions become sharper, more precise, more effective per motion.",
|
||||
id: "glacial_lens",
|
||||
name: "Glacial Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "glacial_ice", quantity: 8 },
|
||||
{ materialId: "frost_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description:
|
||||
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
|
||||
id: "void_fragment_amulet",
|
||||
name: "Void Fragment Amulet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_shard", quantity: 2 },
|
||||
{ materialId: "frost_crystal", quantity: 6 },
|
||||
],
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
|
||||
// Zone 4: shadow_marshes
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.08 },
|
||||
description:
|
||||
"Marsh roots processed with shadow essence into a refined compound that somehow makes the essence of things flow more freely toward your guild hall.",
|
||||
id: "shadow_extract",
|
||||
name: "Shadow Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "marsh_root", quantity: 8 },
|
||||
{ materialId: "shadow_essence", quantity: 4 },
|
||||
],
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
description:
|
||||
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
||||
id: "cursed_focus",
|
||||
name: "Cursed Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "cursed_bone", quantity: 2 },
|
||||
{ materialId: "shadow_essence", quantity: 6 },
|
||||
],
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
|
||||
// Zone 5: volcanic_depths
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description:
|
||||
"A seal forged in the volcanic depths, using the eternal heat of the magma stone and ember crystal to create something that burns wealth into existence continuously.",
|
||||
id: "magma_core_seal",
|
||||
name: "Magma Core Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "magma_stone", quantity: 8 },
|
||||
{ materialId: "ember_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.12 },
|
||||
description:
|
||||
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
||||
id: "elemental_ore_ingot",
|
||||
name: "Elemental Ore Ingot",
|
||||
requiredMaterials: [
|
||||
{ materialId: "legendary_ore", quantity: 2 },
|
||||
{ materialId: "magma_stone", quantity: 10 },
|
||||
],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
|
||||
// Zone 6: astral_void
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.12 },
|
||||
description:
|
||||
"Stardust arranged along astral threads into a map of the void that somehow, impossibly, shows your guild where to press and how to press it for maximum effect.",
|
||||
id: "star_chart",
|
||||
name: "Star Chart",
|
||||
requiredMaterials: [
|
||||
{ materialId: "stardust", quantity: 10 },
|
||||
{ materialId: "astral_thread", quantity: 4 },
|
||||
],
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.12 },
|
||||
description:
|
||||
"A void crystal suspended in a matrix of stardust — something that exists in several places simultaneously and draws gold from all of them at once.",
|
||||
id: "void_crystal_matrix",
|
||||
name: "Void Crystal Matrix",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_crystal", quantity: 2 },
|
||||
{ materialId: "stardust", quantity: 12 },
|
||||
],
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
|
||||
// Zone 7: celestial_reaches
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.12 },
|
||||
description:
|
||||
"Celestial dust and divine fragments ground into a lens that sees the essence in all things and draws a portion of it — gently, as the celestials would prefer.",
|
||||
id: "celestial_lens",
|
||||
name: "Celestial Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_dust", quantity: 10 },
|
||||
{ materialId: "divine_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description:
|
||||
"A choir shard set in divine fragments, still humming with the celestial harmonic. The resonance makes gold flow in its direction — not compelled, simply invited.",
|
||||
id: "choir_resonator",
|
||||
name: "Choir Resonator",
|
||||
requiredMaterials: [
|
||||
{ materialId: "choir_shard", quantity: 2 },
|
||||
{ materialId: "divine_fragment", quantity: 6 },
|
||||
],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
description:
|
||||
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
||||
id: "pressure_forged_core",
|
||||
name: "Pressure-Forged Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "trench_coral", quantity: 10 },
|
||||
{ materialId: "pressure_gem", quantity: 4 },
|
||||
],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.15 },
|
||||
description:
|
||||
"A talisman set with the ancient tooth, suspended in trench coral carvings. Your party fights differently with this at their chest. More deliberately. More completely.",
|
||||
id: "ancient_fang_talisman",
|
||||
name: "Ancient Fang Talisman",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ancient_tooth", quantity: 2 },
|
||||
{ materialId: "trench_coral", quantity: 12 },
|
||||
],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
|
||||
// Zone 9: infernal_court
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description:
|
||||
"A seal of infernal court authority, forged from brimstone and ichor. The court doesn't know you have this. It's better that way. It does make trade extremely efficient.",
|
||||
id: "court_seal",
|
||||
name: "Court Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "brimstone_flake", quantity: 10 },
|
||||
{ materialId: "demon_ichor", quantity: 5 },
|
||||
],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.15 },
|
||||
description:
|
||||
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
|
||||
id: "soul_bound_catalyst",
|
||||
name: "Soul-Bound Catalyst",
|
||||
requiredMaterials: [
|
||||
{ materialId: "soul_residue", quantity: 2 },
|
||||
{ materialId: "demon_ichor", quantity: 8 },
|
||||
],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
|
||||
// Zone 10: crystalline_spire
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.18 },
|
||||
description:
|
||||
"Prism dust and calculation shards assembled into an array that the spire's intelligence would call elegant, if it had aesthetic preferences, which it might.",
|
||||
id: "prism_array",
|
||||
name: "Prism Array",
|
||||
requiredMaterials: [
|
||||
{ materialId: "prism_dust", quantity: 10 },
|
||||
{ materialId: "calculation_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.18 },
|
||||
description:
|
||||
"A possibility crystal contained within a calculation shard framework. It runs through every possible outcome of every guild action and finds the one with the highest gold yield.",
|
||||
id: "possibility_engine",
|
||||
name: "Possibility Engine",
|
||||
requiredMaterials: [
|
||||
{ materialId: "possibility_crystal", quantity: 2 },
|
||||
{ materialId: "calculation_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.18 },
|
||||
description:
|
||||
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
||||
id: "null_field_generator",
|
||||
name: "Null Field Generator",
|
||||
requiredMaterials: [
|
||||
{ materialId: "null_matter", quantity: 10 },
|
||||
{ materialId: "resonance_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.18 },
|
||||
description:
|
||||
"A sanctum core and resonance fragments shaped into a key to something. The essence flows through it like it was designed to carry essence, which it may have been.",
|
||||
id: "sanctum_key",
|
||||
name: "Sanctum Key",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sanctum_core", quantity: 2 },
|
||||
{ materialId: "resonance_fragment", quantity: 6 },
|
||||
],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
|
||||
// Zone 12: eternal_throne
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.2 },
|
||||
description:
|
||||
"Throne dust pressed into throne dust-lacquered crown fragments, shaped into a circlet. Wearing it — metaphorically — makes gold accumulate with the inevitability of authority.",
|
||||
id: "crown_circlet",
|
||||
name: "Crown Circlet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "throne_dust", quantity: 10 },
|
||||
{ materialId: "crown_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
||||
id: "eternity_bound_ring",
|
||||
name: "Eternity-Bound Ring",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternity_splinter", quantity: 2 },
|
||||
{ materialId: "crown_fragment", quantity: 6 },
|
||||
],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
|
||||
// Zone 13: primordial_chaos
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.2 },
|
||||
description:
|
||||
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
||||
id: "chaos_lens",
|
||||
name: "Chaos Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "chaos_fragment", quantity: 10 },
|
||||
{ materialId: "creation_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.22 },
|
||||
description:
|
||||
"Primordial essence held in a creation shard framework. It hums constantly. Gold flows toward it with the enthusiasm of something that wants to become something.",
|
||||
id: "creation_core",
|
||||
name: "Creation Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "primordial_essence", quantity: 2 },
|
||||
{ materialId: "creation_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
|
||||
// Zone 14: infinite_expanse
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.2 },
|
||||
description:
|
||||
"Expanse dust wound around distance crystals into a coil that draws essence from distances too vast to measure, compressing it into something your guild can actually use.",
|
||||
id: "distance_coil",
|
||||
name: "Distance Coil",
|
||||
requiredMaterials: [
|
||||
{ materialId: "expanse_dust", quantity: 10 },
|
||||
{ materialId: "distance_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.22 },
|
||||
description:
|
||||
"An infinity shard mounted in a distance crystal frame. The prism reflects gold from an infinite number of directions simultaneously. The math works out favourably.",
|
||||
id: "infinity_prism",
|
||||
name: "Infinity Prism",
|
||||
requiredMaterials: [
|
||||
{ materialId: "infinity_shard", quantity: 2 },
|
||||
{ materialId: "distance_crystal", quantity: 6 },
|
||||
],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.22 },
|
||||
description:
|
||||
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
||||
id: "reality_ingot",
|
||||
name: "Reality Ingot",
|
||||
requiredMaterials: [
|
||||
{ materialId: "forge_ash", quantity: 10 },
|
||||
{ materialId: "creation_tool", quantity: 4 },
|
||||
],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.22 },
|
||||
description:
|
||||
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
||||
id: "universe_seed",
|
||||
name: "Universe Seed",
|
||||
requiredMaterials: [
|
||||
{ materialId: "reality_shard", quantity: 2 },
|
||||
{ materialId: "creation_tool", quantity: 6 },
|
||||
],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
|
||||
// Zone 16: cosmic_maelstrom
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.25 },
|
||||
description:
|
||||
"Maelstrom debris and force crystals ground into a lens at the intersection of fundamental forces. Gold flows toward it with the same inevitability that galaxies flow toward gravity.",
|
||||
id: "force_lens",
|
||||
name: "Force Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "maelstrom_debris", quantity: 10 },
|
||||
{ materialId: "force_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.22 },
|
||||
description:
|
||||
"A cosmic fragment suspended in a force crystal matrix — a piece of the maelstrom's impossible calm, holding the eye of the storm. Essence accumulates in its vicinity.",
|
||||
id: "maelstrom_eye",
|
||||
name: "Maelstrom Eye",
|
||||
requiredMaterials: [
|
||||
{ materialId: "cosmic_fragment", quantity: 2 },
|
||||
{ materialId: "force_crystal", quantity: 6 },
|
||||
],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description:
|
||||
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
||||
id: "ancient_memory_array",
|
||||
name: "Ancient Memory Array",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ancient_dust", quantity: 10 },
|
||||
{ materialId: "memory_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.25 },
|
||||
description:
|
||||
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
||||
id: "first_artefact",
|
||||
name: "First Artefact",
|
||||
requiredMaterials: [
|
||||
{ materialId: "primeval_relic", quantity: 2 },
|
||||
{ materialId: "memory_shard", quantity: 6 },
|
||||
],
|
||||
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
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
description:
|
||||
"Absolute fragments and boundary shards ground into a lens that sees to the end of all things — and in seeing, draws the wealth inherent in finality toward your guild.",
|
||||
id: "final_truth_lens",
|
||||
name: "Final Truth Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "absolute_fragment", quantity: 10 },
|
||||
{ materialId: "boundary_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description:
|
||||
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
||||
id: "omega_convergence",
|
||||
name: "Omega Convergence",
|
||||
requiredMaterials: [
|
||||
{ materialId: "omega_crystal", quantity: 2 },
|
||||
{ materialId: "boundary_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @file Schema version tracking for game state.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
|
||||
*/
|
||||
export const currentSchemaVersion = 1;
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file Game title definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { Title } from "@elysium/types";
|
||||
|
||||
export const gameTitles: Array<Title> = [
|
||||
// Quest milestones
|
||||
{
|
||||
condition: { amount: 1, type: "questsCompleted" },
|
||||
description: "Complete your first quest.",
|
||||
id: "the_adventurous",
|
||||
name: "The Adventurous",
|
||||
},
|
||||
{
|
||||
condition: { amount: 100, type: "questsCompleted" },
|
||||
description: "Complete 100 quests in a single run.",
|
||||
id: "the_persistent",
|
||||
name: "The Persistent",
|
||||
},
|
||||
// Boss milestones
|
||||
{
|
||||
condition: { amount: 1, type: "bossesDefeated" },
|
||||
description: "Defeat your first boss.",
|
||||
id: "boss_slayer",
|
||||
name: "Boss Slayer",
|
||||
},
|
||||
{
|
||||
condition: { amount: 10, type: "bossesDefeated" },
|
||||
description: "Defeat 10 bosses in a single run.",
|
||||
id: "dungeon_master",
|
||||
name: "Dungeon Master",
|
||||
},
|
||||
// Gold milestones
|
||||
{
|
||||
condition: { amount: 1_000_000, type: "totalGoldEarned" },
|
||||
description: "Earn 1,000,000 gold in a single run.",
|
||||
id: "the_wealthy",
|
||||
name: "The Wealthy",
|
||||
},
|
||||
{
|
||||
condition: { amount: 1_000_000_000, type: "totalGoldEarned" },
|
||||
description: "Earn 1,000,000,000 gold in a single run.",
|
||||
id: "the_rich",
|
||||
name: "The Rich",
|
||||
},
|
||||
// Click milestones
|
||||
{
|
||||
condition: { amount: 10_000, type: "totalClicks" },
|
||||
description: "Click the Guild Hall 10,000 times in a single run.",
|
||||
id: "click_maniac",
|
||||
name: "Click Maniac",
|
||||
},
|
||||
// Adventurer milestones
|
||||
{
|
||||
condition: { amount: 100, type: "adventurerTotal" },
|
||||
description: "Recruit 100 adventurers.",
|
||||
id: "commander",
|
||||
name: "Commander",
|
||||
},
|
||||
{
|
||||
condition: { amount: 1000, type: "adventurerTotal" },
|
||||
description: "Recruit 1,000 adventurers.",
|
||||
id: "warlord",
|
||||
name: "Warlord",
|
||||
},
|
||||
// Social
|
||||
{
|
||||
condition: { type: "guildFounded" },
|
||||
description: "Give your guild a name.",
|
||||
id: "guild_founder",
|
||||
name: "Guild Founder",
|
||||
},
|
||||
// Prestige milestones
|
||||
{
|
||||
condition: { amount: 1, type: "prestigeCount" },
|
||||
description: "Achieve your first Prestige.",
|
||||
id: "the_undying",
|
||||
name: "The Undying",
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "prestigeCount" },
|
||||
description: "Achieve 5 Prestiges.",
|
||||
id: "battle_hardened",
|
||||
name: "Battle Hardened",
|
||||
},
|
||||
{
|
||||
condition: { amount: 25, type: "prestigeCount" },
|
||||
description: "Achieve 25 Prestiges.",
|
||||
id: "legend",
|
||||
name: "Legend",
|
||||
},
|
||||
// Transcendence milestones
|
||||
{
|
||||
condition: { amount: 1, type: "transcendenceCount" },
|
||||
description: "Achieve your first Transcendence.",
|
||||
id: "transcendent",
|
||||
name: "Transcendent",
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "transcendenceCount" },
|
||||
description: "Achieve 5 Transcendences.",
|
||||
id: "beyond_mortal",
|
||||
name: "Beyond Mortal",
|
||||
},
|
||||
// Apotheosis milestones
|
||||
{
|
||||
condition: { amount: 1, type: "apotheosisCount" },
|
||||
description: "Achieve your first Apotheosis.",
|
||||
id: "apotheosised",
|
||||
name: "Apotheosised",
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "apotheosisCount" },
|
||||
description: "Achieve 5 Apotheoses.",
|
||||
id: "ascendant",
|
||||
name: "Ascendant",
|
||||
},
|
||||
// Achievement milestone
|
||||
{
|
||||
condition: { amount: 40, type: "achievementsUnlocked" },
|
||||
description: "Unlock all achievements.",
|
||||
id: "completionist",
|
||||
name: "Completionist",
|
||||
},
|
||||
// Longevity
|
||||
{
|
||||
condition: { amount: 30, type: "playedDays" },
|
||||
description: "Play Elysium for 30 days.",
|
||||
id: "veteran",
|
||||
name: "Veteran",
|
||||
},
|
||||
{
|
||||
condition: { amount: 365, type: "playedDays" },
|
||||
description: "Play Elysium for a full year.",
|
||||
id: "timeless",
|
||||
name: "Timeless",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { TranscendenceUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
||||
// ── Income multipliers ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "income",
|
||||
cost: 5,
|
||||
description:
|
||||
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
||||
id: "echo_income_1",
|
||||
multiplier: 1.25,
|
||||
name: "Whisper of Power",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 10,
|
||||
description:
|
||||
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
||||
id: "echo_income_2",
|
||||
multiplier: 1.5,
|
||||
name: "Resonance",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 20,
|
||||
description:
|
||||
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
||||
id: "echo_income_3",
|
||||
multiplier: 2,
|
||||
name: "Harmonic Surge",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 40,
|
||||
description:
|
||||
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
||||
id: "echo_income_4",
|
||||
multiplier: 3,
|
||||
name: "Ethereal Overflow",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 80,
|
||||
description:
|
||||
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
||||
id: "echo_income_5",
|
||||
multiplier: 5,
|
||||
name: "Infinite Chorus",
|
||||
},
|
||||
|
||||
// ── Combat multipliers ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
cost: 5,
|
||||
description:
|
||||
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
||||
id: "echo_combat_1",
|
||||
multiplier: 1.25,
|
||||
name: "Battle-Hardened",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 15,
|
||||
description:
|
||||
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
||||
id: "echo_combat_2",
|
||||
multiplier: 1.5,
|
||||
name: "Veteran's Edge",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 35,
|
||||
description:
|
||||
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
||||
id: "echo_combat_3",
|
||||
multiplier: 2,
|
||||
name: "Transcendent Warrior",
|
||||
},
|
||||
|
||||
// ── Prestige threshold reductions ──────────────────────────────────────────
|
||||
{
|
||||
category: "prestige_threshold",
|
||||
cost: 8,
|
||||
description:
|
||||
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
||||
id: "echo_prestige_threshold_1",
|
||||
multiplier: 0.9,
|
||||
name: "Accelerated Path",
|
||||
},
|
||||
{
|
||||
category: "prestige_threshold",
|
||||
cost: 20,
|
||||
description:
|
||||
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
||||
id: "echo_prestige_threshold_2",
|
||||
multiplier: 0.8,
|
||||
name: "Shortcut Through Time",
|
||||
},
|
||||
|
||||
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
||||
{
|
||||
category: "prestige_runestones",
|
||||
cost: 8,
|
||||
description:
|
||||
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
||||
id: "echo_prestige_runestones_1",
|
||||
multiplier: 1.5,
|
||||
name: "Runic Attunement",
|
||||
},
|
||||
{
|
||||
category: "prestige_runestones",
|
||||
cost: 20,
|
||||
description:
|
||||
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
||||
id: "echo_prestige_runestones_2",
|
||||
multiplier: 2,
|
||||
name: "Master Runesmith",
|
||||
},
|
||||
|
||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 50,
|
||||
description:
|
||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||
id: "echo_meta_1",
|
||||
multiplier: 1.25,
|
||||
name: "Resonant Awakening",
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 150,
|
||||
description:
|
||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||
id: "echo_meta_2",
|
||||
multiplier: 1.5,
|
||||
name: "Transcendent Loop",
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 400,
|
||||
description:
|
||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||
id: "echo_meta_3",
|
||||
multiplier: 2,
|
||||
name: "Infinite Spiral",
|
||||
},
|
||||
];
|
||||
+686
-534
File diff suppressed because it is too large
Load Diff
+98
-91
@@ -1,184 +1,191 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { Zone } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ZONES: Zone[] = [
|
||||
export const defaultZones: Array<Zone> = [
|
||||
{
|
||||
id: "verdant_vale",
|
||||
name: "The Verdant Vale",
|
||||
description:
|
||||
"Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.",
|
||||
emoji: "🌿",
|
||||
status: "unlocked",
|
||||
unlockBossId: null,
|
||||
emoji: "🌿",
|
||||
id: "verdant_vale",
|
||||
name: "The Verdant Vale",
|
||||
status: "unlocked",
|
||||
unlockBossId: null,
|
||||
unlockQuestId: null,
|
||||
},
|
||||
{
|
||||
id: "shattered_ruins",
|
||||
name: "The Shattered Ruins",
|
||||
description:
|
||||
"The remnants of a civilisation long lost to war and dragonfire. Crumbling towers and cursed lakes hide treasures — and an elder dragon who claims these lands as his own.",
|
||||
emoji: "🏛️",
|
||||
status: "locked",
|
||||
unlockBossId: "forest_giant",
|
||||
emoji: "🏛️",
|
||||
id: "shattered_ruins",
|
||||
name: "The Shattered Ruins",
|
||||
status: "locked",
|
||||
unlockBossId: "forest_giant",
|
||||
unlockQuestId: "ancient_ruins",
|
||||
},
|
||||
{
|
||||
id: "frozen_peaks",
|
||||
name: "The Frozen Peaks",
|
||||
description:
|
||||
"At the edge of the world, where the sun barely rises and the cold is a living thing, a tear in reality has drawn something ancient and terrible. Only the mightiest guilds dare tread here.",
|
||||
emoji: "❄️",
|
||||
status: "locked",
|
||||
unlockBossId: "elder_dragon",
|
||||
emoji: "❄️",
|
||||
id: "frozen_peaks",
|
||||
name: "The Frozen Peaks",
|
||||
status: "locked",
|
||||
unlockBossId: "elder_dragon",
|
||||
unlockQuestId: "dragon_lair",
|
||||
},
|
||||
{
|
||||
id: "shadow_marshes",
|
||||
name: "The Shadow Marshes",
|
||||
description:
|
||||
"A vast, fog-choked wetland where the sun never fully rises. Dark magic seeps from the earth itself, and things far older than the kingdom lurk beneath the murky waters.",
|
||||
emoji: "🌑",
|
||||
status: "locked",
|
||||
unlockBossId: "void_titan",
|
||||
emoji: "🌑",
|
||||
id: "shadow_marshes",
|
||||
name: "The Shadow Marshes",
|
||||
status: "locked",
|
||||
unlockBossId: "void_titan",
|
||||
unlockQuestId: "storm_citadel",
|
||||
},
|
||||
{
|
||||
id: "volcanic_depths",
|
||||
name: "The Volcanic Depths",
|
||||
description:
|
||||
"A chain of active volcanoes whose caverns plunge deep into the earth's molten heart. Legendary forges burn here, tended by fire elementals who serve no master — yet.",
|
||||
emoji: "🌋",
|
||||
status: "locked",
|
||||
unlockBossId: "mud_kraken",
|
||||
emoji: "🌋",
|
||||
id: "volcanic_depths",
|
||||
name: "The Volcanic Depths",
|
||||
status: "locked",
|
||||
unlockBossId: "mud_kraken",
|
||||
unlockQuestId: "plague_ruins",
|
||||
},
|
||||
{
|
||||
id: "astral_void",
|
||||
name: "The Astral Void",
|
||||
description:
|
||||
"Beyond the veil of the mortal world lies a realm of pure possibility and absolute terror. Stars are born and die here in moments, and the beings that call this place home have never known mortality.",
|
||||
emoji: "🌌",
|
||||
status: "locked",
|
||||
unlockBossId: "phoenix_lord",
|
||||
emoji: "🌌",
|
||||
id: "astral_void",
|
||||
name: "The Astral Void",
|
||||
status: "locked",
|
||||
unlockBossId: "phoenix_lord",
|
||||
unlockQuestId: "the_forge",
|
||||
},
|
||||
{
|
||||
id: "celestial_reaches",
|
||||
name: "The Celestial Reaches",
|
||||
description:
|
||||
"Beyond the astral void, where reality gives way to pure divinity. The celestial host holds court here in towers of light older than stars, but their idea of order is as alien and terrifying as the chaos below.",
|
||||
emoji: "✨",
|
||||
status: "locked",
|
||||
unlockBossId: "the_devourer",
|
||||
emoji: "✨",
|
||||
id: "celestial_reaches",
|
||||
name: "The Celestial Reaches",
|
||||
status: "locked",
|
||||
unlockBossId: "the_devourer",
|
||||
unlockQuestId: "the_end",
|
||||
},
|
||||
{
|
||||
id: "abyssal_trench",
|
||||
name: "The Abyssal Trench",
|
||||
description:
|
||||
"At the bottom of all things, where no light reaches and pressure could crush continents, something old and patient waits. It has been waiting since before your world was made — and it has never been interrupted.",
|
||||
emoji: "🌊",
|
||||
status: "locked",
|
||||
unlockBossId: "the_first_light",
|
||||
emoji: "🌊",
|
||||
id: "abyssal_trench",
|
||||
name: "The Abyssal Trench",
|
||||
status: "locked",
|
||||
unlockBossId: "the_first_light",
|
||||
unlockQuestId: "celestial_archive",
|
||||
},
|
||||
{
|
||||
id: "infernal_court",
|
||||
name: "The Infernal Court",
|
||||
description:
|
||||
"The courts of the underworld, where demon lords scheme across aeons. Power here is measured in souls and suffering — your guild deals in neither, but you will have to speak their language before this is over.",
|
||||
emoji: "👿",
|
||||
status: "locked",
|
||||
unlockBossId: "elder_abomination",
|
||||
emoji: "👿",
|
||||
id: "infernal_court",
|
||||
name: "The Infernal Court",
|
||||
status: "locked",
|
||||
unlockBossId: "elder_abomination",
|
||||
unlockQuestId: "abyssal_chronicle",
|
||||
},
|
||||
{
|
||||
id: "crystalline_spire",
|
||||
name: "The Crystalline Spire",
|
||||
description:
|
||||
"A tower of living crystal that pierces every boundary between planes. Its facets reflect possibilities that have never existed and futures that cannot be. The intelligence at its core has been calculating since before this universe existed.",
|
||||
emoji: "💎",
|
||||
status: "locked",
|
||||
unlockBossId: "the_fallen",
|
||||
emoji: "💎",
|
||||
id: "crystalline_spire",
|
||||
name: "The Crystalline Spire",
|
||||
status: "locked",
|
||||
unlockBossId: "the_fallen",
|
||||
unlockQuestId: "infernal_codex",
|
||||
},
|
||||
{
|
||||
id: "void_sanctum",
|
||||
name: "The Void Sanctum",
|
||||
description:
|
||||
"Not a place but a state of being — the space between the spaces between things. Existence grows thin here. Your guild is the first to find it, drawn by a power that should not be able to call to anything that lives.",
|
||||
emoji: "🌀",
|
||||
status: "locked",
|
||||
unlockBossId: "crystal_sovereign",
|
||||
emoji: "🌀",
|
||||
id: "void_sanctum",
|
||||
name: "The Void Sanctum",
|
||||
status: "locked",
|
||||
unlockBossId: "crystal_sovereign",
|
||||
unlockQuestId: "the_prism_vault",
|
||||
},
|
||||
{
|
||||
id: "eternal_throne",
|
||||
name: "The Eternal Throne",
|
||||
description:
|
||||
"The seat of ultimate power at the centre of all creation. Whoever sits here has sat here since the beginning. They have watched countless guilds rise and fall across uncounted ages. Your guild has come to take the throne. It does not yield.",
|
||||
emoji: "👑",
|
||||
status: "locked",
|
||||
unlockBossId: "void_emperor",
|
||||
emoji: "👑",
|
||||
id: "eternal_throne",
|
||||
name: "The Eternal Throne",
|
||||
status: "locked",
|
||||
unlockBossId: "void_emperor",
|
||||
unlockQuestId: "heart_of_void",
|
||||
},
|
||||
{
|
||||
id: "primordial_chaos",
|
||||
name: "The Primordial Chaos",
|
||||
description:
|
||||
"Beyond the throne lies the raw stuff of creation itself — not a place but an ongoing argument between existence and non-existence that has never been resolved. Your guild enters the argument.",
|
||||
emoji: "🌪️",
|
||||
status: "locked",
|
||||
unlockBossId: "the_apex",
|
||||
emoji: "🌪️",
|
||||
id: "primordial_chaos",
|
||||
name: "The Primordial Chaos",
|
||||
status: "locked",
|
||||
unlockBossId: "the_apex",
|
||||
unlockQuestId: "eternal_dominion",
|
||||
},
|
||||
{
|
||||
id: "infinite_expanse",
|
||||
name: "The Infinite Expanse",
|
||||
description:
|
||||
"A realm without edges, without centre, without reference — where distance is a concept that does not apply and your guild must define their own coordinates to navigate at all. Everything here is further than it looks.",
|
||||
emoji: "♾️",
|
||||
status: "locked",
|
||||
unlockBossId: "primordial_titan",
|
||||
emoji: "♾️",
|
||||
id: "infinite_expanse",
|
||||
name: "The Infinite Expanse",
|
||||
status: "locked",
|
||||
unlockBossId: "primordial_titan",
|
||||
unlockQuestId: "chaos_chronicle",
|
||||
},
|
||||
{
|
||||
id: "reality_forge",
|
||||
name: "The Reality Forge",
|
||||
description:
|
||||
"The workshop where the original universe was hammered into shape — still hot, still humming, still producing realities as a byproduct of its idle operation. The things that work here have never stopped.",
|
||||
emoji: "⚒️",
|
||||
status: "locked",
|
||||
unlockBossId: "expanse_sovereign",
|
||||
emoji: "⚒️",
|
||||
id: "reality_forge",
|
||||
name: "The Reality Forge",
|
||||
status: "locked",
|
||||
unlockBossId: "expanse_sovereign",
|
||||
unlockQuestId: "expanse_codex",
|
||||
},
|
||||
{
|
||||
id: "cosmic_maelstrom",
|
||||
name: "The Cosmic Maelstrom",
|
||||
description:
|
||||
"A confluence of every force in existence, spinning in patterns that reduce galaxies to debris. Your guild navigates currents of energy that, on a good day, merely shatter planets.",
|
||||
emoji: "🌀",
|
||||
status: "locked",
|
||||
unlockBossId: "reality_architect",
|
||||
emoji: "🌀",
|
||||
id: "cosmic_maelstrom",
|
||||
name: "The Cosmic Maelstrom",
|
||||
status: "locked",
|
||||
unlockBossId: "reality_architect",
|
||||
unlockQuestId: "forge_chronicle",
|
||||
},
|
||||
{
|
||||
id: "primeval_sanctum",
|
||||
name: "The Primeval Sanctum",
|
||||
description:
|
||||
"The oldest place that has ever existed — older than time, older than space, older than the concept of age itself. It holds something that remembers the moment before the first moment.",
|
||||
emoji: "🗿",
|
||||
status: "locked",
|
||||
unlockBossId: "cosmic_annihilator",
|
||||
emoji: "🗿",
|
||||
id: "primeval_sanctum",
|
||||
name: "The Primeval Sanctum",
|
||||
status: "locked",
|
||||
unlockBossId: "cosmic_annihilator",
|
||||
unlockQuestId: "maelstrom_codex",
|
||||
},
|
||||
{
|
||||
id: "the_absolute",
|
||||
name: "The Absolute",
|
||||
description:
|
||||
"There is nothing beyond this. Not because nothing has been found — because nothing exists to find. The Absolute is the final truth: the end of all things that are and the beginning of all things that never were. Your guild stands at the edge of everything.",
|
||||
emoji: "⚫",
|
||||
status: "locked",
|
||||
unlockBossId: "primeval_god",
|
||||
emoji: "⚫",
|
||||
id: "the_absolute",
|
||||
name: "The Absolute",
|
||||
status: "locked",
|
||||
unlockBossId: "primeval_god",
|
||||
unlockQuestId: "sanctum_chronicle",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Prisma database client singleton.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
+55
-11
@@ -1,37 +1,81 @@
|
||||
/**
|
||||
* @file Entry point for the Elysium API server.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { logger as honoLogger } from "hono/logger";
|
||||
import { aboutRouter } from "./routes/about.js";
|
||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { bossRouter } from "./routes/boss.js";
|
||||
import { craftRouter } from "./routes/craft.js";
|
||||
import { debugRouter } from "./routes/debug.js";
|
||||
import { exploreRouter } from "./routes/explore.js";
|
||||
import { frontendRouter } from "./routes/frontend.js";
|
||||
import { gameRouter } from "./routes/game.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||
import { connectGateway } from "./services/gateway.js";
|
||||
import { logger } from "./services/logger.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", logger());
|
||||
app.use("*", honoLogger());
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: process.env["CORS_ORIGIN"] ?? "http://localhost:5173",
|
||||
allowHeaders: ["Authorization", "Content-Type"],
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowHeaders: [ "Authorization", "Content-Type" ],
|
||||
allowMethods: [ "GET", "POST", "PUT", "DELETE", "OPTIONS" ],
|
||||
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||
}),
|
||||
);
|
||||
|
||||
app.route("/about", aboutRouter);
|
||||
app.route("/debug", debugRouter);
|
||||
app.route("/fe", frontendRouter);
|
||||
app.route("/auth", authRouter);
|
||||
app.route("/game", gameRouter);
|
||||
app.route("/boss", bossRouter);
|
||||
app.route("/explore", exploreRouter);
|
||||
app.route("/craft", craftRouter);
|
||||
app.route("/prestige", prestigeRouter);
|
||||
app.route("/transcendence", transcendenceRouter);
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
app.route("/profile", profileRouter);
|
||||
|
||||
app.get("/health", (context) => context.json({ status: "ok" }));
|
||||
|
||||
const port = Number(process.env["PORT"] ?? 3001);
|
||||
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Elysium API running on port ${port}`);
|
||||
app.get("/health", (context) => {
|
||||
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);
|
||||
|
||||
try {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { verifyToken } from "../services/jwt.js";
|
||||
/**
|
||||
* @file Authentication middleware for validating JWT tokens.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export const authMiddleware: MiddlewareHandler<HonoEnv> = async (context, next) => {
|
||||
import { verifyToken } from "../services/jwt.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
/**
|
||||
* Validates the Authorization Bearer token on each request and attaches the discordId to context.
|
||||
* @param context - The Hono context object.
|
||||
* @param next - The next middleware handler.
|
||||
* @returns A JSON error response if authentication fails, otherwise calls next.
|
||||
*/
|
||||
export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
||||
context,
|
||||
next,
|
||||
) => {
|
||||
const authorization = context.req.header("Authorization");
|
||||
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
context.status(401);
|
||||
context.json({ error: "Missing or invalid Authorization header" });
|
||||
return;
|
||||
if (authorization?.startsWith("Bearer ") !== true) {
|
||||
return context.json(
|
||||
{ error: "Missing or invalid Authorization header" },
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const token = authorization.slice(7);
|
||||
@@ -16,9 +34,16 @@ export const authMiddleware: MiddlewareHandler<HonoEnv> = async (context, next)
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
context.set("discordId", payload.discordId);
|
||||
await next();
|
||||
} catch {
|
||||
context.status(401);
|
||||
context.json({ error: "Invalid or expired token" });
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"auth_middleware",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Invalid or expired token" }, 401);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -- Need the consistent return!
|
||||
return await next();
|
||||
};
|
||||
|
||||
@@ -1,46 +1,70 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
/**
|
||||
* @file About route providing API version and release information.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const { version: API_VERSION } = JSON.parse(
|
||||
readFileSync(join(__dirname, "../../package.json"), "utf-8"),
|
||||
) as { version: string };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const apiVersion = process.env.npm_package_version ?? "unknown";
|
||||
|
||||
const GITEA_RELEASES_URL =
|
||||
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan-ideation/elysium/releases";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const giteaReleasesUrl = "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/elysium/releases";
|
||||
const cacheTtlMs = 5 * 60 * 1000;
|
||||
|
||||
let releasesCache: GiteaRelease[] = [];
|
||||
let cacheTimestamp = 0;
|
||||
interface ReleasesCache {
|
||||
data: Array<GiteaRelease>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const fetchReleases = async (): Promise<GiteaRelease[]> => {
|
||||
let releasesCache: ReleasesCache = { data: [], timestamp: 0 };
|
||||
|
||||
const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
||||
const now = Date.now();
|
||||
if (releasesCache.length > 0 && now - cacheTimestamp < CACHE_TTL_MS) {
|
||||
return releasesCache;
|
||||
if (releasesCache.data.length > 0 && now - releasesCache.timestamp < cacheTtlMs) {
|
||||
return releasesCache.data;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(GITEA_RELEASES_URL);
|
||||
const response = await fetch(giteaReleasesUrl);
|
||||
if (!response.ok) {
|
||||
return releasesCache;
|
||||
return releasesCache.data;
|
||||
}
|
||||
releasesCache = (await response.json()) as GiteaRelease[];
|
||||
cacheTimestamp = now;
|
||||
return releasesCache;
|
||||
const rawData: unknown = await response.json();
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response */
|
||||
const data = rawData as Array<GiteaRelease>;
|
||||
releasesCache = { data: data, timestamp: now };
|
||||
return releasesCache.data;
|
||||
} catch {
|
||||
return releasesCache;
|
||||
return releasesCache.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const aboutRouter = new Hono();
|
||||
const aboutRouter = new Hono();
|
||||
|
||||
aboutRouter.get("/", async (context) => {
|
||||
const releases = await fetchReleases();
|
||||
const body: AboutResponse = {
|
||||
apiVersion: API_VERSION,
|
||||
releases,
|
||||
};
|
||||
return context.json(body);
|
||||
aboutRouter.get("/", async(context) => {
|
||||
try {
|
||||
const releases = await fetchReleases();
|
||||
const body: AboutResponse = {
|
||||
apiVersion,
|
||||
releases,
|
||||
};
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file Apotheosis route handling the apotheosis reset mechanic.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* 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 */
|
||||
import { Hono } from "hono";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostApotheosisState,
|
||||
isEligibleForApotheosis,
|
||||
} from "../services/apotheosis.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
grantApotheosisRole,
|
||||
postMilestoneWebhook,
|
||||
} from "../services/webhook.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const apotheosisRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
apotheosisRouter.use("*", authMiddleware);
|
||||
|
||||
apotheosisRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
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 (!isEligibleForApotheosis(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error:
|
||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 },
|
||||
});
|
||||
|
||||
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 };
|
||||
+85
-33
@@ -1,15 +1,25 @@
|
||||
import type { Player } from "@elysium/types";
|
||||
/**
|
||||
* @file Authentication routes for Discord OAuth.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Auth callback requires many steps */
|
||||
/* eslint-disable max-statements -- Auth callback requires many statements */
|
||||
import { Hono } from "hono";
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { INITIAL_GAME_STATE } from "../data/initialState.js";
|
||||
import {
|
||||
buildOAuthUrl,
|
||||
exchangeCode,
|
||||
fetchDiscordUser,
|
||||
} from "../services/discord.js";
|
||||
import { signToken } from "../services/jwt.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { grantElysianRole } from "../services/webhook.js";
|
||||
import type { Player } from "@elysium/types";
|
||||
|
||||
export const authRouter = new Hono();
|
||||
const authRouter = new Hono();
|
||||
|
||||
authRouter.get("/url", (context) => {
|
||||
try {
|
||||
@@ -20,10 +30,10 @@ authRouter.get("/url", (context) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/callback", async (context) => {
|
||||
authRouter.get("/callback", async(context) => {
|
||||
const code = context.req.query("code");
|
||||
|
||||
if (!code) {
|
||||
if (code === undefined || code === "") {
|
||||
return context.json({ error: "Missing code parameter" }, 400);
|
||||
}
|
||||
|
||||
@@ -40,58 +50,100 @@ authRouter.get("/callback", async (context) => {
|
||||
if (!existing) {
|
||||
const player = await prisma.player.create({
|
||||
data: {
|
||||
discordId: discordUser.id,
|
||||
username: discordUser.username,
|
||||
discriminator: discordUser.discriminator,
|
||||
avatar: discordUser.avatar,
|
||||
characterName: discordUser.username,
|
||||
createdAt: now,
|
||||
lastSavedAt: now,
|
||||
avatar: discordUser.avatar,
|
||||
characterName: discordUser.username,
|
||||
createdAt: now,
|
||||
discordId: discordUser.id,
|
||||
discriminator: discordUser.discriminator,
|
||||
lastSavedAt: now,
|
||||
totalClicks: 0,
|
||||
totalGoldEarned: 0,
|
||||
totalClicks: 0,
|
||||
username: discordUser.username,
|
||||
},
|
||||
});
|
||||
|
||||
const playerShape: Player = {
|
||||
discordId: player.discordId,
|
||||
username: player.username,
|
||||
discriminator: player.discriminator,
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
createdAt: player.createdAt,
|
||||
lastSavedAt: player.lastSavedAt,
|
||||
totalGoldEarned: player.totalGoldEarned,
|
||||
totalClicks: player.totalClicks,
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
createdAt: player.createdAt,
|
||||
discordId: player.discordId,
|
||||
discriminator: player.discriminator,
|
||||
lastSavedAt: player.lastSavedAt,
|
||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||
lifetimeClicks: player.lifetimeClicks,
|
||||
lifetimeGoldEarned: player.lifetimeGoldEarned,
|
||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||
totalClicks: player.totalClicks,
|
||||
totalGoldEarned: player.totalGoldEarned,
|
||||
username: player.username,
|
||||
};
|
||||
|
||||
const initialState = INITIAL_GAME_STATE(playerShape, playerShape.characterName);
|
||||
const freshState = initialGameState(
|
||||
playerShape,
|
||||
playerShape.characterName,
|
||||
);
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
discordId: player.discordId,
|
||||
state: initialState as unknown as never,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never type */
|
||||
state: freshState as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const inGuild = await grantElysianRole(player.discordId);
|
||||
await prisma.player.update({
|
||||
data: { inGuild },
|
||||
where: { discordId: player.discordId },
|
||||
});
|
||||
|
||||
const jwtToken = signToken(player.discordId);
|
||||
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
|
||||
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`);
|
||||
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
|
||||
/* v8 ignore next -- @preserve */
|
||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
return context.redirect(
|
||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`,
|
||||
);
|
||||
}
|
||||
|
||||
const inGuild = await grantElysianRole(discordUser.id);
|
||||
const updated = await prisma.player.update({
|
||||
where: { discordId: discordUser.id },
|
||||
data: {
|
||||
username: discordUser.username,
|
||||
avatar: discordUser.avatar,
|
||||
discriminator: discordUser.discriminator,
|
||||
avatar: discordUser.avatar,
|
||||
inGuild: inGuild,
|
||||
username: discordUser.username,
|
||||
},
|
||||
where: { discordId: discordUser.id },
|
||||
});
|
||||
|
||||
const jwtToken = signToken(updated.discordId);
|
||||
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
|
||||
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`);
|
||||
} catch {
|
||||
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
|
||||
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
|
||||
/* v8 ignore next -- @preserve */
|
||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
return context.redirect(
|
||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||
);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"auth_callback",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
return context.redirect(`${clientUrl}/auth/callback?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
export { authRouter };
|
||||
|
||||
+340
-201
@@ -1,14 +1,30 @@
|
||||
import type { BossChallengeResponse, GameState } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
/**
|
||||
* @file Boss challenge route handling combat mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
|
||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||
/* 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 {
|
||||
computeSetBonuses,
|
||||
getActiveCompanionBonus,
|
||||
type BossChallengeResponse,
|
||||
type GameState,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { DEFAULT_BOSSES } from "../data/bosses.js";
|
||||
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
export const bossRouter = new Hono<HonoEnv>();
|
||||
const bossRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
bossRouter.use("*", authMiddleware);
|
||||
|
||||
@@ -18,239 +34,362 @@ const calculatePartyStats = (
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier *= upgrade.multiplier;
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
|
||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
|
||||
// Apply equipped weapon's combat bonus
|
||||
const equipmentCombatMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equipmentCombatMultiplier = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
const equippedItemIds = (state.equipment ?? []).filter((e) => e.equipped).map((e) => e.id);
|
||||
const setCombatMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).combatMultiplier;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equippedItemIds = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
|
||||
equippedItemIds,
|
||||
defaultEquipmentSets,
|
||||
);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) continue;
|
||||
if (adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased &&
|
||||
upgrade.target === "adventurer" &&
|
||||
upgrade.adventurerId === adventurer.id
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier *= upgrade.multiplier;
|
||||
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
partyDPS +=
|
||||
adventurer.combatPower *
|
||||
adventurer.count *
|
||||
adventurerMultiplier *
|
||||
globalMultiplier *
|
||||
prestigeMultiplier;
|
||||
const adventurerContribution
|
||||
= adventurer.combatPower
|
||||
* adventurer.count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyDPS = partyDPS + adventurerContribution;
|
||||
|
||||
partyMaxHp += adventurer.level * 50 * adventurer.count;
|
||||
const adventurerHp = adventurer.level * 50 * adventurer.count;
|
||||
partyMaxHp = partyMaxHp + adventurerHp;
|
||||
}
|
||||
|
||||
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 12 -- @preserve */
|
||||
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
||||
const craftedCombatMultiplier
|
||||
= state.exploration?.craftedCombatMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId ?? null,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionCombatMult
|
||||
= companionBonus?.type === "bossDamage"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
partyDPS = partyDPS
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* echoCombatMultiplier
|
||||
* craftedCombatMultiplier
|
||||
* companionCombatMult;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
bossRouter.post("/challenge", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
bossRouter.post("/challenge", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
|
||||
if (!body.bossId) {
|
||||
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 state = record.state as unknown as GameState;
|
||||
const boss = state.bosses.find((b) => 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 || !isFinite(partyDPS) || !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;
|
||||
|
||||
let partyHpRemaining: number;
|
||||
let bossHpAtBattleEnd: number;
|
||||
let bossNewHp: number;
|
||||
let rewards: BossChallengeResponse["rewards"];
|
||||
let casualties: BossChallengeResponse["casualties"];
|
||||
|
||||
if (won) {
|
||||
bossHpAtBattleEnd = 0;
|
||||
bossNewHp = 0;
|
||||
partyHpRemaining = Math.max(
|
||||
0,
|
||||
partyMaxHp - bossDPS * timeToKillBoss,
|
||||
);
|
||||
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
state.resources.gold += boss.goldReward;
|
||||
state.resources.essence += boss.essenceReward;
|
||||
state.resources.crystals += boss.crystalReward;
|
||||
state.player.totalGoldEarned += boss.goldReward;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
const upgrade = state.upgrades.find((u) => u.id === upgradeId);
|
||||
if (upgrade) {
|
||||
upgrade.unlocked = true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.bossId) {
|
||||
return context.json({ error: "Invalid request body" }, 400);
|
||||
}
|
||||
|
||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||
const equipmentRewards = boss.equipmentRewards ?? [];
|
||||
for (const equipmentId of equipmentRewards) {
|
||||
const equipment = (state.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (equipment) {
|
||||
equipment.owned = true;
|
||||
const slotAlreadyEquipped = (state.equipment ?? []).some(
|
||||
(e) => e.type === equipment.type && e.equipped,
|
||||
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
|
||||
// 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,
|
||||
);
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
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 zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId);
|
||||
const zoneIndex = zoneBosses.findIndex((b) => b.id === body.bossId);
|
||||
const nextZoneBoss = zoneBosses[zoneIndex + 1];
|
||||
if (nextZoneBoss && nextZoneBoss.prestigeRequirement <= state.prestige.count) {
|
||||
const nextBossInState = state.bosses.find((b) => b.id === nextZoneBoss.id);
|
||||
if (nextBossInState) nextBossInState.status = "available";
|
||||
}
|
||||
const now = Date.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 },
|
||||
});
|
||||
|
||||
// Unlock any zone whose unlock conditions are now both satisfied
|
||||
// (final boss defeated AND final quest completed)
|
||||
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) => q.id === zone.unlockQuestId && q.status === "completed");
|
||||
if (!questSatisfied) continue;
|
||||
zone.status = "unlocked";
|
||||
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
|
||||
const firstNewBoss = newZoneBosses[0];
|
||||
if (firstNewBoss && firstNewBoss.prestigeRequirement <= state.prestige.count) {
|
||||
firstNewBoss.status = "available";
|
||||
}
|
||||
}
|
||||
const { bossId } = body;
|
||||
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||
|
||||
// Update daily boss challenge progress
|
||||
if (state.dailyChallenges) {
|
||||
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
|
||||
state.dailyChallenges,
|
||||
"bossesDefeated",
|
||||
1,
|
||||
);
|
||||
state.dailyChallenges = updatedChallenges;
|
||||
state.resources.crystals += crystalsAwarded;
|
||||
}
|
||||
|
||||
// First-kill bounty — look up authoritative bounty from static data
|
||||
const staticBoss = DEFAULT_BOSSES.find((b) => b.id === body.bossId);
|
||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
||||
state.prestige.runestones += bountyRunestones;
|
||||
|
||||
rewards = {
|
||||
gold: boss.goldReward,
|
||||
essence: boss.essenceReward,
|
||||
crystals: boss.crystalReward,
|
||||
upgradeIds: boss.upgradeRewards,
|
||||
equipmentIds: equipmentRewards,
|
||||
bountyRunestones,
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: BossChallengeResponse = {
|
||||
bossDPS,
|
||||
bossHpAtBattleEnd,
|
||||
bossHpBefore,
|
||||
bossMaxHp,
|
||||
bossNewHp,
|
||||
partyDPS,
|
||||
partyHpRemaining,
|
||||
partyMaxHp,
|
||||
won,
|
||||
};
|
||||
} else {
|
||||
bossHpAtBattleEnd = Math.max(
|
||||
0,
|
||||
bossHpBefore - partyDPS * timeToKillParty,
|
||||
);
|
||||
bossNewHp = 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 });
|
||||
}
|
||||
if (rewards !== undefined) {
|
||||
response.rewards = rewards;
|
||||
}
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"boss_challenge",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
where: { discordId },
|
||||
data: { state: state as object, updatedAt: now },
|
||||
});
|
||||
|
||||
const response: BossChallengeResponse = {
|
||||
won,
|
||||
partyDPS,
|
||||
bossDPS,
|
||||
bossHpBefore,
|
||||
bossMaxHp: boss.maxHp,
|
||||
bossHpAtBattleEnd,
|
||||
bossNewHp,
|
||||
partyMaxHp,
|
||||
partyHpRemaining,
|
||||
};
|
||||
if (rewards !== undefined) response.rewards = rewards;
|
||||
if (casualties !== undefined) response.casualties = casualties;
|
||||
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
export { bossRouter };
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file Crafting route handling recipe crafting mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||
import { Hono } from "hono";
|
||||
import { defaultRecipes } from "../data/recipes.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const craftRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
craftRouter.use("*", authMiddleware);
|
||||
|
||||
const recomputeCraftedMultipliers = (
|
||||
craftedRecipeIds: Array<string>,
|
||||
): {
|
||||
craftedGoldMultiplier: number;
|
||||
craftedEssenceMultiplier: number;
|
||||
craftedClickMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
} => {
|
||||
return {
|
||||
craftedClickMultiplier: defaultRecipes.filter((r) => {
|
||||
return craftedRecipeIds.includes(r.id) && r.bonus.type === "click_power";
|
||||
}).reduce((mult, r) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * r.bonus.value;
|
||||
}, 1),
|
||||
craftedCombatMultiplier: defaultRecipes.filter((r) => {
|
||||
return craftedRecipeIds.includes(r.id) && r.bonus.type === "combat_power";
|
||||
}).reduce((mult, r) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * r.bonus.value;
|
||||
}, 1),
|
||||
craftedEssenceMultiplier: defaultRecipes.filter((r) => {
|
||||
return (
|
||||
craftedRecipeIds.includes(r.id) && r.bonus.type === "essence_income"
|
||||
);
|
||||
}).reduce((mult, r) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * r.bonus.value;
|
||||
}, 1),
|
||||
craftedGoldMultiplier: defaultRecipes.filter((r) => {
|
||||
return craftedRecipeIds.includes(r.id) && r.bonus.type === "gold_income";
|
||||
}).reduce((mult, r) => {
|
||||
return mult * r.bonus.value;
|
||||
}, 1),
|
||||
};
|
||||
};
|
||||
|
||||
craftRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<CraftRecipeRequest>();
|
||||
|
||||
const { recipeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!recipeId) {
|
||||
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
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
export { craftRouter };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* @file Exploration routes handling area exploration mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialExploration } from "../data/initialState.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
ExploreStartResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const exploreRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
exploreRouter.use("*", authMiddleware);
|
||||
|
||||
const nothingProbability = 0.2;
|
||||
|
||||
const nothingMessages = [
|
||||
"Your scouts searched thoroughly but found nothing of value.",
|
||||
"The area yielded nothing remarkable this time.",
|
||||
"Your scouts returned empty-handed.",
|
||||
"A wasted journey — the area proved barren.",
|
||||
"Nothing to show for the effort. Perhaps next time.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random "nothing found" message.
|
||||
* V8 ignore next 2 -- @preserve.
|
||||
* @returns A random message string.
|
||||
*/
|
||||
const pickNothingMessage = (): string => {
|
||||
const index = Math.floor(Math.random() * nothingMessages.length);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||
};
|
||||
|
||||
exploreRouter.get("/claimable", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const areaId = context.req.query("areaId");
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!explorationArea) {
|
||||
return context.json({ error: "Unknown exploration area" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.exploration) {
|
||||
const response: ExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
|
||||
if (!area || area.status !== "in_progress") {
|
||||
const response: ExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
const claimable = Date.now() >= expiresAt;
|
||||
const response: ExploreClaimableResponse = { claimable };
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"explore_claimable",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
exploreRouter.post("/start", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreStartRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!explorationArea) {
|
||||
return context.json({ error: "Unknown exploration area" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
// 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) => {
|
||||
return z.id === explorationArea.zoneId;
|
||||
});
|
||||
if (!zone || zone.status !== "unlocked") {
|
||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json(
|
||||
{ error: "Exploration area not found in state" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const anyInProgress = state.exploration.areas.some((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (anyInProgress) {
|
||||
return context.json(
|
||||
{ error: "An exploration is already in progress" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"explore_start",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
exploreRouter.post("/collect", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreCollectRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!explorationArea) {
|
||||
return context.json({ error: "Unknown exploration area" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.exploration) {
|
||||
return context.json({ error: "No exploration state found" }, 400);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
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
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
const response: ExploreCollectResponse = {
|
||||
event: null,
|
||||
foundNothing: true,
|
||||
materialsFound: [],
|
||||
nothingMessage: pickNothingMessage(),
|
||||
};
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// 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
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!event) {
|
||||
return context.json({ error: "No events available" }, 500);
|
||||
}
|
||||
|
||||
// Apply event effects and build the result summary
|
||||
let goldChange = 0;
|
||||
let essenceChange = 0;
|
||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||
|
||||
if (event.effect.type === "gold_gain") {
|
||||
// Gold gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.gold = state.resources.gold + amount;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||
goldChange = amount;
|
||||
} else if (event.effect.type === "gold_loss") {
|
||||
// Gold loss — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||
state.resources.gold = state.resources.gold - amount;
|
||||
goldChange = -amount;
|
||||
} else if (event.effect.type === "essence_gain") {
|
||||
// Essence gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.essence = state.resources.essence + amount;
|
||||
essenceChange = amount;
|
||||
} else if (event.effect.type === "material_gain") {
|
||||
const { materialId } = event.effect;
|
||||
|
||||
// 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) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
materialGained = { materialId, quantity };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* 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);
|
||||
}
|
||||
});
|
||||
|
||||
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 };
|
||||
+984
-457
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file Leaderboard routes for retrieving ranked player statistics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable complexity -- Leaderboard handler has inherent complexity */
|
||||
import { Hono } from "hono";
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const leaderboardRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const validCategories = new Set([
|
||||
"totalGold",
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
"achievementsUnlocked",
|
||||
"prestigeCount",
|
||||
"transcendenceCount",
|
||||
"apotheosisCount",
|
||||
]);
|
||||
|
||||
const gameStateCategories = new Set([
|
||||
"prestigeCount",
|
||||
"transcendenceCount",
|
||||
"apotheosisCount",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parses the showOnLeaderboards flag from a player's profile settings blob.
|
||||
* @param raw - The raw profile settings value from the database.
|
||||
* @returns True if the player should appear on leaderboards, false otherwise.
|
||||
*/
|
||||
const parseShowOnLeaderboards = (raw: unknown): boolean => {
|
||||
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime profile shape */
|
||||
return (raw as Record<string, unknown>).showOnLeaderboards !== false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the display title name for a given title ID.
|
||||
* @param titleId - The player's active title ID.
|
||||
* @returns The human-readable title name, or empty string if no title.
|
||||
*/
|
||||
const resolveTitleName = (titleId: string | null): string => {
|
||||
if (titleId === null || titleId === "") {
|
||||
return "";
|
||||
}
|
||||
return gameTitles.find((title) => {
|
||||
return title.id === titleId;
|
||||
})?.name ?? titleId;
|
||||
};
|
||||
|
||||
leaderboardRouter.get("/", async(context) => {
|
||||
try {
|
||||
const category = context.req.query("category") ?? "totalGold";
|
||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||
|
||||
if (!validCategories.has(category)) {
|
||||
return context.json({ error: "Invalid category" }, 400);
|
||||
}
|
||||
|
||||
const [ players, gameStates ] = await Promise.all([
|
||||
prisma.player.findMany(),
|
||||
gameStateCategories.has(category)
|
||||
? prisma.gameState.findMany()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const stateMap = new Map(
|
||||
gameStates.map((gs) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||
}),
|
||||
);
|
||||
|
||||
const entries = players.
|
||||
filter((player) => {
|
||||
return parseShowOnLeaderboards(player.profileSettings);
|
||||
}).
|
||||
map((player) => {
|
||||
let value = 0;
|
||||
if (category === "totalGold") {
|
||||
value = player.lifetimeGoldEarned;
|
||||
} else if (category === "bossesDefeated") {
|
||||
value = player.lifetimeBossesDefeated;
|
||||
} else if (category === "questsCompleted") {
|
||||
value = player.lifetimeQuestsCompleted;
|
||||
} else if (category === "achievementsUnlocked") {
|
||||
value = player.lifetimeAchievementsUnlocked;
|
||||
} else {
|
||||
const state = stateMap.get(player.discordId);
|
||||
if (category === "prestigeCount") {
|
||||
value = state?.prestige.count ?? 0;
|
||||
} else if (category === "transcendenceCount") {
|
||||
value = state?.transcendence?.count ?? 0;
|
||||
} else if (category === "apotheosisCount") {
|
||||
value = state?.apotheosis?.count ?? 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeTitle: resolveTitleName(player.activeTitle),
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
discordId: player.discordId,
|
||||
username: player.username,
|
||||
value: value,
|
||||
};
|
||||
}).
|
||||
sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
}).
|
||||
slice(0, limit).
|
||||
map((entry, index) => {
|
||||
return { ...entry, rank: index + 1 };
|
||||
});
|
||||
|
||||
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 };
|
||||
+218
-119
@@ -1,144 +1,243 @@
|
||||
import type { BuyPrestigeUpgradeRequest, GameState, PrestigeRequest } from "@elysium/types";
|
||||
/**
|
||||
* @file Prestige routes handling prestige resets and upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../services/prestige.js";
|
||||
import { postMilestoneWebhook } from "../services/webhook.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { BuyPrestigeUpgradeRequest, GameState } from "@elysium/types";
|
||||
|
||||
export const prestigeRouter = new Hono<HonoEnv>();
|
||||
const prestigeRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
prestigeRouter.use("*", authMiddleware);
|
||||
|
||||
prestigeRouter.post("/", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<PrestigeRequest>();
|
||||
prestigeRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const characterName = body.characterName?.trim();
|
||||
if (!characterName) {
|
||||
return context.json({ error: "characterName is required" }, 400);
|
||||
}
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
/* 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)) {
|
||||
return context.json(
|
||||
{
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
return context.json(
|
||||
{ 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,
|
||||
},
|
||||
};
|
||||
|
||||
// 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 },
|
||||
});
|
||||
|
||||
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",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
// 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 { newState, newPrestigeData, runestonesEarned, milestoneRunestones } = buildPostPrestigeState(
|
||||
state,
|
||||
characterName,
|
||||
);
|
||||
|
||||
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
||||
const finalState: GameState = {
|
||||
...newState,
|
||||
...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}),
|
||||
resources: {
|
||||
...newState.resources,
|
||||
crystals: newState.resources.crystals + challengeCrystals,
|
||||
},
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
where: { discordId },
|
||||
data: { state: finalState as object, updatedAt: now },
|
||||
});
|
||||
|
||||
await prisma.player.update({
|
||||
where: { discordId },
|
||||
data: {
|
||||
characterName,
|
||||
totalGoldEarned: 0,
|
||||
totalClicks: 0,
|
||||
lastSavedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return context.json({
|
||||
runestones: runestonesEarned,
|
||||
newPrestigeCount: newPrestigeData.count,
|
||||
milestoneRunestones,
|
||||
});
|
||||
});
|
||||
|
||||
prestigeRouter.post("/buy-upgrade", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||
return prestigeUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (runestones < upgrade.runestonesCost) {
|
||||
return context.json({ error: "Not enough runestones" }, 400);
|
||||
}
|
||||
|
||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedState: GameState = {
|
||||
...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,
|
||||
runestonesRemaining: updatedRunestones,
|
||||
...multipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"prestige_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
const upgrade = DEFAULT_PRESTIGE_UPGRADES.find((u) => u.id === upgradeId);
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const state = record.state as unknown as GameState;
|
||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (runestones < upgrade.runestonesCost) {
|
||||
return context.json({ error: "Not enough runestones" }, 400);
|
||||
}
|
||||
|
||||
const newRunestones = runestones - upgrade.runestonesCost;
|
||||
const newPurchasedUpgradeIds = [...purchasedUpgradeIds, upgradeId];
|
||||
|
||||
const newState: GameState = {
|
||||
...state,
|
||||
prestige: {
|
||||
...state.prestige,
|
||||
runestones: newRunestones,
|
||||
purchasedUpgradeIds: newPurchasedUpgradeIds,
|
||||
...computeRunestoneMultipliers(newPurchasedUpgradeIds),
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
where: { discordId },
|
||||
data: { state: newState as object, updatedAt: Date.now() },
|
||||
});
|
||||
|
||||
const multipliers = computeRunestoneMultipliers(newPurchasedUpgradeIds);
|
||||
|
||||
return context.json({
|
||||
runestonesRemaining: newRunestones,
|
||||
purchasedUpgradeIds: newPurchasedUpgradeIds,
|
||||
...multipliers,
|
||||
});
|
||||
});
|
||||
|
||||
export { prestigeRouter };
|
||||
|
||||
+273
-94
@@ -1,112 +1,291 @@
|
||||
import type {
|
||||
GameState,
|
||||
ProfileSettings,
|
||||
UpdateProfileRequest,
|
||||
/**
|
||||
* @file Profile routes handling player profile retrieval and updates.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many steps */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
||||
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Defensive checks for runtime nullable fields */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
type GameState,
|
||||
type ProfileSettings,
|
||||
type UpdateProfileRequest,
|
||||
} from "@elysium/types";
|
||||
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import { parseUnlockedTitles } from "../services/titles.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
export const profileRouter = new Hono<HonoEnv>();
|
||||
const profileRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const VALID_NUMBER_FORMATS = new Set(["suffix", "scientific", "engineering"]);
|
||||
const validNumberFormats = new Set([ "suffix", "scientific", "engineering" ]);
|
||||
|
||||
/**
|
||||
* Parses a raw profile settings blob from the database into a typed ProfileSettings object.
|
||||
* @param raw - The raw value from the database.
|
||||
* @returns A valid ProfileSettings object with defaults for missing fields.
|
||||
*/
|
||||
const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
||||
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const numberFormat = VALID_NUMBER_FORMATS.has(obj.numberFormat as string)
|
||||
? (obj.numberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
return {
|
||||
showTotalGold: obj.showTotalGold !== false,
|
||||
showTotalClicks: obj.showTotalClicks !== false,
|
||||
showPrestige: obj.showPrestige !== false,
|
||||
showGuildFounded: obj.showGuildFounded !== false,
|
||||
showBossesDefeated: obj.showBossesDefeated !== false,
|
||||
showQuestsCompleted: obj.showQuestsCompleted !== false,
|
||||
showAdventurersRecruited: obj.showAdventurersRecruited !== false,
|
||||
showAchievementsUnlocked: obj.showAchievementsUnlocked !== false,
|
||||
numberFormat,
|
||||
};
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_PROFILE_SETTINGS };
|
||||
}
|
||||
return { ...DEFAULT_PROFILE_SETTINGS };
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
const rawObject = raw as Record<string, unknown>;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
const parsedNumberFormat = rawObject.numberFormat as string;
|
||||
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
return {
|
||||
enableNotifications: rawObject.enableNotifications === true,
|
||||
enableSounds: rawObject.enableSounds === true,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||
showAdventurersRecruited: rawObject.showAdventurersRecruited !== false,
|
||||
showApotheosis: rawObject.showApotheosis !== false,
|
||||
showBossesDefeated: rawObject.showBossesDefeated !== false,
|
||||
showCurrentClicks: rawObject.showCurrentClicks !== false,
|
||||
showCurrentGold: rawObject.showCurrentGold !== false,
|
||||
showGuildFounded: rawObject.showGuildFounded !== false,
|
||||
showLifetimeAchievementsUnlocked: rawObject.showLifetimeAchievementsUnlocked !== false,
|
||||
showLifetimeAdventurersRecruited: rawObject.showLifetimeAdventurersRecruited !== false,
|
||||
showLifetimeBossesDefeated: rawObject.showLifetimeBossesDefeated !== false,
|
||||
showLifetimeQuestsCompleted: rawObject.showLifetimeQuestsCompleted !== false,
|
||||
showOnLeaderboards: rawObject.showOnLeaderboards !== false,
|
||||
showPrestige: rawObject.showPrestige !== false,
|
||||
showQuestsCompleted: rawObject.showQuestsCompleted !== false,
|
||||
showTotalClicks: rawObject.showTotalClicks !== false,
|
||||
showTotalGold: rawObject.showTotalGold !== false,
|
||||
showTranscendence: rawObject.showTranscendence !== false,
|
||||
};
|
||||
};
|
||||
|
||||
profileRouter.get("/:discordId", async (context) => {
|
||||
const { discordId } = context.req.param();
|
||||
|
||||
const [player, gameStateRecord] = await Promise.all([
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
if (!player) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||
const prestigeCount = state?.prestige.count ?? 0;
|
||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||
|
||||
const bossesDefeated = state?.bosses.filter((b) => b.status === "defeated").length ?? 0;
|
||||
const questsCompleted = state?.quests.filter((q) => q.status === "completed").length ?? 0;
|
||||
const adventurersRecruited =
|
||||
state?.adventurers.reduce((sum, a) => sum + a.count, 0) ?? 0;
|
||||
const achievementsUnlocked =
|
||||
(state?.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
|
||||
|
||||
return context.json({
|
||||
characterName: player.characterName,
|
||||
username: player.username,
|
||||
avatar: player.avatar ?? null,
|
||||
bio: player.bio ?? "",
|
||||
profileSettings,
|
||||
prestigeCount,
|
||||
totalGoldEarned: player.totalGoldEarned,
|
||||
totalClicks: player.totalClicks,
|
||||
bossesDefeated,
|
||||
questsCompleted,
|
||||
adventurersRecruited,
|
||||
achievementsUnlocked,
|
||||
createdAt: player.createdAt,
|
||||
/**
|
||||
* Resolves a title ID to its display name.
|
||||
* @param id - The title ID to resolve.
|
||||
* @returns An object with id and name fields.
|
||||
*/
|
||||
const resolveTitle = (id: string): { id: string; name: string } => {
|
||||
const title = gameTitles.find((gameTitle) => {
|
||||
return gameTitle.id === id;
|
||||
});
|
||||
return { id: id, name: title?.name ?? id };
|
||||
};
|
||||
|
||||
profileRouter.get("/:discordId", async(context) => {
|
||||
try {
|
||||
const { discordId } = context.req.param();
|
||||
|
||||
const [ player, gameStateRecord ] = await Promise.all([
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
if (!player) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||
const prestigeCount = state?.prestige.count ?? 0;
|
||||
const transcendenceCount = state?.transcendence?.count ?? 0;
|
||||
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||
|
||||
const bossesDefeated
|
||||
= state?.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length ?? 0;
|
||||
const questsCompleted
|
||||
= state?.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).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;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
||||
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 ?? [];
|
||||
|
||||
return context.json({
|
||||
achievementsUnlocked: achievementsUnlocked,
|
||||
activeTitle: player.activeTitle,
|
||||
adventurersRecruited: adventurersRecruited,
|
||||
apotheosisCount: apotheosisCount,
|
||||
avatar: player.avatar,
|
||||
bio: player.bio ?? "",
|
||||
bossesDefeated: bossesDefeated,
|
||||
characterClass: player.characterClass,
|
||||
characterName: player.characterName,
|
||||
characterRace: player.characterRace ?? "",
|
||||
completedChapters: completedChapters,
|
||||
createdAt: player.createdAt,
|
||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||
equippedItems: equippedItems,
|
||||
guildDescription: player.guildDescription,
|
||||
guildName: player.guildName,
|
||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||
prestigeCount: prestigeCount,
|
||||
profileSettings: profileSettings,
|
||||
pronouns: player.pronouns ?? "",
|
||||
questsCompleted: questsCompleted,
|
||||
totalClicks: player.lifetimeClicks,
|
||||
totalGoldEarned: player.lifetimeGoldEarned,
|
||||
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) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<UpdateProfileRequest>();
|
||||
profileRouter.put("/", authMiddleware, async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<UpdateProfileRequest>();
|
||||
|
||||
const characterName = (body.characterName ?? "").trim().slice(0, 32);
|
||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||
const numberFormat = VALID_NUMBER_FORMATS.has(body.profileSettings?.numberFormat as string)
|
||||
? (body.profileSettings?.numberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
showTotalGold: body.profileSettings?.showTotalGold !== false,
|
||||
showTotalClicks: body.profileSettings?.showTotalClicks !== false,
|
||||
showPrestige: body.profileSettings?.showPrestige !== false,
|
||||
showGuildFounded: body.profileSettings?.showGuildFounded !== false,
|
||||
showBossesDefeated: body.profileSettings?.showBossesDefeated !== false,
|
||||
showQuestsCompleted: body.profileSettings?.showQuestsCompleted !== false,
|
||||
showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false,
|
||||
showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false,
|
||||
numberFormat,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.characterName) {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
|
||||
if (!characterName) {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
const characterName = body.characterName.trim().slice(0, 32);
|
||||
|
||||
if (characterName === "") {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
|
||||
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
||||
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
||||
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
||||
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* 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 */
|
||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
||||
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
||||
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
||||
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
||||
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
||||
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
||||
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
||||
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
||||
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
||||
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
||||
showPrestige: body.profileSettings.showPrestige ?? true,
|
||||
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
||||
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
||||
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
||||
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
||||
};
|
||||
|
||||
const activeTitle
|
||||
= typeof body.activeTitle === "string"
|
||||
? body.activeTitle.slice(0, 64)
|
||||
: undefined;
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
data: {
|
||||
bio: bio,
|
||||
characterClass: characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: characterRace,
|
||||
guildDescription: guildDescription,
|
||||
guildName: guildName,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
profileSettings: profileSettings as object,
|
||||
pronouns: pronouns,
|
||||
...activeTitle === undefined
|
||||
? {}
|
||||
: { activeTitle },
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
return context.json({
|
||||
activeTitle: updated.activeTitle,
|
||||
bio: updated.bio,
|
||||
characterClass: updated.characterClass,
|
||||
characterName: updated.characterName,
|
||||
characterRace: updated.characterRace,
|
||||
guildDescription: updated.guildDescription,
|
||||
guildName: updated.guildName,
|
||||
profileSettings: profileSettings,
|
||||
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);
|
||||
}
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
where: { discordId },
|
||||
data: { characterName, bio, profileSettings: profileSettings as object },
|
||||
});
|
||||
|
||||
return context.json({
|
||||
characterName: updated.characterName,
|
||||
bio: updated.bio,
|
||||
profileSettings,
|
||||
});
|
||||
});
|
||||
|
||||
export { profileRouter };
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @file Transcendence routes handling transcendence resets and echo upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostTranscendenceState,
|
||||
computeTranscendenceMultipliers,
|
||||
isEligibleForTranscendence,
|
||||
} from "../services/transcendence.js";
|
||||
import { postMilestoneWebhook } from "../services/webhook.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types";
|
||||
|
||||
const transcendenceRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
transcendenceRouter.use("*", authMiddleware);
|
||||
|
||||
transcendenceRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForTranscendence(state)) {
|
||||
return context.json(
|
||||
{
|
||||
// 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,
|
||||
},
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||
return transcendenceUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.transcendence) {
|
||||
return context.json({ error: "No transcendence data found" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (echoes < upgrade.cost) {
|
||||
return context.json({ error: "Not enough echoes" }, 400);
|
||||
}
|
||||
|
||||
const updatedEchoes = echoes - upgrade.cost;
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
const updatedMultipliers
|
||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
transcendence: {
|
||||
...state.transcendence,
|
||||
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,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"transcendence_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { transcendenceRouter };
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file Apotheosis service handling eligibility checks and state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import {
|
||||
defaultTranscendenceUpgrades,
|
||||
} from "../data/transcendenceUpgrades.js";
|
||||
import type { ApotheosisData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Total number of echo upgrades — all must be purchased to unlock Apotheosis.
|
||||
*/
|
||||
const totalEchoUpgrades = defaultTranscendenceUpgrades.length;
|
||||
|
||||
/**
|
||||
* Returns true when the player is eligible to achieve Apotheosis:
|
||||
* all Transcendence echo upgrades must be purchased.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for Apotheosis.
|
||||
*/
|
||||
const isEligibleForApotheosis = (state: GameState): boolean => {
|
||||
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
|
||||
return (
|
||||
purchasedIds.length >= totalEchoUpgrades
|
||||
&& defaultTranscendenceUpgrades.every((u) => {
|
||||
return purchasedIds.includes(u.id);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated game state after Apotheosis — the ultimate nuclear reset.
|
||||
* Wipes absolutely everything including prestige and transcendence.
|
||||
* Only codex lore entries and the apotheosis count itself are preserved.
|
||||
* @param currentState - The current game state before apotheosis.
|
||||
* @param characterName - The character name to carry over.
|
||||
* @returns The updated game state and apotheosis data.
|
||||
*/
|
||||
const buildPostApotheosisState = (
|
||||
currentState: GameState,
|
||||
characterName: string,
|
||||
): { updatedApotheosisData: ApotheosisData; updatedState: GameState } => {
|
||||
const apotheosisCount = (currentState.apotheosis?.count ?? 0) + 1;
|
||||
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
const updatedState: GameState = {
|
||||
...freshState,
|
||||
lastTickAt: Date.now(),
|
||||
// Codex lore persists through all resets — players keep their discovered entries
|
||||
...currentState.codex
|
||||
? { codex: currentState.codex }
|
||||
: {},
|
||||
// Apotheosis data is eternal — never wiped by any reset
|
||||
apotheosis: updatedApotheosisData,
|
||||
// Story chapter progress is permanent — survives all resets
|
||||
...currentState.story
|
||||
? { story: currentState.story }
|
||||
: {},
|
||||
};
|
||||
|
||||
return { updatedApotheosisData, updatedState };
|
||||
};
|
||||
|
||||
export { buildPostApotheosisState, isEligibleForApotheosis };
|
||||
@@ -1,36 +1,77 @@
|
||||
/**
|
||||
* @file Daily challenge generation and progress tracking utilities.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { dailyChallengeTemplates } from "../data/dailyChallenges.js";
|
||||
import type {
|
||||
DailyChallenge,
|
||||
DailyChallengeState,
|
||||
DailyChallengeType,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
import { DAILY_CHALLENGE_TEMPLATES } from "../data/dailyChallenges.js";
|
||||
|
||||
// Use the server's PST/PDT timezone so challenges roll over at PST midnight
|
||||
const getTodayString = (): string =>
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(new Date());
|
||||
/**
|
||||
* Returns today's date string in PST/PDT so challenges roll over at midnight Pacific.
|
||||
* @returns A date string in YYYY-MM-DD format.
|
||||
*/
|
||||
const getTodayString = (): string => {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/Los_Angeles",
|
||||
}).format(new Date());
|
||||
};
|
||||
|
||||
/** Simple deterministic pseudo-random based on a numeric seed. */
|
||||
/**
|
||||
* Simple deterministic pseudo-random number based on a numeric seed.
|
||||
* @param seed - The numeric seed value.
|
||||
* @returns A pseudo-random float in [0, 1).
|
||||
*/
|
||||
const seededRandom = (seed: number): number => {
|
||||
const x = Math.sin(seed + 1) * 10_000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
/** Converts a date string into a stable numeric seed. */
|
||||
const dateSeed = (dateStr: string): number =>
|
||||
dateStr.split("").reduce((acc, char, i) => acc + char.charCodeAt(0) * (i + 1), 0);
|
||||
/**
|
||||
* Converts a date string into a stable numeric seed.
|
||||
* @param dateString - A date string such as "2025-01-01".
|
||||
* @returns A numeric seed derived from the date characters.
|
||||
*/
|
||||
const dateSeed = (dateString: string): number => {
|
||||
let accumulator = 0;
|
||||
let index = 0;
|
||||
for (const char of dateString) {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const charValue = char.codePointAt(0) ?? 0;
|
||||
const contribution = charValue * (index + 1);
|
||||
accumulator = accumulator + contribution;
|
||||
index = index + 1;
|
||||
}
|
||||
return accumulator;
|
||||
};
|
||||
|
||||
/** Deterministically shuffles an array using a numeric seed. */
|
||||
const shuffleWithSeed = <T>(arr: T[], seed: number): T[] => {
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(seededRandom(seed + i) * (i + 1));
|
||||
[result[i], result[j]] = [result[j]!, result[i]!];
|
||||
/**
|
||||
* Deterministically shuffles an array using a numeric seed (Fisher-Yates).
|
||||
* @param array - The array to shuffle.
|
||||
* @param seed - The seed controlling shuffle order.
|
||||
* @returns A new shuffled array.
|
||||
*/
|
||||
const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
||||
const result = [ ...array ];
|
||||
for (let index = result.length - 1; index > 0; index = index - 1) {
|
||||
const swapIndex = Math.floor(seededRandom(seed + index) * (index + 1));
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */
|
||||
const fromSwap = result[swapIndex]!;
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */
|
||||
const fromIndex = result[index]!;
|
||||
result[index] = fromSwap;
|
||||
result[swapIndex] = fromIndex;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const CHALLENGE_TYPES: DailyChallengeType[] = [
|
||||
const challengeTypes: Array<DailyChallengeType> = [
|
||||
"clicks",
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
@@ -40,45 +81,64 @@ const CHALLENGE_TYPES: DailyChallengeType[] = [
|
||||
/**
|
||||
* Generates 3 daily challenges for the given date string, deterministically.
|
||||
* Picks one challenge from 3 different randomly-selected types.
|
||||
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
||||
* @returns An array of 3 DailyChallenge objects.
|
||||
*/
|
||||
export const generateDailyChallenges = (dateStr: string): DailyChallenge[] => {
|
||||
const seed = dateSeed(dateStr);
|
||||
const selectedTypes = shuffleWithSeed([...CHALLENGE_TYPES], seed).slice(0, 3);
|
||||
const generateDailyChallenges = (
|
||||
dateString: string,
|
||||
): Array<DailyChallenge> => {
|
||||
const seed = dateSeed(dateString);
|
||||
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
|
||||
slice(0, 3);
|
||||
|
||||
return selectedTypes.map((type, index) => {
|
||||
const templates = DAILY_CHALLENGE_TEMPLATES.filter((t) => t.type === type);
|
||||
const templateIndex = Math.floor(seededRandom(seed + index * 100) * templates.length);
|
||||
const templates = dailyChallengeTemplates.filter((template) => {
|
||||
return template.type === type;
|
||||
});
|
||||
const indexOffset = index * 100;
|
||||
const templateIndex = Math.floor(
|
||||
seededRandom(seed + indexOffset) * templates.length,
|
||||
);
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- templateIndex is always valid: seededRandom returns [0,1) so floor * length is always in bounds */
|
||||
const template = templates[templateIndex]!;
|
||||
|
||||
return {
|
||||
id: `${dateStr}_${type}`,
|
||||
type: template.type,
|
||||
label: template.label,
|
||||
target: template.target,
|
||||
progress: 0,
|
||||
completed: false,
|
||||
completed: false,
|
||||
id: `${dateString}_${type}`,
|
||||
label: template.label,
|
||||
progress: 0,
|
||||
rewardCrystals: template.rewardCrystals,
|
||||
target: template.target,
|
||||
type: template.type,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current daily challenge state, generating fresh challenges if
|
||||
* the stored date doesn't match today (i.e. a new day has begun).
|
||||
* Returns the current daily challenge state, generating fresh challenges when
|
||||
* the stored date does not match today.
|
||||
* @param state - The current game state.
|
||||
* @returns The current or freshly-generated DailyChallengeState.
|
||||
*/
|
||||
export const getOrResetDailyChallenges = (state: GameState): DailyChallengeState => {
|
||||
const getOrResetDailyChallenges = (
|
||||
state: GameState,
|
||||
): DailyChallengeState => {
|
||||
const today = getTodayString();
|
||||
if (state.dailyChallenges?.date === today) {
|
||||
return state.dailyChallenges;
|
||||
}
|
||||
return { date: today, challenges: generateDailyChallenges(today) };
|
||||
return { challenges: generateDailyChallenges(today), date: today };
|
||||
};
|
||||
|
||||
/**
|
||||
* Increments progress for challenges matching the given type.
|
||||
* Returns the updated challenge state and total crystals awarded for newly completed challenges.
|
||||
* @param challengeState - The current daily challenge state.
|
||||
* @param type - The challenge type to increment progress for.
|
||||
* @param amount - The amount to increment progress by.
|
||||
* @returns The updated challenge state and total crystals awarded.
|
||||
*/
|
||||
export const updateChallengeProgress = (
|
||||
const updateChallengeProgress = (
|
||||
challengeState: DailyChallengeState,
|
||||
type: DailyChallengeType,
|
||||
amount: number,
|
||||
@@ -88,16 +148,33 @@ export const updateChallengeProgress = (
|
||||
const updatedChallenges: DailyChallengeState = {
|
||||
...challengeState,
|
||||
challenges: challengeState.challenges.map((challenge) => {
|
||||
if (challenge.type !== type || challenge.completed) return challenge;
|
||||
if (challenge.type !== type || challenge.completed) {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
const newProgress = Math.min(challenge.progress + amount, challenge.target);
|
||||
const nowCompleted = newProgress >= challenge.target;
|
||||
const updatedProgress = Math.min(
|
||||
challenge.progress + amount,
|
||||
challenge.target,
|
||||
);
|
||||
const nowCompleted = updatedProgress >= challenge.target;
|
||||
|
||||
if (nowCompleted) crystalsAwarded += challenge.rewardCrystals;
|
||||
if (nowCompleted) {
|
||||
crystalsAwarded = crystalsAwarded + challenge.rewardCrystals;
|
||||
}
|
||||
|
||||
return { ...challenge, progress: newProgress, completed: nowCompleted };
|
||||
return {
|
||||
...challenge,
|
||||
completed: nowCompleted,
|
||||
progress: updatedProgress,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return { updatedChallenges, crystalsAwarded };
|
||||
return { crystalsAwarded, updatedChallenges };
|
||||
};
|
||||
|
||||
export {
|
||||
generateDailyChallenges,
|
||||
getOrResetDailyChallenges,
|
||||
updateChallengeProgress,
|
||||
};
|
||||
|
||||
@@ -1,74 +1,157 @@
|
||||
export interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
/**
|
||||
* @file Discord OAuth helpers for token exchange, user fetching, and URL building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const discordClientId = "1479551654264049908";
|
||||
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
||||
|
||||
interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export const exchangeCode = async (code: string): Promise<DiscordTokenResponse> => {
|
||||
const clientId = process.env["DISCORD_CLIENT_ID"];
|
||||
const clientSecret = process.env["DISCORD_CLIENT_SECRET"];
|
||||
const redirectUri = process.env["DISCORD_REDIRECT_URI"];
|
||||
/**
|
||||
* Exchanges a Discord OAuth authorisation code for an access token.
|
||||
* @param code - The authorisation code received from Discord's OAuth callback.
|
||||
* @returns The Discord token response containing the access token.
|
||||
* @throws {Error} If OAuth environment variables are missing or the exchange fails.
|
||||
*/
|
||||
const exchangeCode = async(
|
||||
code: string,
|
||||
): Promise<DiscordTokenResponse> => {
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
if (clientSecret === undefined || clientSecret === "") {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: discordClientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: discordRedirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
body: parameters.toString(),
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
|
||||
return response.json() as Promise<DiscordTokenResponse>;
|
||||
};
|
||||
|
||||
export const fetchDiscordUser = async (accessToken: string): Promise<DiscordUser> => {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
/**
|
||||
* Fetches the Discord user profile for the given access token.
|
||||
* @param accessToken - A valid Discord OAuth access token.
|
||||
* @returns The Discord user object.
|
||||
* @throws {Error} If the user fetch fails.
|
||||
*/
|
||||
const fetchDiscordUser = async(
|
||||
accessToken: string,
|
||||
): Promise<DiscordUser> => {
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
|
||||
return response.json() as Promise<DiscordUser>;
|
||||
};
|
||||
|
||||
export const buildOAuthUrl = (): string => {
|
||||
const clientId = process.env["DISCORD_CLIENT_ID"];
|
||||
const redirectUri = process.env["DISCORD_REDIRECT_URI"];
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
/**
|
||||
* Builds the Discord OAuth authorisation URL.
|
||||
* @returns The full OAuth URL to redirect the user to.
|
||||
* @throws {Error} If OAuth environment variables are missing.
|
||||
*/
|
||||
const buildOAuthUrl = (): string => {
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: discordClientId,
|
||||
redirect_uri: discordRedirectUri,
|
||||
response_type: "code",
|
||||
scope: "identify",
|
||||
scope: "identify",
|
||||
});
|
||||
|
||||
return `https://discord.com/api/oauth2/authorize?${params.toString()}`;
|
||||
return `https://discord.com/api/oauth2/authorize?${parameters.toString()}`;
|
||||
};
|
||||
|
||||
export type { DiscordTokenResponse, DiscordUser };
|
||||
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 };
|
||||
@@ -1,42 +1,65 @@
|
||||
import { createHmac } from "crypto";
|
||||
/**
|
||||
* @file JWT token signing and verification utilities.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
interface JwtPayload {
|
||||
discordId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const base64UrlEncode = (data: string): string =>
|
||||
Buffer.from(data).toString("base64url");
|
||||
const base64UrlEncode = (data: string): string => {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
};
|
||||
|
||||
const base64UrlDecode = (data: string): string =>
|
||||
Buffer.from(data, "base64url").toString("utf8");
|
||||
const base64UrlDecode = (data: string): string => {
|
||||
return Buffer.from(data, "base64url").toString("utf8");
|
||||
};
|
||||
|
||||
export const signToken = (discordId: string): string => {
|
||||
const secret = process.env["JWT_SECRET"];
|
||||
if (!secret) {
|
||||
/**
|
||||
* Signs a JWT token for the given Discord ID.
|
||||
* @param discordId - The Discord user ID to encode in the token.
|
||||
* @returns A signed JWT string valid for 30 days.
|
||||
* @throws {Error} If the JWT_SECRET environment variable is not set.
|
||||
*/
|
||||
const signToken = (discordId: string): string => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (secret === undefined || secret === "") {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
// 30 days expiry
|
||||
const thirtyDaysInSeconds = 60 * 60 * 24 * 30;
|
||||
const payload = base64UrlEncode(
|
||||
JSON.stringify({
|
||||
discordId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days
|
||||
discordId: discordId,
|
||||
exp: Math.floor(Date.now() / 1000) + thirtyDaysInSeconds,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
);
|
||||
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
const signature = createHmac("sha256", secret).
|
||||
update(`${header}.${payload}`).
|
||||
digest("base64url");
|
||||
|
||||
return `${header}.${payload}.${signature}`;
|
||||
};
|
||||
|
||||
export const verifyToken = (token: string): JwtPayload => {
|
||||
const secret = process.env["JWT_SECRET"];
|
||||
if (!secret) {
|
||||
/**
|
||||
* Verifies a JWT token and returns the decoded payload.
|
||||
* @param token - The JWT string to verify.
|
||||
* @returns The decoded JWT payload containing discordId, iat, and exp.
|
||||
* @throws {Error} If the JWT_SECRET is missing, the token is malformed, the
|
||||
* signature is invalid, or the token has expired.
|
||||
*/
|
||||
const verifyToken = (token: string): JwtPayload => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (secret === undefined || secret === "") {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
@@ -45,16 +68,18 @@ export const verifyToken = (token: string): JwtPayload => {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
const [header, payload, signature] = parts as [string, string, string];
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Array destructure of known-length tuple */
|
||||
const [ header, payload, signature ] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
const expectedSignature = createHmac("sha256", secret).
|
||||
update(`${header}.${payload}`).
|
||||
digest("base64url");
|
||||
|
||||
if (signature !== expectedSignature) {
|
||||
throw new Error("Invalid token signature");
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsed JSON from trusted base64url payload */
|
||||
const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload;
|
||||
|
||||
if (decoded.exp < Math.floor(Date.now() / 1000)) {
|
||||
@@ -63,3 +88,5 @@ export const verifyToken = (token: string): JwtPayload => {
|
||||
|
||||
return decoded;
|
||||
};
|
||||
|
||||
export { signToken, verifyToken };
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,24 +1,42 @@
|
||||
/**
|
||||
* @file Offline earnings calculator for gold and essence accrued while logged out.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Offline earnings calculation requires iterating all adventurers with multi-step math */
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const MAX_OFFLINE_SECONDS = 8 * 60 * 60; // 8 hours
|
||||
/**
|
||||
* Maximum offline accrual cap: 8 hours.
|
||||
*/
|
||||
const maxOfflineSeconds = 8 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Calculates the gold and essence earned whilst the player was offline.
|
||||
* Capped at 8 hours to prevent exploit via system clock manipulation.
|
||||
* Applies the same multipliers as the client-side tick engine.
|
||||
* @param state - The current game state to calculate offline earnings from.
|
||||
* @param nowMs - The current timestamp in milliseconds.
|
||||
* @returns The gold, essence, and elapsed seconds earned offline.
|
||||
*/
|
||||
export const calculateOfflineEarnings = (
|
||||
const calculateOfflineEarnings = (
|
||||
state: GameState,
|
||||
nowMs: number,
|
||||
): { offlineGold: number; offlineEssence: number; offlineSeconds: number } => {
|
||||
const elapsedSeconds = Math.min(
|
||||
(nowMs - state.lastTickAt) / 1000,
|
||||
MAX_OFFLINE_SECONDS,
|
||||
maxOfflineSeconds,
|
||||
);
|
||||
|
||||
const equipmentGoldMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped)
|
||||
.reduce((mult, e) => mult * (e.bonus.goldMultiplier ?? 1), 1);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for runtime nullable fields
|
||||
const equipmentGoldMultiplier = (state.equipment ?? []).
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
@@ -31,36 +49,44 @@ export const calculateOfflineEarnings = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgradeMultiplier = state.upgrades
|
||||
.filter(
|
||||
(u) =>
|
||||
u.purchased &&
|
||||
(u.target === "global" ||
|
||||
(u.target === "adventurer" && u.adventurerId === adventurer.id)),
|
||||
)
|
||||
.reduce((mult, u) => mult * u.multiplier, 1);
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isForAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id;
|
||||
const affectsAdventurer = isGlobal || isForAdventurer;
|
||||
return upgrade.purchased && affectsAdventurer;
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const prestige = state.prestige.productionMultiplier;
|
||||
|
||||
goldPerSecond +=
|
||||
adventurer.goldPerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesIncome *
|
||||
equipmentGoldMultiplier;
|
||||
const goldContribution
|
||||
= adventurer.goldPerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesIncome
|
||||
* equipmentGoldMultiplier;
|
||||
goldPerSecond = goldPerSecond + goldContribution;
|
||||
|
||||
essencePerSecond +=
|
||||
adventurer.essencePerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesEssence;
|
||||
const essenceContribution
|
||||
= adventurer.essencePerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesEssence;
|
||||
essencePerSecond = essencePerSecond + essenceContribution;
|
||||
}
|
||||
|
||||
return {
|
||||
offlineGold: goldPerSecond * elapsedSeconds,
|
||||
offlineEssence: essencePerSecond * elapsedSeconds,
|
||||
offlineGold: goldPerSecond * elapsedSeconds,
|
||||
offlineSeconds: elapsedSeconds,
|
||||
};
|
||||
};
|
||||
|
||||
export { calculateOfflineEarnings };
|
||||
|
||||
@@ -1,117 +1,318 @@
|
||||
/**
|
||||
* @file Prestige eligibility checks, runestone calculations, and post-prestige state builder.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* 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 { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||
import type {
|
||||
GameState,
|
||||
PrestigeData,
|
||||
PrestigeUpgradeCategory,
|
||||
} from "@elysium/types";
|
||||
import { INITIAL_GAME_STATE } from "../data/initialState.js";
|
||||
import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js";
|
||||
|
||||
const BASE_PRESTIGE_GOLD_THRESHOLD = 1_000_000;
|
||||
const THRESHOLD_SCALE_FACTOR = 5;
|
||||
const RUNESTONES_PER_PRESTIGE_LEVEL = 10;
|
||||
const MILESTONE_INTERVAL = 5;
|
||||
const MILESTONE_RUNESTONES_PER_INTERVAL = 25;
|
||||
const basePrestigeGoldThreshold = 1_000_000;
|
||||
const thresholdScaleFactor = 5;
|
||||
const runestonesPerPrestigeLevel = 10;
|
||||
const milestoneInterval = 5;
|
||||
const milestoneRunestonesPerInterval = 25;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
|
||||
* @param prestigeCount - The current number of prestiges completed.
|
||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||
* @returns The gold amount required to prestige.
|
||||
*/
|
||||
export const calculatePrestigeThreshold = (prestigeCount: number): number =>
|
||||
BASE_PRESTIGE_GOLD_THRESHOLD * Math.pow(THRESHOLD_SCALE_FACTOR, prestigeCount);
|
||||
const calculatePrestigeThreshold = (
|
||||
prestigeCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
basePrestigeGoldThreshold
|
||||
* Math.pow(thresholdScaleFactor, prestigeCount)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
export const isEligibleForPrestige = (state: GameState): boolean =>
|
||||
state.player.totalGoldEarned >= calculatePrestigeThreshold(state.prestige.count);
|
||||
/**
|
||||
* Returns true if the player has earned enough gold to prestige.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for a prestige reset.
|
||||
*/
|
||||
const isEligibleForPrestige = (state: GameState): boolean => {
|
||||
const thresholdMultiplier
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
return (
|
||||
state.player.totalGoldEarned
|
||||
>= calculatePrestigeThreshold(state.prestige.count, thresholdMultiplier)
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedUpgradeIds: string[],
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
category: PrestigeUpgradeCategory,
|
||||
): number =>
|
||||
DEFAULT_PRESTIGE_UPGRADES.filter(
|
||||
(u) => u.category === category && purchasedUpgradeIds.includes(u.id),
|
||||
).reduce((mult, u) => mult * u.multiplier, 1);
|
||||
): number => {
|
||||
return defaultPrestigeUpgrades.filter((upgrade) => {
|
||||
const matchesCategory = upgrade.category === category;
|
||||
const isPurchased = purchasedUpgradeIds.includes(upgrade.id);
|
||||
return matchesCategory && isPurchased;
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
export const computeRunestoneMultipliers = (
|
||||
purchasedUpgradeIds: string[],
|
||||
/**
|
||||
* Computes all four runestone multipliers from the purchased upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased prestige upgrade IDs.
|
||||
* @returns An object containing all four runestone multiplier values.
|
||||
*/
|
||||
const computeRunestoneMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): {
|
||||
runestonesIncomeMultiplier: number;
|
||||
runestonesClickMultiplier: number;
|
||||
runestonesIncomeMultiplier: number;
|
||||
runestonesClickMultiplier: number;
|
||||
runestonesEssenceMultiplier: number;
|
||||
runestonesCrystalMultiplier: number;
|
||||
} => ({
|
||||
runestonesIncomeMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "income"),
|
||||
runestonesClickMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "click"),
|
||||
runestonesEssenceMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "essence"),
|
||||
runestonesCrystalMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "crystals"),
|
||||
});
|
||||
} => {
|
||||
return {
|
||||
runestonesClickMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"click",
|
||||
),
|
||||
runestonesCrystalMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"crystals",
|
||||
),
|
||||
runestonesEssenceMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"essence",
|
||||
),
|
||||
runestonesIncomeMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"income",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
interface RunestoneParameters {
|
||||
totalGoldEarned: number;
|
||||
prestigeCount: number;
|
||||
purchasedUpgradeIds: Array<string>;
|
||||
echoRunestoneMultiplier?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates how many runestones the player earns from a prestige.
|
||||
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier
|
||||
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
|
||||
* @param parameters - The parameters for the runestone calculation.
|
||||
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
||||
* @param parameters.prestigeCount - The current prestige count.
|
||||
* @param parameters.purchasedUpgradeIds - The purchased prestige upgrade IDs.
|
||||
* @param parameters.echoRunestoneMultiplier - An optional echo-upgrade multiplier.
|
||||
* @returns The number of runestones earned.
|
||||
*/
|
||||
export const calculateRunestones = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: string[],
|
||||
): number => {
|
||||
const calculateRunestones = (parameters: RunestoneParameters): number => {
|
||||
const {
|
||||
totalGoldEarned,
|
||||
prestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
echoRunestoneMultiplier = 1,
|
||||
} = parameters;
|
||||
const threshold = calculatePrestigeThreshold(prestigeCount);
|
||||
const base =
|
||||
Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL;
|
||||
const runestoneMult = getCategoryMultiplier(purchasedUpgradeIds, "runestones");
|
||||
return Math.floor(base * runestoneMult);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevel;
|
||||
const runestoneMult = getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"runestones",
|
||||
);
|
||||
return Math.floor(base * runestoneMult * echoRunestoneMultiplier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the new prestige production multiplier.
|
||||
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
|
||||
* @param prestigeCount - The new prestige count.
|
||||
* @returns The production multiplier for the new prestige level.
|
||||
*/
|
||||
export const calculateProductionMultiplier = (prestigeCount: number): number =>
|
||||
Math.pow(1.15, prestigeCount);
|
||||
const calculateProductionMultiplier = (
|
||||
prestigeCount: number,
|
||||
): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the milestone runestone bonus for the given prestige count.
|
||||
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
|
||||
* @param prestigeCount - The prestige count after the current prestige.
|
||||
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
||||
*/
|
||||
export const calculateMilestoneBonus = (newPrestigeCount: number): number => {
|
||||
if (newPrestigeCount % MILESTONE_INTERVAL !== 0) return 0;
|
||||
const milestoneNumber = newPrestigeCount / MILESTONE_INTERVAL;
|
||||
return milestoneNumber * MILESTONE_RUNESTONES_PER_INTERVAL;
|
||||
const calculateMilestoneBonus = (prestigeCount: number): number => {
|
||||
if (prestigeCount % milestoneInterval !== 0) {
|
||||
return 0;
|
||||
}
|
||||
const milestoneNumber = prestigeCount / milestoneInterval;
|
||||
return milestoneNumber * milestoneRunestonesPerInterval;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the reset game state after a prestige.
|
||||
* Carries over prestige data and runestones; resets everything else.
|
||||
* @param currentState - The game state at the time of the prestige.
|
||||
* @param characterName - The player's character name to carry forward.
|
||||
* @returns The new game state, prestige data, and runestone counts.
|
||||
*/
|
||||
export const buildPostPrestigeState = (
|
||||
const buildPostPrestigeState = (
|
||||
currentState: GameState,
|
||||
characterName: string,
|
||||
): { newState: GameState; newPrestigeData: PrestigeData; runestonesEarned: number; milestoneRunestones: number } => {
|
||||
const runestonesEarned = calculateRunestones(
|
||||
currentState.player.totalGoldEarned,
|
||||
currentState.prestige.count,
|
||||
currentState.prestige.purchasedUpgradeIds,
|
||||
);
|
||||
const newPrestigeCount = currentState.prestige.count + 1;
|
||||
const { purchasedUpgradeIds } = currentState.prestige;
|
||||
const milestoneRunestones = calculateMilestoneBonus(newPrestigeCount);
|
||||
|
||||
const newPrestigeData: PrestigeData = {
|
||||
count: newPrestigeCount,
|
||||
runestones: currentState.prestige.runestones + runestonesEarned + milestoneRunestones,
|
||||
productionMultiplier: calculateProductionMultiplier(newPrestigeCount),
|
||||
): {
|
||||
prestigeState: GameState;
|
||||
prestigeData: PrestigeData;
|
||||
runestonesEarned: number;
|
||||
milestoneRunestones: number;
|
||||
} => {
|
||||
const {
|
||||
autoPrestigeEnabled,
|
||||
count: currentPrestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
lastPrestigedAt: Date.now(),
|
||||
runestones: currentRunestones,
|
||||
} = currentState.prestige;
|
||||
const echoRunestoneMultiplier
|
||||
= currentState.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
|
||||
const runestonesEarned = calculateRunestones({
|
||||
echoRunestoneMultiplier: echoRunestoneMultiplier,
|
||||
prestigeCount: currentPrestigeCount,
|
||||
purchasedUpgradeIds: purchasedUpgradeIds,
|
||||
totalGoldEarned: currentState.player.totalGoldEarned,
|
||||
});
|
||||
const updatedPrestigeCount = currentPrestigeCount + 1;
|
||||
const milestoneRunestones = calculateMilestoneBonus(updatedPrestigeCount);
|
||||
|
||||
const prestigeData: PrestigeData = {
|
||||
count: updatedPrestigeCount,
|
||||
lastPrestigedAt: Date.now(),
|
||||
productionMultiplier: calculateProductionMultiplier(updatedPrestigeCount),
|
||||
purchasedUpgradeIds: purchasedUpgradeIds,
|
||||
runestones:
|
||||
currentRunestones + runestonesEarned + milestoneRunestones,
|
||||
...computeRunestoneMultipliers(purchasedUpgradeIds),
|
||||
...(currentState.prestige.autoPrestigeEnabled !== undefined
|
||||
? { autoPrestigeEnabled: currentState.prestige.autoPrestigeEnabled }
|
||||
: {}),
|
||||
...autoPrestigeEnabled === undefined
|
||||
? {}
|
||||
: { autoPrestigeEnabled },
|
||||
};
|
||||
|
||||
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
|
||||
const newState: GameState = {
|
||||
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 = {
|
||||
...freshState,
|
||||
prestige: newPrestigeData,
|
||||
|
||||
// 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(),
|
||||
|
||||
/*
|
||||
* 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
|
||||
...currentState.codex === undefined
|
||||
? {}
|
||||
: { codex: currentState.codex },
|
||||
// Transcendence data is permanent — never wiped by prestige
|
||||
...currentState.transcendence === undefined
|
||||
? {}
|
||||
: { transcendence: currentState.transcendence },
|
||||
// Apotheosis data is eternal — never wiped by prestige
|
||||
...currentState.apotheosis === undefined
|
||||
? {}
|
||||
: { apotheosis: currentState.apotheosis },
|
||||
// Story chapter progress is permanent — survives all resets
|
||||
...currentState.story === undefined
|
||||
? {}
|
||||
: { story: currentState.story },
|
||||
};
|
||||
|
||||
return { newState, newPrestigeData, runestonesEarned, milestoneRunestones };
|
||||
return {
|
||||
milestoneRunestones,
|
||||
prestigeData,
|
||||
prestigeState,
|
||||
runestonesEarned,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostPrestigeState,
|
||||
calculateMilestoneBonus,
|
||||
calculatePrestigeThreshold,
|
||||
calculateProductionMultiplier,
|
||||
calculateRunestones,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file Title unlock logic for checking and awarding in-game titles.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
interface TitleCheckParameters {
|
||||
currentUnlocked: Array<string>;
|
||||
state: GameState;
|
||||
guildName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which titles the player has newly earned and returns their IDs.
|
||||
* @param parameters - The parameters for the title check.
|
||||
* @param parameters.currentUnlocked - The array of already-unlocked title IDs.
|
||||
* @param parameters.state - The current game state.
|
||||
* @param parameters.guildName - The player's current guild name.
|
||||
* @param parameters.createdAt - The timestamp (ms) when the player account was created.
|
||||
* @returns An array of newly unlocked title IDs.
|
||||
*/
|
||||
const checkAndUnlockTitles = (
|
||||
parameters: TitleCheckParameters,
|
||||
): Array<string> => {
|
||||
const { currentUnlocked, state, guildName, createdAt } = parameters;
|
||||
const metrics: Record<string, number | boolean> = {
|
||||
achievementsUnlocked: state.achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length,
|
||||
adventurerTotal: state.adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0),
|
||||
apotheosisCount: state.apotheosis?.count ?? 0,
|
||||
bossesDefeated: state.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length,
|
||||
guildFounded: guildName.trim().length > 0,
|
||||
playedDays: Math.floor((Date.now() - createdAt) / 86_400_000),
|
||||
prestigeCount: state.prestige.count,
|
||||
questsCompleted: state.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length,
|
||||
totalClicks: state.player.totalClicks,
|
||||
totalGoldEarned: state.player.totalGoldEarned,
|
||||
transcendenceCount: state.transcendence?.count ?? 0,
|
||||
};
|
||||
|
||||
const newlyUnlocked: Array<string> = [];
|
||||
|
||||
for (const title of gameTitles) {
|
||||
if (currentUnlocked.includes(title.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { type, amount } = title.condition;
|
||||
let earned = false;
|
||||
|
||||
if (type === "guildFounded") {
|
||||
earned = metrics.guildFounded === true;
|
||||
} else if (amount !== undefined) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- metrics[type] is number when type is not guildFounded */
|
||||
earned = (metrics[type] as number) >= amount;
|
||||
}
|
||||
|
||||
if (earned) {
|
||||
newlyUnlocked.push(title.id);
|
||||
}
|
||||
}
|
||||
|
||||
return newlyUnlocked;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the raw unlocked titles value from the database into a string array.
|
||||
* @param raw - The raw value from the database (may be any type).
|
||||
* @returns An array of title ID strings.
|
||||
*/
|
||||
const parseUnlockedTitles = (raw: unknown): Array<string> => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((item): item is string => {
|
||||
return typeof item === "string";
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export { checkAndUnlockTitles, parseUnlockedTitles };
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file Transcendence eligibility checks, echo calculations, and post-transcendence state builder.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||
import type {
|
||||
GameState,
|
||||
TranscendenceData,
|
||||
TranscendenceUpgradeCategory,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* ID of the boss that must be defeated to unlock transcendence.
|
||||
*/
|
||||
const finalBossId = "the_absolute_one";
|
||||
|
||||
/**
|
||||
* Base constant used in the echo yield formula.
|
||||
*/
|
||||
const echoFormulaConstant = 853;
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedIds: Array<string>,
|
||||
category: TranscendenceUpgradeCategory,
|
||||
): number => {
|
||||
return defaultTranscendenceUpgrades.filter((upgrade) => {
|
||||
return upgrade.category === category && purchasedIds.includes(upgrade.id);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all transcendence multipliers from the purchased upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased transcendence upgrade IDs.
|
||||
* @returns An object containing all transcendence multiplier values.
|
||||
*/
|
||||
const computeTranscendenceMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Omit<TranscendenceData, "count" | "echoes" | "purchasedUpgradeIds"> => {
|
||||
return {
|
||||
echoCombatMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"combat",
|
||||
),
|
||||
echoIncomeMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"income",
|
||||
),
|
||||
echoMetaMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"echo_meta",
|
||||
),
|
||||
echoPrestigeRunestoneMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"prestige_runestones",
|
||||
),
|
||||
echoPrestigeThresholdMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"prestige_threshold",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when the player is eligible to transcend:
|
||||
* they must have defeated the final boss at least once.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for transcendence.
|
||||
*/
|
||||
const isEligibleForTranscendence = (state: GameState): boolean => {
|
||||
return state.bosses.some((boss) => {
|
||||
return boss.id === finalBossId && boss.status === "defeated";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates echo yield for a transcendence.
|
||||
* Formula: floor(CONSTANT / sqrt(prestigeCount)) × echoMetaMultiplier.
|
||||
* Fewer prestiges = more echoes (rewards efficient play).
|
||||
* Minimum prestige count of 1 is enforced to avoid division by zero.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param echoMetaMultiplier - The echo meta multiplier from transcendence upgrades.
|
||||
* @returns The number of echoes earned.
|
||||
*/
|
||||
const calculateEchoes = (
|
||||
prestigeCount: number,
|
||||
echoMetaMultiplier: number,
|
||||
): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
const baseEchoes = echoFormulaConstant / Math.sqrt(safeCount);
|
||||
return Math.floor(baseEchoes * echoMetaMultiplier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the permanent-data spread objects that survive a transcendence reset.
|
||||
* @param currentState - The game state at the time of transcendence.
|
||||
* @param transcendenceData - The newly-computed transcendence data to carry forward.
|
||||
* @returns A partial GameState object containing all data that persists through transcendence.
|
||||
*/
|
||||
const buildPermanentSpreads = (
|
||||
currentState: GameState,
|
||||
transcendenceData: TranscendenceData,
|
||||
): Partial<GameState> => {
|
||||
return {
|
||||
transcendence: transcendenceData,
|
||||
...currentState.codex === undefined
|
||||
? {}
|
||||
: { codex: currentState.codex },
|
||||
...currentState.apotheosis === undefined
|
||||
? {}
|
||||
: { apotheosis: currentState.apotheosis },
|
||||
...currentState.story === undefined
|
||||
? {}
|
||||
: { story: currentState.story },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the new game state after a transcendence (nuclear reset).
|
||||
* Wipes everything except codex, dailyChallenges, and transcendence data.
|
||||
* @param currentState - The game state at the time of transcendence.
|
||||
* @param characterName - The player's character name to carry forward.
|
||||
* @returns The new game state, transcendence data, and echoes earned.
|
||||
*/
|
||||
const buildPostTranscendenceState = (
|
||||
currentState: GameState,
|
||||
characterName: string,
|
||||
): {
|
||||
transcendenceState: GameState;
|
||||
transcendenceData: TranscendenceData;
|
||||
echoesEarned: number;
|
||||
} => {
|
||||
const previousTranscendence = currentState.transcendence;
|
||||
const echoMetaMultiplier = previousTranscendence?.echoMetaMultiplier ?? 1;
|
||||
|
||||
const echoesEarned = calculateEchoes(
|
||||
currentState.prestige.count,
|
||||
echoMetaMultiplier,
|
||||
);
|
||||
const previousEchoes = previousTranscendence?.echoes ?? 0;
|
||||
const updatedCount = (previousTranscendence?.count ?? 0) + 1;
|
||||
const updatedPurchasedIds = previousTranscendence?.purchasedUpgradeIds ?? [];
|
||||
|
||||
const transcendenceData: TranscendenceData = {
|
||||
count: updatedCount,
|
||||
echoes: previousEchoes + echoesEarned,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...computeTranscendenceMultipliers(updatedPurchasedIds),
|
||||
};
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
const transcendenceState: GameState = {
|
||||
...freshState,
|
||||
lastTickAt: Date.now(),
|
||||
...buildPermanentSpreads(currentState, transcendenceData),
|
||||
};
|
||||
|
||||
return { echoesEarned, transcendenceData, transcendenceState };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostTranscendenceState,
|
||||
calculateEchoes,
|
||||
computeTranscendenceMultipliers,
|
||||
isEligibleForTranscendence,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file Discord webhook and role-grant utilities for milestone events.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* 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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Fails silently so role grant errors do not affect the game action.
|
||||
* @param discordId - The Discord user ID to grant the role to.
|
||||
* @returns A promise that resolves when the role grant attempt completes.
|
||||
*/
|
||||
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
if (botToken === undefined || botToken === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bot ${botToken}`,
|
||||
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
} 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
|
||||
}
|
||||
};
|
||||
|
||||
type MilestoneType = "prestige" | "transcendence" | "apotheosis";
|
||||
|
||||
interface MilestoneCounts {
|
||||
prestige: number;
|
||||
transcendence: number;
|
||||
apotheosis: number;
|
||||
}
|
||||
|
||||
const milestoneVerbs: Record<MilestoneType, string> = {
|
||||
apotheosis: "reached apotheosis",
|
||||
prestige: "prestiged",
|
||||
transcendence: "transcended",
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts a milestone announcement to the configured Discord webhook.
|
||||
* Fails silently so webhook errors do not affect the game action.
|
||||
* @param discordId - The Discord user ID of the player.
|
||||
* @param milestone - The type of milestone reached.
|
||||
* @param counts - The current prestige, transcendence, and apotheosis counts.
|
||||
* @returns A promise that resolves when the webhook post attempt completes.
|
||||
*/
|
||||
const postMilestoneWebhook = async(
|
||||
discordId: string,
|
||||
milestone: MilestoneType,
|
||||
counts: MilestoneCounts,
|
||||
): Promise<void> => {
|
||||
const webhookUrl = process.env.DISCORD_MILESTONE_WEBHOOK;
|
||||
if (webhookUrl === undefined || webhookUrl === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const verb = milestoneVerbs[milestone];
|
||||
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
|
||||
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
|
||||
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
body: JSON.stringify({
|
||||
content: content,
|
||||
flags: suppressNotifications,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
} 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
|
||||
}
|
||||
};
|
||||
|
||||
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
|
||||
@@ -1 +1,10 @@
|
||||
export type HonoEnv = { Variables: { discordId: string } };
|
||||
/**
|
||||
* @file Hono environment type definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Variables is required by Hono */
|
||||
export interface HonoEnvironment {
|
||||
Variables: { discordId: string };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* 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/jwt.js", () => ({
|
||||
verifyToken: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authMiddleware", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const makeApp = async () => {
|
||||
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
const app = new Hono<{ Variables: { discordId: string } }>();
|
||||
app.use("*", authMiddleware);
|
||||
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
||||
return { app, verifyToken };
|
||||
};
|
||||
|
||||
it("returns 401 when Authorization header is missing", async () => {
|
||||
const { app } = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/test"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 when Authorization header does not start with Bearer", async () => {
|
||||
const { app } = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/test", {
|
||||
headers: { Authorization: "Basic abc123" },
|
||||
}));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("sets discordId in context when token is valid", async () => {
|
||||
const { app, verifyToken } = await makeApp();
|
||||
vi.mocked(verifyToken).mockReturnValueOnce({ discordId: "user_123", iat: 0, exp: 9999999999 });
|
||||
const res = await app.fetch(new Request("http://localhost/test", {
|
||||
headers: { Authorization: "Bearer valid_token" },
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { discordId: string };
|
||||
expect(body.discordId).toBe("user_123");
|
||||
});
|
||||
|
||||
it("returns 401 when verifyToken throws", async () => {
|
||||
const { app, verifyToken } = await makeApp();
|
||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||
throw new Error("Invalid token");
|
||||
});
|
||||
const res = await app.fetch(new Request("http://localhost/test", {
|
||||
headers: { Authorization: "Bearer bad_token" },
|
||||
}));
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
describe("about route", () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
const makeApp = async () => {
|
||||
const { aboutRouter } = await import("../../src/routes/about.js");
|
||||
const app = new Hono();
|
||||
app.route("/about", aboutRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
it("returns releases from a successful fetch", async () => {
|
||||
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
|
||||
const app = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/about"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { releases: unknown[] };
|
||||
expect(body.releases).toEqual(releases);
|
||||
});
|
||||
|
||||
it("returns empty releases when fetch is not ok", async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false });
|
||||
const app = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/about"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { releases: unknown[] };
|
||||
expect(body.releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty releases when fetch throws", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
const app = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/about"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { releases: unknown[] };
|
||||
expect(body.releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns cached releases on second call within TTL", async () => {
|
||||
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
|
||||
const app = await makeApp();
|
||||
// First call populates cache
|
||||
await app.fetch(new Request("http://localhost/about"));
|
||||
// Second call should use cache, not call fetch again
|
||||
const res = await app.fetch(new Request("http://localhost/about"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("includes apiVersion in response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
const app = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/about"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { apiVersion: string };
|
||||
expect(typeof body.apiVersion).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/webhook.js", () => ({
|
||||
grantApotheosisRole: vi.fn().mockResolvedValue(undefined),
|
||||
postMilestoneWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("apotheosis route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { player: { update: ReturnType<typeof vi.fn> }; gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { apotheosisRouter } = await import("../../src/routes/apotheosis.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
});
|
||||
|
||||
const post = (path = "/apotheosis") =>
|
||||
app.fetch(new Request(`http://localhost${path}`, { method: "POST" }));
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post();
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when not eligible for apotheosis", async () => {
|
||||
// State without all transcendence upgrades purchased
|
||||
const state = makeState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post();
|
||||
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 () => {
|
||||
// Need all 15 transcendence upgrades purchased for eligibility
|
||||
const allUpgradeIds = [
|
||||
"echo_income_1", "echo_income_2", "echo_income_3", "echo_income_4", "echo_income_5",
|
||||
"echo_combat_1", "echo_combat_2", "echo_combat_3",
|
||||
"echo_prestige_threshold_1", "echo_prestige_threshold_2",
|
||||
"echo_prestige_runestones_1", "echo_prestige_runestones_2",
|
||||
"echo_meta_1", "echo_meta_2", "echo_meta_3",
|
||||
];
|
||||
const state = makeState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: allUpgradeIds,
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { apotheosisCount: number };
|
||||
expect(body.apotheosisCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
gameState: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/discord.js", () => ({
|
||||
buildOAuthUrl: vi.fn(),
|
||||
exchangeCode: vi.fn(),
|
||||
fetchDiscordUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/jwt.js", () => ({
|
||||
signToken: vi.fn().mockReturnValue("test_jwt"),
|
||||
}));
|
||||
|
||||
describe("auth route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env["CORS_ORIGIN"] = "http://localhost:5173";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["CORS_ORIGIN"];
|
||||
});
|
||||
|
||||
const makeApp = async () => {
|
||||
const { authRouter } = await import("../../src/routes/auth.js");
|
||||
const { buildOAuthUrl, exchangeCode, fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||
const { prisma } = await import("../../src/db/client.js");
|
||||
const app = new Hono();
|
||||
app.route("/auth", authRouter);
|
||||
return { app, buildOAuthUrl: vi.mocked(buildOAuthUrl), exchangeCode: vi.mocked(exchangeCode), fetchDiscordUser: vi.mocked(fetchDiscordUser), prisma };
|
||||
};
|
||||
|
||||
describe("GET /url", () => {
|
||||
it("returns the OAuth URL when buildOAuthUrl succeeds", async () => {
|
||||
const { app, buildOAuthUrl } = await makeApp();
|
||||
buildOAuthUrl.mockReturnValueOnce("https://discord.com/oauth2/authorize?...");
|
||||
const res = await app.fetch(new Request("http://localhost/auth/url"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { url: string };
|
||||
expect(body.url).toContain("discord.com");
|
||||
});
|
||||
|
||||
it("returns 500 when buildOAuthUrl throws", async () => {
|
||||
const { app, buildOAuthUrl } = await makeApp();
|
||||
buildOAuthUrl.mockImplementationOnce(() => { throw new Error("Missing env"); });
|
||||
const res = await app.fetch(new Request("http://localhost/auth/url"));
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /callback", () => {
|
||||
it("returns 400 when code parameter is missing", async () => {
|
||||
const { app } = await makeApp();
|
||||
const res = await app.fetch(new Request("http://localhost/auth/callback"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("redirects with isNew=true for a new user", async () => {
|
||||
const { app, exchangeCode, fetchDiscordUser, prisma } = await makeApp();
|
||||
exchangeCode.mockResolvedValueOnce({ access_token: "token" });
|
||||
fetchDiscordUser.mockResolvedValueOnce({ id: "new_user", username: "Newbie", discriminator: "0", avatar: null });
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
const createdPlayer = {
|
||||
discordId: "new_user", username: "Newbie", discriminator: "0", avatar: null,
|
||||
characterName: "Newbie", createdAt: 0, lastSavedAt: 0,
|
||||
totalGoldEarned: 0, totalClicks: 0, lifetimeGoldEarned: 0, lifetimeClicks: 0,
|
||||
lifetimeBossesDefeated: 0, lifetimeQuestsCompleted: 0,
|
||||
lifetimeAdventurersRecruited: 0, lifetimeAchievementsUnlocked: 0,
|
||||
};
|
||||
vi.mocked(prisma.player.create).mockResolvedValueOnce(createdPlayer as never);
|
||||
vi.mocked(prisma.gameState.create).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/auth/callback?code=auth_code"));
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get("Location") ?? "";
|
||||
expect(location).toContain("isNew=true");
|
||||
expect(location).toContain("token=test_jwt");
|
||||
});
|
||||
|
||||
it("redirects with isNew=false for an existing user", async () => {
|
||||
const { app, exchangeCode, fetchDiscordUser, prisma } = await makeApp();
|
||||
exchangeCode.mockResolvedValueOnce({ access_token: "token" });
|
||||
fetchDiscordUser.mockResolvedValueOnce({ id: "existing_user", username: "OldTimer", discriminator: "0", avatar: null });
|
||||
const existingPlayer = { discordId: "existing_user", username: "OldTimer", discriminator: "0", avatar: null, characterName: "OldTimer" };
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(existingPlayer as never);
|
||||
const updatedPlayer = { ...existingPlayer, discordId: "existing_user" };
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce(updatedPlayer as never);
|
||||
const res = await app.fetch(new Request("http://localhost/auth/callback?code=auth_code"));
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get("Location") ?? "";
|
||||
expect(location).toContain("isNew=false");
|
||||
});
|
||||
|
||||
it("redirects with error when callback throws", async () => {
|
||||
const { app, exchangeCode } = await makeApp();
|
||||
exchangeCode.mockRejectedValueOnce(new Error("OAuth failed"));
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeBoss = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_boss",
|
||||
zoneId: "test_zone",
|
||||
status: "available",
|
||||
prestigeRequirement: 0,
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
damagePerSecond: 1,
|
||||
goldReward: 50,
|
||||
essenceReward: 10,
|
||||
crystalReward: 0,
|
||||
upgradeRewards: [] as string[],
|
||||
equipmentRewards: [] as string[],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAdventurer = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_adventurer",
|
||||
count: 1,
|
||||
combatPower: 10000, // Very high DPS to guarantee win
|
||||
level: 10,
|
||||
unlocked: true,
|
||||
goldPerSecond: 1,
|
||||
essencePerSecond: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("boss route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { bossRouter } = await import("../../src/routes/boss.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/boss", bossRouter);
|
||||
});
|
||||
|
||||
const challenge = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/boss/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when bossId is missing", async () => {
|
||||
const res = await challenge({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when boss is not in state", async () => {
|
||||
const state = makeState({ bosses: [] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when boss is already defeated", async () => {
|
||||
const state = makeState({ bosses: [makeBoss({ status: "defeated" })] as GameState["bosses"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 403 when prestige requirement is not met", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ prestigeRequirement: 5 })] as GameState["bosses"],
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 400 when party has no adventurers", async () => {
|
||||
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns won=true when party defeats boss", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer({ combatPower: 10000, count: 1, level: 10 })] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked" }] as GameState["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: { gold: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.gold).toBe(50);
|
||||
});
|
||||
|
||||
it("returns won=false when party is defeated", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 })] as GameState["bosses"],
|
||||
// Include an adventurer with count=0 to cover the casualty-loop skip branch
|
||||
adventurers: [
|
||||
makeAdventurer({ combatPower: 1, count: 10, level: 1 }),
|
||||
makeAdventurer({ id: "zero_count_adventurer", combatPower: 0, count: 0, level: 1 }),
|
||||
] as GameState["adventurers"],
|
||||
});
|
||||
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; casualties: Array<{ adventurerId: string }> };
|
||||
expect(body.won).toBe(false);
|
||||
expect(Array.isArray(body.casualties)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips zone unlock when zone is already unlocked and bossId matches", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
// Zone is already unlocked — the loop should skip it via the status==="unlocked" continue
|
||||
zones: [{ id: "test_zone", status: "unlocked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
});
|
||||
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 };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips zone unlock when quest condition is not satisfied", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
// Zone has unlockBossId matching but the required quest is not completed
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: "required_quest" }] as GameState["zones"],
|
||||
quests: [{ id: "required_quest", status: "active" }] as GameState["quests"],
|
||||
});
|
||||
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 };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks next zone boss when boss is defeated and zone condition is met", async () => {
|
||||
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0 });
|
||||
const state = makeState({
|
||||
bosses: [
|
||||
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
|
||||
nextBoss,
|
||||
] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
});
|
||||
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 };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("handles boss with upgrade and equipment rewards on win", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({
|
||||
upgradeRewards: ["some_upgrade"],
|
||||
equipmentRewards: ["some_equipment"],
|
||||
})] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
upgrades: [{ id: "some_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 }] as GameState["upgrades"],
|
||||
equipment: [{ id: "some_equipment", owned: false, equipped: false, type: "weapon", bonus: {} }] as GameState["equipment"],
|
||||
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: { upgradeIds: string[]; equipmentIds: string[] } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.upgradeIds).toContain("some_upgrade");
|
||||
expect(body.rewards.equipmentIds).toContain("some_equipment");
|
||||
});
|
||||
|
||||
it("updates daily challenge progress on boss defeat", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss()] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [],
|
||||
dailyChallenges: {
|
||||
date: "2024-01-01",
|
||||
challenges: [{ id: "boss_challenge", type: "bossesDefeated", target: 3, progress: 0, completed: false, crystalReward: 5 }],
|
||||
} as GameState["dailyChallenges"],
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it("applies adventurer-specific upgrade to party DPS", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer({ id: "test_adventurer" })] as GameState["adventurers"],
|
||||
upgrades: [{ id: "adv_upgrade", purchased: true, unlocked: true, target: "adventurer", adventurerId: "test_adventurer", multiplier: 2 }] as GameState["upgrades"],
|
||||
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 };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("applies global upgrade multiplier to party DPS when global upgrade is purchased", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer({ combatPower: 10000, count: 1 })] as GameState["adventurers"],
|
||||
upgrades: [{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 }] as GameState["upgrades"],
|
||||
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 };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks zone when boss defeated and quest condition is also satisfied", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: "test_quest" }] as GameState["zones"],
|
||||
quests: [{ id: "test_quest", status: "completed" }] as GameState["quests"],
|
||||
});
|
||||
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 };
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// heartwood_tincture requires 5 verdant_sap + 3 forest_crystal
|
||||
const TEST_RECIPE_ID = "heartwood_tincture";
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 10 }, { materialId: "forest_crystal", quantity: 5 }],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("craft route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { craftRouter } = await import("../../src/routes/craft.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/craft", craftRouter);
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/craft", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when recipeId is missing", async () => {
|
||||
const res = await post({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown recipe", async () => {
|
||||
const res = await post({ recipeId: "nonexistent_recipe" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when no exploration state exists", async () => {
|
||||
const state = makeState({ exploration: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when recipe is already crafted", async () => {
|
||||
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [TEST_RECIPE_ID], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough materials", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 1 }], // needs 5
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when second material is completely absent from list", async () => {
|
||||
// verdant_sap present (enough), but forest_crystal absent entirely — quantity ?? 0 = 0
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 10 }],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns craft result on success", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { recipeId: string; bonusType: string };
|
||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||
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
@@ -0,0 +1,529 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// verdant_meadow is the first area in verdant_vale zone
|
||||
const TEST_AREA_ID = "verdant_meadow";
|
||||
const TEST_ZONE_ID = "verdant_vale";
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [{ id: TEST_ZONE_ID, status: "unlocked" }] as GameState["zones"],
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "available", completedOnce: false }] as GameState["exploration"]["areas"],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("explore route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { exploreRouter } = await import("../../src/routes/explore.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/explore", exploreRouter);
|
||||
});
|
||||
|
||||
const postStart = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/explore/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
const postCollect = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/explore/collect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postStart({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await postStart({ areaId: "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 postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when zone is not unlocked", async () => {
|
||||
const state = makeState({ zones: [{ id: TEST_ZONE_ID, status: "locked" }] as GameState["zones"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when area is not found in state", async () => {
|
||||
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when another exploration is already in progress", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "available" }, { id: "other_area", status: "in_progress" }] 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 postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when area is locked", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "locked" }] 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 postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("starts exploration and returns endsAt on success", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { areaId: string; endsAt: number };
|
||||
expect(body.areaId).toBe(TEST_AREA_ID);
|
||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("backfills exploration state for old saves without exploration", async () => {
|
||||
const state = makeState({ exploration: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
// Even with backfilled state, verdant_meadow may not be available initially — just check the route runs
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
// Should not be a 500; either 200 or a game-logic error
|
||||
expect(res.status).not.toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /collect", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postCollect({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await postCollect({ areaId: "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 postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when no exploration state exists", async () => {
|
||||
const state = makeState({ exploration: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when area is not found in state", async () => {
|
||||
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when area is not in progress", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when exploration is not yet complete", async () => {
|
||||
const now = Date.now();
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 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 postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("collects exploration results when complete", async () => {
|
||||
// Set startedAt far in the past so it's definitely complete
|
||||
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);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; materialsFound: unknown[] };
|
||||
expect(typeof body.foundNothing).toBe("boolean");
|
||||
expect(Array.isArray(body.materialsFound)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns foundNothing=true when random triggers the nothing path", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// First call: the nothing probability check (< 0.2 triggers nothing)
|
||||
mockRandom.mockReturnValueOnce(0.1);
|
||||
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);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; nothingMessage: string };
|
||||
expect(body.foundNothing).toBe(true);
|
||||
expect(typeof body.nothingMessage).toBe("string");
|
||||
mockRandom.mockRestore();
|
||||
});
|
||||
|
||||
it("applies gold_loss event and pushes new material from possibleMaterials", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// verdant_meadow events: [gold_gain(0), gold_loss(1), material_gain(2), essence_gain(3)]
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: 0.5 >= 0.2 → proceed
|
||||
.mockReturnValueOnce(0.26) // event: Math.floor(0.26 * 4) = 1 → gold_loss
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 3 = 0, 0 - 3 = -3 ≤ 0 → verdant_sap
|
||||
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 1 = 1
|
||||
const state = makeState({
|
||||
resources: { gold: 100, essence: 0, crystals: 0, runestones: 0 },
|
||||
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);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; event: { goldChange: number }; materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event.goldChange).toBeLessThan(0);
|
||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||
mockRandom.mockRestore();
|
||||
});
|
||||
|
||||
it("applies essence_gain event during exploration collect", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.76) // event: Math.floor(0.76 * 4) = 3 → essence_gain
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
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);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { essenceChange: number } };
|
||||
expect(body.event.essenceChange).toBeGreaterThan(0);
|
||||
mockRandom.mockRestore();
|
||||
});
|
||||
|
||||
it("pushes new material via material_gain event when material not already in list", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.51) // event: Math.floor(0.51 * 4) = 2 → material_gain (verdant_sap qty=2)
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
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);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
|
||||
expect(body.event.materialGained?.materialId).toBe("verdant_sap");
|
||||
mockRandom.mockRestore();
|
||||
});
|
||||
|
||||
it("increments existing material quantity via material_gain event", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.51) // event: Math.floor(0.51 * 4) = 2 → material_gain (verdant_sap qty=2)
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 5 }],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
|
||||
expect(body.event.materialGained?.materialId).toBe("verdant_sap");
|
||||
mockRandom.mockRestore();
|
||||
});
|
||||
|
||||
it("increments existing material quantity when material already in list", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// verdant_meadow has 4 events (indices 0-3), 1 possibleMaterial (verdant_sap, weight=3)
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: 0.5 >= 0.2 → proceed
|
||||
.mockReturnValueOnce(0) // event selection: Math.floor(0 * 4) = 0 → gold_gain (index 0)
|
||||
.mockReturnValueOnce(0) // material roll: 0 * 3 = 0, then 0 - 3 = -3 <= 0 → verdant_sap selected
|
||||
.mockReturnValueOnce(0); // quantity roll: Math.floor(0 * 3) + 1 = 1
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 5 }],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,578 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { findUnique: vi.fn(), update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/discord.js", () => ({
|
||||
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: Date.now() - 60_000, // 60 seconds ago
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
||||
discordId: DISCORD_ID,
|
||||
characterName: "T",
|
||||
username: "u",
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
createdAt: Date.now(),
|
||||
lastSavedAt: 0,
|
||||
totalGoldEarned: 0,
|
||||
totalClicks: 0,
|
||||
lifetimeGoldEarned: 0,
|
||||
lifetimeClicks: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
lifetimeAdventurersRecruited: 0,
|
||||
lifetimeAchievementsUnlocked: 0,
|
||||
loginStreak: 1,
|
||||
lastLoginDate: null,
|
||||
unlockedTitles: null,
|
||||
guildName: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("game route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env["ANTI_CHEAT_SECRET"];
|
||||
const { gameRouter } = await import("../../src/routes/game.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/game", gameRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["ANTI_CHEAT_SECRET"];
|
||||
});
|
||||
|
||||
describe("GET /load", () => {
|
||||
it("returns 404 when neither game state nor player exists", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("creates fresh state when game state is missing but player exists", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.create).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { offlineGold: number; schemaOutdated: boolean };
|
||||
expect(body.offlineGold).toBe(0);
|
||||
expect(body.schemaOutdated).toBe(false);
|
||||
});
|
||||
|
||||
it("returns state with offline earnings when game state exists", async () => {
|
||||
const state = makeState({ lastTickAt: Date.now() - 10_000 }); // 10 seconds ago
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never).mockRejectedValueOnce(Object.assign(new Error("conflict"), { code: "P2034" }));
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState; offlineSeconds: number; currentSchemaVersion: number };
|
||||
expect(body.currentSchemaVersion).toBe(CURRENT_SCHEMA_VERSION);
|
||||
expect(typeof body.offlineSeconds).toBe("number");
|
||||
});
|
||||
|
||||
it("awards login bonus when player logs in on a new day", async () => {
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: yesterday, loginStreak: 3 }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { loginBonus: { streak: number; goldEarned: number } | null };
|
||||
expect(body.loginBonus).not.toBeNull();
|
||||
expect(body.loginBonus?.streak).toBe(4);
|
||||
});
|
||||
|
||||
it("resets streak when login gap is more than one day", async () => {
|
||||
const longAgo = "2020-01-01";
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: longAgo, loginStreak: 10 }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { loginBonus: { streak: number } | null };
|
||||
expect(body.loginBonus?.streak).toBe(1);
|
||||
});
|
||||
|
||||
it("does not award login bonus when already logged in today", 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 }) as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { loginBonus: null };
|
||||
expect(body.loginBonus).toBeNull();
|
||||
});
|
||||
|
||||
it("includes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
expect(typeof body.signature).toBe("string");
|
||||
});
|
||||
|
||||
it("marks schema as outdated when save has older schema version", async () => {
|
||||
const state = makeState({ schemaVersion: 0 });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { schemaOutdated: boolean };
|
||||
expect(body.schemaOutdated).toBe(true);
|
||||
});
|
||||
|
||||
it("returns non-zero offline earnings when adventurers have production stats", async () => {
|
||||
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||
const state = makeState({
|
||||
adventurers: [{
|
||||
id: "worker", count: 1, unlocked: true, level: 1,
|
||||
goldPerSecond: 1, essencePerSecond: 1, combatPower: 0,
|
||||
}] as GameState["adventurers"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||
makePlayer({ lastLoginDate: todayUTC }) as never,
|
||||
);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { offlineGold: number; offlineEssence: number };
|
||||
expect(body.offlineGold).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", () => {
|
||||
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 400 when state is missing from body", async () => {
|
||||
const res = await save({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 when save schema version is outdated", async () => {
|
||||
const state = makeState({ schemaVersion: 0 });
|
||||
const res = await save({ state });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("saves state when no previous record exists", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const state = makeState();
|
||||
const res = await save({ state });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { savedAt: number };
|
||||
expect(body.savedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to state characterName when playerRecord is null", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const state = makeState();
|
||||
const res = await save({ state });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("validates and sanitizes state when previous record exists", async () => {
|
||||
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
||||
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects save with wrong HMAC signature when secret is configured", async () => {
|
||||
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
|
||||
const prevState = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
const res = await save({ state: makeState(), signature: "wrong_signature" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts save with correct HMAC signature", async () => {
|
||||
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
|
||||
const { createHmac } = await import("node:crypto");
|
||||
const prevState = makeState();
|
||||
const correctSig = createHmac("sha256", "my_secret").update(JSON.stringify(prevState)).digest("hex");
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: makeState(), signature: correctSig });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("unlocks new titles and persists them", async () => {
|
||||
const prevState = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ guildName: "My Guild" }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: makeState() });
|
||||
expect(res.status).toBe(200);
|
||||
// Just verifies the route completes without error when title checking runs
|
||||
});
|
||||
|
||||
it("exercises all validateAndSanitize branches with rich state", async () => {
|
||||
const now = Date.now();
|
||||
const prevState = makeState({
|
||||
resources: { gold: 1000, essence: 50, crystals: 5, runestones: 5 },
|
||||
adventurers: [
|
||||
{ id: "militia", count: 5, unlocked: true, level: 2, goldPerSecond: 0.5, essencePerSecond: 0, combatPower: 3 },
|
||||
] as GameState["adventurers"],
|
||||
upgrades: [
|
||||
{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 },
|
||||
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 1.5 },
|
||||
] as GameState["upgrades"],
|
||||
quests: [
|
||||
// main path: active → completed (startedAt far in past → expired)
|
||||
{ id: "first_steps", status: "active", startedAt: 1000 },
|
||||
// defensive: prevQuest.status === "completed" → skip in computeQuestRewards
|
||||
{ id: "goblin_camp", status: "completed", startedAt: 1000 },
|
||||
// defensive: prevQuest.status !== "active" → skip
|
||||
{ id: "haunted_mine", status: "locked", startedAt: null },
|
||||
// defensive: startedAt == null → skip
|
||||
{ id: "ancient_ruins", status: "active", startedAt: null },
|
||||
// defensive: !questData → skip (not in DEFAULT_QUESTS)
|
||||
{ id: "not_a_real_quest", status: "active", startedAt: 1000 },
|
||||
// anti-rollback: completed in prev, active in incoming → quests.map restores completed
|
||||
{ id: "rollback_quest", status: "completed", startedAt: 1000 },
|
||||
] as GameState["quests"],
|
||||
bosses: [
|
||||
// main path in computeBossRewards: available → defeated
|
||||
{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, damagePerSecond: 5, goldReward: 10000, essenceReward: 25, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
// defensive: prevBoss.status === "defeated" → skip
|
||||
{ id: "lich_queen", status: "defeated", currentHp: 0, maxHp: 10000, damagePerSecond: 20, goldReward: 100000, essenceReward: 200, crystalReward: 10, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
// defensive: prevBoss.status === "locked" → skip
|
||||
{ id: "forest_giant", status: "locked", currentHp: 35000, maxHp: 35000, damagePerSecond: 40, goldReward: 350000, essenceReward: 400, crystalReward: 20, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
// defensive: !bossData → skip (not in DEFAULT_BOSSES)
|
||||
{ id: "not_a_real_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
// anti-rollback: defeated in prev, available in incoming → bosses.map restores defeated
|
||||
{ id: "anti_rollback_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
] as GameState["bosses"],
|
||||
achievements: [
|
||||
{ id: "ach1", unlockedAt: 1000 }, // prev has unlockedAt → anti-rollback when incoming=null
|
||||
{ id: "ach2", unlockedAt: null }, // prev null → future timestamp check → caught
|
||||
{ id: "ach3", unlockedAt: null }, // prev null → legitimate past unlock → return a
|
||||
] as GameState["achievements"],
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 10 }],
|
||||
craftedRecipeIds: ["haunted_mine_recipe"],
|
||||
craftedGoldMultiplier: 2,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
transcendence: { count: 1, echoes: 10, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
apotheosis: { count: 2 },
|
||||
story: { unlockedChapterIds: ["ch1"], completedChapters: [{ chapterId: "ch1", completedAt: 1000 }] },
|
||||
});
|
||||
|
||||
const incomingState = makeState({
|
||||
resources: { gold: 1e18, essence: 1e18, crystals: 5, runestones: 0 },
|
||||
adventurers: [
|
||||
{ id: "militia", count: 5, unlocked: true, level: 2, goldPerSecond: 0.5, essencePerSecond: 0, combatPower: 3 },
|
||||
] as GameState["adventurers"],
|
||||
upgrades: [
|
||||
{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 },
|
||||
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 1.5 },
|
||||
] as GameState["upgrades"],
|
||||
quests: [
|
||||
{ id: "first_steps", status: "completed", startedAt: 1000 }, // was active → now completed
|
||||
{ id: "goblin_camp", status: "completed", startedAt: 1000 }, // both completed → skip
|
||||
{ id: "haunted_mine", status: "completed", startedAt: null }, // prevStatus=locked → skip
|
||||
{ id: "ancient_ruins", status: "completed", startedAt: null }, // startedAt=null → skip
|
||||
{ id: "not_a_real_quest", status: "completed", startedAt: 1000 }, // !questData → skip
|
||||
{ id: "rollback_quest", status: "active", startedAt: 1000 }, // anti-rollback → restored
|
||||
{ id: "orphan_quest", status: "completed", startedAt: 1000 }, // !prevQuest → skip
|
||||
{ id: "still_active_quest", status: "active", startedAt: 1000 }, // status !== completed → skip
|
||||
] as GameState["quests"],
|
||||
bosses: [
|
||||
{ id: "troll_king", status: "defeated", currentHp: 0, maxHp: 1000, damagePerSecond: 5, goldReward: 10000, essenceReward: 25, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
{ id: "lich_queen", status: "defeated", currentHp: 0, maxHp: 10000, damagePerSecond: 20, goldReward: 100000, essenceReward: 200, crystalReward: 10, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
{ id: "forest_giant", status: "defeated", currentHp: 0, maxHp: 35000, damagePerSecond: 40, goldReward: 350000, essenceReward: 400, crystalReward: 20, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
{ id: "not_a_real_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
{ id: "anti_rollback_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
{ id: "orphan_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
{ id: "still_available_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
||||
] as GameState["bosses"],
|
||||
achievements: [
|
||||
{ id: "ach1", unlockedAt: null }, // prev had unlockedAt → anti-rollback restores it
|
||||
{ id: "ach2", unlockedAt: now + 99999 }, // future timestamp → cheat caught
|
||||
{ id: "ach3", unlockedAt: 1000 }, // past timestamp → legitimate unlock
|
||||
{ id: "ach4", unlockedAt: null }, // not in prev → !prev → return a
|
||||
] as GameState["achievements"],
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [{ materialId: "verdant_sap", quantity: 1000 }], // inflated → capped at 10
|
||||
craftedRecipeIds: ["haunted_mine_recipe", "fake_recipe"], // fake_recipe filtered out
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
apotheosis: { count: 5 },
|
||||
story: {
|
||||
unlockedChapterIds: ["ch1", "ch2"],
|
||||
completedChapters: [{ chapterId: "ch1", completedAt: 1000 }, { chapterId: "ch2", completedAt: now }],
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { savedAt: number };
|
||||
expect(body.savedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("validates companion when active companion is legitimately unlocked", async () => {
|
||||
const prevState = makeState();
|
||||
const stateWithCompanion = makeState({
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: "lyra" },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||
makePlayer({ lifetimeBossesDefeated: 100 }) as never,
|
||||
);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: stateWithCompanion });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const reset = () =>
|
||||
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
||||
|
||||
it("returns 404 when player is not found", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await reset();
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("creates fresh state and returns it on success", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await reset();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { offlineGold: number; schemaOutdated: boolean; loginBonus: null };
|
||||
expect(body.offlineGold).toBe(0);
|
||||
expect(body.schemaOutdated).toBe(false);
|
||||
expect(body.loginBonus).toBeNull();
|
||||
});
|
||||
|
||||
it("includes HMAC signature in reset response when secret is configured", async () => {
|
||||
process.env["ANTI_CHEAT_SECRET"] = "reset_secret";
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await reset();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { findMany: vi.fn() },
|
||||
gameState: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
||||
discordId: "player_1",
|
||||
characterName: "Hero",
|
||||
username: "hero",
|
||||
avatar: null,
|
||||
profileSettings: null,
|
||||
activeTitle: null,
|
||||
lifetimeGoldEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
lifetimeAchievementsUnlocked: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("leaderboards route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { player: { findMany: ReturnType<typeof vi.fn> }; gameState: { findMany: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { leaderboardRouter } = await import("../../src/routes/leaderboards.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
});
|
||||
|
||||
const get = (query = "") =>
|
||||
app.fetch(new Request(`http://localhost/leaderboards${query ? `?${query}` : ""}`));
|
||||
|
||||
it("returns 400 for an invalid category", async () => {
|
||||
const res = await get("category=invalid");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns totalGold leaderboard by default", async () => {
|
||||
const players = [
|
||||
makePlayer({ discordId: "p1", lifetimeGoldEarned: 1000 }),
|
||||
makePlayer({ discordId: "p2", lifetimeGoldEarned: 500 }),
|
||||
];
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce(players as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { category: string; entries: Array<{ rank: number; value: number }> };
|
||||
expect(body.category).toBe("totalGold");
|
||||
expect(body.entries[0]?.value).toBe(1000);
|
||||
expect(body.entries[0]?.rank).toBe(1);
|
||||
});
|
||||
|
||||
it("returns bossesDefeated leaderboard", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ lifetimeBossesDefeated: 42 })] as never);
|
||||
const res = await get("category=bossesDefeated");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(42);
|
||||
});
|
||||
|
||||
it("returns questsCompleted leaderboard", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ lifetimeQuestsCompleted: 7 })] as never);
|
||||
const res = await get("category=questsCompleted");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(7);
|
||||
});
|
||||
|
||||
it("returns achievementsUnlocked leaderboard", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ lifetimeAchievementsUnlocked: 3 })] as never);
|
||||
const res = await get("category=achievementsUnlocked");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(3);
|
||||
});
|
||||
|
||||
it("returns prestigeCount from game state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 5 }, transcendence: null, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=prestigeCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(5);
|
||||
});
|
||||
|
||||
it("returns transcendenceCount from game state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: { count: 2 }, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=transcendenceCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(2);
|
||||
});
|
||||
|
||||
it("returns apotheosisCount from game state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: null, apotheosis: { count: 1 } },
|
||||
}] as never);
|
||||
const res = await get("category=apotheosisCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("filters out players with showOnLeaderboards=false", async () => {
|
||||
const players = [
|
||||
makePlayer({ discordId: "visible", lifetimeGoldEarned: 100 }),
|
||||
makePlayer({ discordId: "hidden", lifetimeGoldEarned: 200, profileSettings: { showOnLeaderboards: false } }),
|
||||
];
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce(players as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ discordId: string }> };
|
||||
expect(body.entries).toHaveLength(1);
|
||||
expect(body.entries[0]?.discordId).toBe("visible");
|
||||
});
|
||||
|
||||
it("respects the limit parameter", async () => {
|
||||
const players = Array.from({ length: 5 }, (_, i) => makePlayer({ discordId: `p${String(i)}`, lifetimeGoldEarned: i }));
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce(players as never);
|
||||
const res = await get("limit=2");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: unknown[] };
|
||||
expect(body.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses active title name in entries", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([
|
||||
makePlayer({ discordId: "p1", activeTitle: "the_first" }),
|
||||
] as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ activeTitle: string }> };
|
||||
// title may or may not be found — just verify the field exists
|
||||
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 () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||
const res = await get("category=prestigeCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves title name when active title ID is found in TITLES", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([
|
||||
makePlayer({ discordId: "p1", activeTitle: "the_adventurous" }),
|
||||
] as never);
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ activeTitle: string }> };
|
||||
// "the_adventurous" has name "The Adventurous" in TITLES — should differ from raw ID
|
||||
expect(body.entries[0]?.activeTitle).toBe("The Adventurous");
|
||||
});
|
||||
|
||||
it("defaults to 0 for transcendenceCount when transcendence is null in state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: null, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=transcendenceCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults to 0 for apotheosisCount when apotheosis is null in state", async () => {
|
||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([{
|
||||
discordId: "p1",
|
||||
state: { prestige: { count: 0 }, transcendence: null, apotheosis: null },
|
||||
}] as never);
|
||||
const res = await get("category=apotheosisCount");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entries: Array<{ value: number }> };
|
||||
expect(body.entries[0]?.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/webhook.js", () => ({
|
||||
postMilestoneWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 1_000_000, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("prestige route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
player: { update: ReturnType<typeof vi.fn> };
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { prestigeRouter } = await import("../../src/routes/prestige.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/prestige", prestigeRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/prestige${path}`, {
|
||||
method: "POST",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when not eligible (not enough gold)", async () => {
|
||||
const state = makeState({ player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns runestones on successful prestige", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
||||
expect(body.newPrestigeCount).toBe(1);
|
||||
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 () => {
|
||||
const state = makeState({
|
||||
dailyChallenges: {
|
||||
date: "2024-01-01",
|
||||
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
||||
} as GameState["dailyChallenges"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
||||
expect(body.newPrestigeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown upgrade", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const state = makeState({ prestige: { count: 0, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: ["income_1"] } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough runestones", async () => {
|
||||
const state = makeState({ prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
// income_1 costs 10 runestones but state has 0
|
||||
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns updated multipliers on successful purchase", async () => {
|
||||
const state = makeState({ prestige: { count: 0, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { runestonesRemaining: number; purchasedUpgradeIds: string[] };
|
||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { findUnique: vi.fn(), update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
||||
discordId: DISCORD_ID,
|
||||
characterName: "Hero",
|
||||
username: "hero",
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
pronouns: "she/her",
|
||||
characterRace: "Elf",
|
||||
characterClass: "Mage",
|
||||
bio: "A brave hero",
|
||||
guildName: "Brave Guild",
|
||||
guildDescription: "We are brave",
|
||||
profileSettings: null,
|
||||
createdAt: 1000,
|
||||
lastSavedAt: 2000,
|
||||
lifetimeGoldEarned: 500,
|
||||
lifetimeClicks: 100,
|
||||
lifetimeBossesDefeated: 5,
|
||||
lifetimeQuestsCompleted: 10,
|
||||
lifetimeAdventurersRecruited: 20,
|
||||
lifetimeAchievementsUnlocked: 3,
|
||||
unlockedTitles: null,
|
||||
activeTitle: null,
|
||||
loginStreak: 1,
|
||||
lastLoginDate: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "hero", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "Hero" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("profile route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { profileRouter } = await import("../../src/routes/profile.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/profile", profileRouter);
|
||||
});
|
||||
|
||||
describe("GET /:discordId", () => {
|
||||
it("returns 404 when player is not found", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request("http://localhost/profile/unknown_id"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns player profile with game state data", async () => {
|
||||
const state = makeState({
|
||||
prestige: { count: 3, runestones: 10, productionMultiplier: 1.45, purchasedUpgradeIds: [] },
|
||||
bosses: [{ id: "b1", status: "defeated" }] as GameState["bosses"],
|
||||
quests: [{ id: "q1", status: "completed" }] as GameState["quests"],
|
||||
achievements: [{ id: "a1", unlockedAt: 1000 }] as GameState["achievements"],
|
||||
transcendence: { count: 1, echoes: 10, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
apotheosis: { count: 1 },
|
||||
});
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
characterName: string;
|
||||
prestigeCount: number;
|
||||
bossesDefeated: number;
|
||||
questsCompleted: number;
|
||||
achievementsUnlocked: number;
|
||||
transcendenceCount: number;
|
||||
apotheosisCount: number;
|
||||
};
|
||||
expect(body.characterName).toBe("Hero");
|
||||
expect(body.prestigeCount).toBe(3);
|
||||
expect(body.bossesDefeated).toBe(1);
|
||||
expect(body.questsCompleted).toBe(1);
|
||||
expect(body.achievementsUnlocked).toBe(1);
|
||||
expect(body.transcendenceCount).toBe(1);
|
||||
expect(body.apotheosisCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty strings for null nullable player fields", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||
makePlayer({ pronouns: null, characterRace: null, characterClass: null, bio: null, guildName: null, guildDescription: null }) as never,
|
||||
);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { pronouns: string; characterRace: string; bio: string };
|
||||
expect(body.pronouns).toBe("");
|
||||
expect(body.characterRace).toBe("");
|
||||
expect(body.bio).toBe("");
|
||||
});
|
||||
|
||||
it("returns defaults when no game state exists", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { prestigeCount: number; bossesDefeated: number };
|
||||
expect(body.prestigeCount).toBe(0);
|
||||
expect(body.bossesDefeated).toBe(0);
|
||||
});
|
||||
|
||||
it("parses profileSettings when it is a valid object", async () => {
|
||||
const settings = { showTotalGold: false, showOnLeaderboards: false, numberFormat: "scientific" };
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ profileSettings: settings }) as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { profileSettings: { numberFormat: string; showTotalGold: boolean } };
|
||||
expect(body.profileSettings.numberFormat).toBe("scientific");
|
||||
expect(body.profileSettings.showTotalGold).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
|
||||
);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||
expect(body.profileSettings.numberFormat).toBe("suffix");
|
||||
});
|
||||
|
||||
it("maps known and unknown unlocked title IDs to name and fallback id", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||
makePlayer({ unlockedTitles: ["the_adventurous", "unknown_title_id"] }) as never,
|
||||
);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { unlockedTitles: Array<{ id: string; name: string }> };
|
||||
const known = body.unlockedTitles.find((t) => t.id === "the_adventurous");
|
||||
expect(known?.name).toBe("The Adventurous");
|
||||
const unknown = body.unlockedTitles.find((t) => t.id === "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 () => {
|
||||
const state = makeState({
|
||||
story: {
|
||||
unlockedChapterIds: [ "boss_troll_king" ],
|
||||
completedChapters: [ { chapterId: "boss_troll_king", choiceId: "fight" } ],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
completedChapters: Array<{ chapterId: string; choiceId: string }>;
|
||||
};
|
||||
expect(body.completedChapters).toHaveLength(1);
|
||||
expect(body.completedChapters[0]).toMatchObject({ chapterId: "boss_troll_king", choiceId: "fight" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /", () => {
|
||||
const put = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when character name is empty after trim", async () => {
|
||||
const res = await put({ characterName: " " });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when characterName is absent from request body", async () => {
|
||||
const res = await put({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("updates profile and returns updated data", async () => {
|
||||
const updatedPlayer = {
|
||||
characterName: "NewName", pronouns: "they/them", characterRace: "Human", characterClass: "Rogue",
|
||||
bio: "Updated bio", guildName: "New Guild", guildDescription: "Desc",
|
||||
profileSettings: null, activeTitle: "the_first",
|
||||
};
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce(updatedPlayer as never);
|
||||
const res = await put({
|
||||
characterName: "NewName",
|
||||
pronouns: "they/them",
|
||||
characterRace: "Human",
|
||||
characterClass: "Rogue",
|
||||
bio: "Updated bio",
|
||||
guildName: "New Guild",
|
||||
guildDescription: "Desc",
|
||||
profileSettings: { numberFormat: "engineering", showTotalGold: true, showOnLeaderboards: true },
|
||||
activeTitle: "the_first",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { characterName: string; activeTitle: string };
|
||||
expect(body.characterName).toBe("NewName");
|
||||
expect(body.activeTitle).toBe("the_first");
|
||||
});
|
||||
|
||||
it("uses suffix numberFormat when invalid value is provided", async () => {
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({
|
||||
characterName: "Hero", pronouns: null, characterRace: null, characterClass: null,
|
||||
bio: null, guildName: null, guildDescription: null, profileSettings: null, activeTitle: null,
|
||||
} as never);
|
||||
const res = await put({
|
||||
characterName: "Hero",
|
||||
profileSettings: { numberFormat: "invalid_format" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { update: vi.fn() },
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/webhook.js", () => ({
|
||||
postMilestoneWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [{ id: "the_absolute_one", status: "defeated" }] as GameState["bosses"],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("transcendence route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
player: { update: ReturnType<typeof vi.fn> };
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { transcendenceRouter } = await import("../../src/routes/transcendence.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/transcendence", transcendenceRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/transcendence${path}`, {
|
||||
method: "POST",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when the absolute one is not defeated", async () => {
|
||||
const state = makeState({ bosses: [] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns echoes and count on successful transcendence", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { echoes: number; newTranscendenceCount: number };
|
||||
expect(body.newTranscendenceCount).toBe(1);
|
||||
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", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown upgrade", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_echo" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when transcendence data is missing from state", async () => {
|
||||
const state = makeState({ transcendence: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const state = makeState({
|
||||
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: ["echo_income_1"], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough echoes", async () => {
|
||||
const state = makeState({
|
||||
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
// echo_income_1 costs 5 echoes but state has 0
|
||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns updated data on successful echo upgrade purchase", async () => {
|
||||
const state = makeState({
|
||||
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
|
||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPostApotheosisState,
|
||||
isEligibleForApotheosis,
|
||||
} from "../../src/services/apotheosis.js";
|
||||
import { defaultTranscendenceUpgrades } from "../../src/data/transcendenceUpgrades.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id);
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("isEligibleForApotheosis", () => {
|
||||
it("returns true when all transcendence upgrades are purchased", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: ALL_UPGRADE_IDS,
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
expect(isEligibleForApotheosis(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when one upgrade is missing", () => {
|
||||
const partialIds = ALL_UPGRADE_IDS.slice(0, -1);
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: partialIds,
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
expect(isEligibleForApotheosis(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when transcendence is undefined", () => {
|
||||
const state = makeMinimalState({ transcendence: undefined });
|
||||
expect(isEligibleForApotheosis(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when purchasedUpgradeIds is empty", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
expect(isEligibleForApotheosis(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostApotheosisState", () => {
|
||||
it("increments apotheosis count from 0", () => {
|
||||
const state = makeMinimalState();
|
||||
const { updatedApotheosisData } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedApotheosisData.count).toBe(1);
|
||||
});
|
||||
|
||||
it("increments apotheosis count from existing value", () => {
|
||||
const state = makeMinimalState({ apotheosis: { count: 2 } });
|
||||
const { updatedApotheosisData } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedApotheosisData.count).toBe(3);
|
||||
});
|
||||
|
||||
it("persists codex", () => {
|
||||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||||
const state = makeMinimalState({ codex });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.codex).toEqual(codex);
|
||||
});
|
||||
|
||||
it("persists story", () => {
|
||||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||||
const state = makeMinimalState({ story });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.story).toEqual(story);
|
||||
});
|
||||
|
||||
it("wipes prestige data", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.prestige.count).toBe(0);
|
||||
expect(updatedState.prestige.runestones).toBe(0);
|
||||
});
|
||||
|
||||
it("sets apotheosis count on new state", () => {
|
||||
const state = makeMinimalState({ apotheosis: { count: 0 } });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.apotheosis?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DailyChallengeState, GameState } from "@elysium/types";
|
||||
|
||||
// We reset modules so the module picks up fake timers when re-imported
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const makeState = (dailyChallenges?: DailyChallengeState): GameState =>
|
||||
({ dailyChallenges } as unknown as GameState);
|
||||
|
||||
const LA_MIDNIGHT_2024_01_15 = new Date("2024-01-15T08:00:00.000Z"); // LA midnight = UTC+8
|
||||
const LA_MIDNIGHT_2024_01_16 = new Date("2024-01-16T08:00:00.000Z");
|
||||
|
||||
describe("generateDailyChallenges", () => {
|
||||
it("returns exactly 3 challenges", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const result = generateDailyChallenges("2024-01-15");
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("all challenges start with progress 0 and completed false", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const result = generateDailyChallenges("2024-01-15");
|
||||
for (const challenge of result) {
|
||||
expect(challenge.progress).toBe(0);
|
||||
expect(challenge.completed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for the same date", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const a = generateDailyChallenges("2024-01-15");
|
||||
const b = generateDailyChallenges("2024-01-15");
|
||||
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
||||
});
|
||||
|
||||
it("generates different challenges for different dates", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const day1 = generateDailyChallenges("2024-01-15");
|
||||
const day2 = generateDailyChallenges("2024-01-16");
|
||||
// They should differ in at least one challenge ID (types vary by seed)
|
||||
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrResetDailyChallenges", () => {
|
||||
it("returns existing challenges when date matches today", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { getOrResetDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const existing: DailyChallengeState = {
|
||||
date: "2024-01-15",
|
||||
challenges: [{ id: "old_challenge", type: "clicks", label: "l", target: 100, progress: 50, completed: false, rewardCrystals: 1 }],
|
||||
};
|
||||
const state = makeState(existing);
|
||||
const result = getOrResetDailyChallenges(state);
|
||||
expect(result).toBe(existing);
|
||||
});
|
||||
|
||||
it("generates fresh challenges when date is yesterday", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_16);
|
||||
const { getOrResetDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const stale: DailyChallengeState = {
|
||||
date: "2024-01-15",
|
||||
challenges: [],
|
||||
};
|
||||
const state = makeState(stale);
|
||||
const result = getOrResetDailyChallenges(state);
|
||||
expect(result.date).toBe("2024-01-16");
|
||||
expect(result.challenges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("generates fresh challenges when dailyChallenges is undefined", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { getOrResetDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState(undefined);
|
||||
const result = getOrResetDailyChallenges(state);
|
||||
expect(result.challenges).toHaveLength(3);
|
||||
expect(result.date).toBe("2024-01-15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChallengeProgress", () => {
|
||||
const makeChallenge = (
|
||||
type: DailyChallengeState["challenges"][0]["type"],
|
||||
progress: number,
|
||||
completed: boolean,
|
||||
) => ({
|
||||
id: `${type}_test`,
|
||||
type,
|
||||
label: "Test",
|
||||
target: 100,
|
||||
progress,
|
||||
completed,
|
||||
rewardCrystals: 10,
|
||||
});
|
||||
|
||||
const makeState2 = (challenges: DailyChallengeState["challenges"]): DailyChallengeState => ({
|
||||
date: "2024-01-15",
|
||||
challenges,
|
||||
});
|
||||
|
||||
it("increments progress for matching non-completed challenges", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 0, false)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 10);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(10);
|
||||
});
|
||||
|
||||
it("does not modify already-completed challenges", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 100, true)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 50);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("does not modify challenges of a different type", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("bossesDefeated", 0, false)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 10);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(0);
|
||||
});
|
||||
|
||||
it("awards crystals when challenge completes", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 90, false)]);
|
||||
const { crystalsAwarded } = updateChallengeProgress(state, "clicks", 20);
|
||||
expect(crystalsAwarded).toBe(10);
|
||||
});
|
||||
|
||||
it("caps progress at target value", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 95, false)]);
|
||||
const { updatedChallenges } = updateChallengeProgress(state, "clicks", 100);
|
||||
expect(updatedChallenges.challenges[0]!.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("returns zero crystals when no challenge completes", async () => {
|
||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||
const { updateChallengeProgress } = await import("../../src/services/dailyChallenges.js");
|
||||
const state = makeState2([makeChallenge("clicks", 0, false)]);
|
||||
const { crystalsAwarded } = updateChallengeProgress(state, "clicks", 10);
|
||||
expect(crystalsAwarded).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("discord service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("buildOAuthUrl", () => {
|
||||
it("returns a URL with correct query params", async () => {
|
||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||
const url = buildOAuthUrl();
|
||||
expect(url).toContain("client_id=1479551654264049908");
|
||||
expect(url).toContain("response_type=code");
|
||||
expect(url).toContain("scope=identify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeCode", () => {
|
||||
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
|
||||
delete process.env["DISCORD_CLIENT_SECRET"];
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||
});
|
||||
|
||||
it("throws when response is not ok", async () => {
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||
});
|
||||
|
||||
it("returns parsed body on success", async () => {
|
||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||
const result = await exchangeCode("good_code");
|
||||
expect(result.access_token).toBe("tok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDiscordUser", () => {
|
||||
it("throws when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Forbidden" });
|
||||
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||
await expect(fetchDiscordUser("bad_token")).rejects.toThrow("Discord user fetch failed");
|
||||
});
|
||||
|
||||
it("returns parsed user on success", async () => {
|
||||
const user = { id: "123", username: "testuser", discriminator: "0", avatar: null };
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
|
||||
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||
const result = await fetchDiscordUser("valid_token");
|
||||
expect(result.id).toBe("123");
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("jwt service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
describe("signToken", () => {
|
||||
it("throws when JWT_SECRET is not set", async () => {
|
||||
delete process.env["JWT_SECRET"];
|
||||
const { signToken } = await import("../../src/services/jwt.js");
|
||||
expect(() => signToken("test_id")).toThrow("JWT_SECRET environment variable is required");
|
||||
});
|
||||
|
||||
it("returns a three-part dot-separated token", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { signToken } = await import("../../src/services/jwt.js");
|
||||
const token = signToken("test_id");
|
||||
expect(token.split(".")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
it("throws when JWT_SECRET is not set", async () => {
|
||||
delete process.env["JWT_SECRET"];
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
expect(() => verifyToken("a.b.c")).toThrow("JWT_SECRET environment variable is required");
|
||||
});
|
||||
|
||||
it("round-trips a token correctly", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { signToken, verifyToken } = await import("../../src/services/jwt.js");
|
||||
const token = signToken("user_123");
|
||||
const payload = verifyToken(token);
|
||||
expect(payload.discordId).toBe("user_123");
|
||||
});
|
||||
|
||||
it("throws on wrong token format (not 3 parts)", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
expect(() => verifyToken("only.two")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws on tampered signature", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { signToken, verifyToken } = await import("../../src/services/jwt.js");
|
||||
const token = signToken("user_123");
|
||||
const parts = token.split(".");
|
||||
const tampered = `${parts[0]}.${parts[1]}.BAD_SIGNATURE`;
|
||||
expect(() => verifyToken(tampered)).toThrow("Invalid token signature");
|
||||
});
|
||||
|
||||
it("throws on expired token", async () => {
|
||||
process.env["JWT_SECRET"] = "test_secret";
|
||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||
// Build a token with exp in the past
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ discordId: "x", iat: 1000, exp: 1001 }),
|
||||
).toString("base64url");
|
||||
const { createHmac } = await import("crypto");
|
||||
const signature = createHmac("sha256", "test_secret")
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
expect(() => verifyToken(`${header}.${payload}.${signature}`)).toThrow("Token has expired");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateOfflineEarnings } from "../../src/services/offlineProgress.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
lastTickAt: 0,
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
equipment: [],
|
||||
prestige: {
|
||||
count: 0,
|
||||
runestones: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
runestonesIncomeMultiplier: 1,
|
||||
runestonesEssenceMultiplier: 1,
|
||||
},
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("calculateOfflineEarnings", () => {
|
||||
it("returns zero earnings when no adventurers", () => {
|
||||
const state = makeState({ lastTickAt: 0 });
|
||||
const result = calculateOfflineEarnings(state, 60_000);
|
||||
expect(result.offlineGold).toBe(0);
|
||||
expect(result.offlineEssence).toBe(0);
|
||||
expect(result.offlineSeconds).toBe(60);
|
||||
});
|
||||
|
||||
it("returns zero when all adventurers have count 0", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 0, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 60_000);
|
||||
expect(result.offlineGold).toBe(0);
|
||||
});
|
||||
|
||||
it("returns zero when adventurer is not unlocked", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: false, count: 5, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 60_000);
|
||||
expect(result.offlineGold).toBe(0);
|
||||
});
|
||||
|
||||
it("calculates basic gold earnings correctly", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 2, goldPerSecond: 5, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 10_000);
|
||||
// 2 adventurers × 5 gps × 10 seconds = 100 gold
|
||||
expect(result.offlineGold).toBe(100);
|
||||
expect(result.offlineSeconds).toBe(10);
|
||||
});
|
||||
|
||||
it("calculates basic essence earnings correctly", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 0, essencePerSecond: 3 }] as GameState["adventurers"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 10_000);
|
||||
expect(result.offlineEssence).toBe(30);
|
||||
});
|
||||
|
||||
it("caps earnings at 8 hours regardless of elapsed time", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 1, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
});
|
||||
const twelveHoursMs = 12 * 60 * 60 * 1000;
|
||||
const result = calculateOfflineEarnings(state, twelveHoursMs);
|
||||
const maxSeconds = 8 * 60 * 60;
|
||||
expect(result.offlineGold).toBe(maxSeconds);
|
||||
expect(result.offlineSeconds).toBe(maxSeconds);
|
||||
});
|
||||
|
||||
it("applies global upgrade multiplier", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
upgrades: [{ id: "u1", purchased: true, target: "global", multiplier: 2 }] as GameState["upgrades"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineGold).toBe(20);
|
||||
});
|
||||
|
||||
it("applies adventurer-specific upgrade multiplier", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "peasant", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
upgrades: [{ id: "u1", purchased: true, target: "adventurer", adventurerId: "peasant", multiplier: 3 }] as GameState["upgrades"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineGold).toBe(30);
|
||||
});
|
||||
|
||||
it("does not apply upgrade for different adventurer", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "peasant", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
upgrades: [{ id: "u1", purchased: true, target: "adventurer", adventurerId: "knight", multiplier: 3 }] as GameState["upgrades"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineGold).toBe(10);
|
||||
});
|
||||
|
||||
it("applies equipment gold multiplier for equipped items only", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
equipment: [
|
||||
{ id: "e1", equipped: true, bonus: { goldMultiplier: 2 } },
|
||||
{ id: "e2", equipped: false, bonus: { goldMultiplier: 5 } },
|
||||
] as GameState["equipment"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
// Only e1 applies: 10 × 2 × 1s = 20
|
||||
expect(result.offlineGold).toBe(20);
|
||||
});
|
||||
|
||||
it("applies runestone income multiplier to gold", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
prestige: {
|
||||
count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [],
|
||||
runestonesIncomeMultiplier: 2,
|
||||
runestonesEssenceMultiplier: 1,
|
||||
} as GameState["prestige"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineGold).toBe(20);
|
||||
});
|
||||
|
||||
it("applies runestone essence multiplier to essence", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 0, essencePerSecond: 5 }] as GameState["adventurers"],
|
||||
prestige: {
|
||||
count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [],
|
||||
runestonesIncomeMultiplier: 1,
|
||||
runestonesEssenceMultiplier: 3,
|
||||
} as GameState["prestige"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineEssence).toBe(15);
|
||||
});
|
||||
|
||||
it("defaults to 1 when runestonesIncomeMultiplier is undefined", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 2 }] as GameState["adventurers"],
|
||||
// Prestige without runestone multiplier fields — hits the ?? 1 fallback
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] } as GameState["prestige"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineGold).toBe(10);
|
||||
expect(result.offlineEssence).toBe(2);
|
||||
});
|
||||
|
||||
it("defaults to 1 when equipment is undefined", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
equipment: undefined,
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
expect(result.offlineGold).toBe(10);
|
||||
});
|
||||
|
||||
it("defaults goldMultiplier to 1 when equipment item has no goldMultiplier", () => {
|
||||
const state = makeState({
|
||||
lastTickAt: 0,
|
||||
adventurers: [{ id: "a", unlocked: true, count: 1, goldPerSecond: 10, essencePerSecond: 0 }] as GameState["adventurers"],
|
||||
equipment: [
|
||||
{ id: "e1", equipped: true, bonus: {} }, // no goldMultiplier — hits ?? 1
|
||||
] as GameState["equipment"],
|
||||
});
|
||||
const result = calculateOfflineEarnings(state, 1_000);
|
||||
// goldMultiplier defaults to 1, so no boost
|
||||
expect(result.offlineGold).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
calculateMilestoneBonus,
|
||||
calculatePrestigeThreshold,
|
||||
calculateProductionMultiplier,
|
||||
calculateRunestones,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../../src/services/prestige.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makePlayer = (
|
||||
totalGoldEarned: number,
|
||||
lifetimeGoldEarned = 0,
|
||||
totalClicks = 0,
|
||||
) => ({
|
||||
avatar: null,
|
||||
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 =>
|
||||
({
|
||||
player: makePlayer(0),
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("calculatePrestigeThreshold", () => {
|
||||
it("returns base threshold at count 0", () => {
|
||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("returns 5× at count 1", () => {
|
||||
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
|
||||
});
|
||||
|
||||
it("returns 25× at count 2", () => {
|
||||
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
|
||||
});
|
||||
|
||||
it("applies threshold multiplier correctly", () => {
|
||||
expect(calculatePrestigeThreshold(0, 2)).toBe(2_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEligibleForPrestige", () => {
|
||||
it("returns true when totalGoldEarned meets threshold", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(1_000_000) });
|
||||
expect(isEligibleForPrestige(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when totalGoldEarned is below threshold", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(999_999) });
|
||||
expect(isEligibleForPrestige(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses echoPrestigeThresholdMultiplier from transcendence when present", () => {
|
||||
const state = makeMinimalState({
|
||||
player: makePlayer(2_000_000),
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 2,
|
||||
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
// threshold = 1_000_000 × 2 = 2_000_000 — exactly meets
|
||||
expect(isEligibleForPrestige(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateRunestones", () => {
|
||||
it("calculates basic runestones formula", () => {
|
||||
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it("applies echo runestone multiplier", () => {
|
||||
// floor(sqrt(4) × 10) = 20; × 2 = 40
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||||
expect(result).toBe(40);
|
||||
});
|
||||
|
||||
it("applies purchased runestone upgrade multiplier", () => {
|
||||
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||||
expect(result).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateProductionMultiplier", () => {
|
||||
it("returns 1 at count 0", () => {
|
||||
expect(calculateProductionMultiplier(0)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1.15 at count 1", () => {
|
||||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
|
||||
});
|
||||
|
||||
it("scales exponentially", () => {
|
||||
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateMilestoneBonus", () => {
|
||||
it("returns 0 for non-milestone prestiges", () => {
|
||||
expect(calculateMilestoneBonus(1)).toBe(0);
|
||||
expect(calculateMilestoneBonus(3)).toBe(0);
|
||||
expect(calculateMilestoneBonus(4)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 25 at prestige 5", () => {
|
||||
expect(calculateMilestoneBonus(5)).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 50 at prestige 10", () => {
|
||||
expect(calculateMilestoneBonus(10)).toBe(50);
|
||||
});
|
||||
|
||||
it("returns 75 at prestige 15", () => {
|
||||
expect(calculateMilestoneBonus(15)).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRunestoneMultipliers", () => {
|
||||
it("returns all 1s with empty ids", () => {
|
||||
const result = computeRunestoneMultipliers([]);
|
||||
expect(result.runestonesIncomeMultiplier).toBe(1);
|
||||
expect(result.runestonesClickMultiplier).toBe(1);
|
||||
expect(result.runestonesEssenceMultiplier).toBe(1);
|
||||
expect(result.runestonesCrystalMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies income upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["income_1"]);
|
||||
expect(result.runestonesIncomeMultiplier).toBeGreaterThan(1);
|
||||
expect(result.runestonesClickMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies click upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["click_power_1"]);
|
||||
expect(result.runestonesClickMultiplier).toBeGreaterThan(1);
|
||||
expect(result.runestonesIncomeMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies essence upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["essence_1"]);
|
||||
expect(result.runestonesEssenceMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("applies crystals upgrade when purchased", () => {
|
||||
const result = computeRunestoneMultipliers(["crystal_1"]);
|
||||
expect(result.runestonesCrystalMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostPrestigeState", () => {
|
||||
it("increments prestige count", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(4_000_000) });
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.count).toBe(1);
|
||||
});
|
||||
|
||||
it("sums runestones earned", () => {
|
||||
const state = makeMinimalState({ player: makePlayer(4_000_000) });
|
||||
const { prestigeData, runestonesEarned } = buildPostPrestigeState(state, "Tester");
|
||||
expect(runestonesEarned).toBeGreaterThan(0);
|
||||
expect(prestigeData.runestones).toBe(runestonesEarned);
|
||||
});
|
||||
|
||||
it("adds milestone runestones at prestige 5", () => {
|
||||
const state = makeMinimalState({
|
||||
player: makePlayer(100_000_000),
|
||||
prestige: { count: 4, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const { milestoneRunestones } = buildPostPrestigeState(state, "Tester");
|
||||
expect(milestoneRunestones).toBe(25);
|
||||
});
|
||||
|
||||
it("persists codex from current state", () => {
|
||||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||||
const state = makeMinimalState({ codex });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.codex).toEqual(codex);
|
||||
});
|
||||
|
||||
it("persists story from current state", () => {
|
||||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||||
const state = makeMinimalState({ story });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.story).toEqual(story);
|
||||
});
|
||||
|
||||
it("persists transcendence from current state", () => {
|
||||
const transcendence = {
|
||||
count: 1, echoes: 10, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1,
|
||||
echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
};
|
||||
const state = makeMinimalState({ transcendence });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeState.transcendence).toEqual(transcendence);
|
||||
});
|
||||
|
||||
it("preserves autoPrestigeEnabled when set", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [], autoPrestigeEnabled: true },
|
||||
});
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("omits autoPrestigeEnabled when not set", () => {
|
||||
const state = makeMinimalState();
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves apotheosis data across prestige", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkAndUnlockTitles,
|
||||
parseUnlockedTitles,
|
||||
} from "../../src/services/titles.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
bosses: [],
|
||||
quests: [],
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
adventurers: [],
|
||||
achievements: [],
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("parseUnlockedTitles", () => {
|
||||
it("returns the array as-is when input is a string array", () => {
|
||||
expect(parseUnlockedTitles(["boss_slayer", "the_adventurous"])).toEqual(["boss_slayer", "the_adventurous"]);
|
||||
});
|
||||
|
||||
it("returns empty array for null input", () => {
|
||||
expect(parseUnlockedTitles(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for undefined input", () => {
|
||||
expect(parseUnlockedTitles(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for object input", () => {
|
||||
expect(parseUnlockedTitles({ key: "value" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for number input", () => {
|
||||
expect(parseUnlockedTitles(42)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters non-string values from mixed array", () => {
|
||||
expect(parseUnlockedTitles(["valid", 42, null, "also_valid"])).toEqual(["valid", "also_valid"]);
|
||||
});
|
||||
|
||||
it("returns empty array for an empty array", () => {
|
||||
expect(parseUnlockedTitles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAndUnlockTitles", () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
const THIRTY_DAYS_MS = 30 * 86_400_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array when no new titles are earned", () => {
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips titles already unlocked", () => {
|
||||
const state = makeMinimalState({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 10_000, characterName: "T" },
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: ["click_maniac"], state, guildName: "", createdAt: NOW });
|
||||
expect(result).not.toContain("click_maniac");
|
||||
});
|
||||
|
||||
it("unlocks guild_founder when guild name is non-empty", () => {
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "My Guild", createdAt: NOW });
|
||||
expect(result).toContain("guild_founder");
|
||||
});
|
||||
|
||||
it("does not unlock guild_founder for whitespace-only guild name", () => {
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: " ", createdAt: NOW });
|
||||
expect(result).not.toContain("guild_founder");
|
||||
});
|
||||
|
||||
it("unlocks the_adventurous when 1 quest is completed", () => {
|
||||
const state = makeMinimalState({
|
||||
quests: [{ status: "completed" }] as GameState["quests"],
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toContain("the_adventurous");
|
||||
});
|
||||
|
||||
it("unlocks boss_slayer when 1 boss is defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ status: "defeated" }] as GameState["bosses"],
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toContain("boss_slayer");
|
||||
});
|
||||
|
||||
it("unlocks the_undying at prestige 1", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 1, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(result).toContain("the_undying");
|
||||
});
|
||||
|
||||
it("unlocks veteran after 30 days of play", () => {
|
||||
const createdAt = NOW - THIRTY_DAYS_MS;
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt });
|
||||
expect(result).toContain("veteran");
|
||||
});
|
||||
|
||||
it("does not unlock veteran before 30 days", () => {
|
||||
const createdAt = NOW - (29 * 86_400_000);
|
||||
const state = makeMinimalState();
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt });
|
||||
expect(result).not.toContain("veteran");
|
||||
});
|
||||
|
||||
it("returns multiple newly unlocked titles at once", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ status: "defeated" }] as GameState["bosses"],
|
||||
quests: [{ status: "completed" }] as GameState["quests"],
|
||||
});
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "Guild", createdAt: NOW });
|
||||
expect(result).toContain("boss_slayer");
|
||||
expect(result).toContain("the_adventurous");
|
||||
expect(result).toContain("guild_founder");
|
||||
});
|
||||
|
||||
it("reads transcendenceCount and apotheosisCount from state when present", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 0, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
apotheosis: { count: 1 },
|
||||
});
|
||||
// Just verify this runs without error — the counts are read via ?. chains
|
||||
const result = checkAndUnlockTitles({ currentUnlocked: [], state, guildName: "", createdAt: NOW });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPostTranscendenceState,
|
||||
calculateEchoes,
|
||||
computeTranscendenceMultipliers,
|
||||
isEligibleForTranscendence,
|
||||
} from "../../src/services/transcendence.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
({
|
||||
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("computeTranscendenceMultipliers", () => {
|
||||
it("returns all 1s with empty ids", () => {
|
||||
const result = computeTranscendenceMultipliers([]);
|
||||
expect(result.echoIncomeMultiplier).toBe(1);
|
||||
expect(result.echoCombatMultiplier).toBe(1);
|
||||
expect(result.echoPrestigeThresholdMultiplier).toBe(1);
|
||||
expect(result.echoPrestigeRunestoneMultiplier).toBe(1);
|
||||
expect(result.echoMetaMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies income upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_income_1"]);
|
||||
expect(result.echoIncomeMultiplier).toBeGreaterThan(1);
|
||||
expect(result.echoCombatMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies combat upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_combat_1"]);
|
||||
expect(result.echoCombatMultiplier).toBeGreaterThan(1);
|
||||
expect(result.echoIncomeMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies prestige_threshold upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_prestige_threshold_1"]);
|
||||
expect(result.echoPrestigeThresholdMultiplier).not.toBe(1);
|
||||
});
|
||||
|
||||
it("applies prestige_runestones upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_prestige_runestones_1"]);
|
||||
expect(result.echoPrestigeRunestoneMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("applies echo_meta upgrade when purchased", () => {
|
||||
const result = computeTranscendenceMultipliers(["echo_meta_1"]);
|
||||
expect(result.echoMetaMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEligibleForTranscendence", () => {
|
||||
it("returns true when final boss is defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ id: "the_absolute_one", status: "defeated" }] as GameState["bosses"],
|
||||
});
|
||||
expect(isEligibleForTranscendence(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when final boss is available but not defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ id: "the_absolute_one", status: "available" }] as GameState["bosses"],
|
||||
});
|
||||
expect(isEligibleForTranscendence(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when final boss is not in the list", () => {
|
||||
const state = makeMinimalState({ bosses: [] });
|
||||
expect(isEligibleForTranscendence(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when a different boss is defeated", () => {
|
||||
const state = makeMinimalState({
|
||||
bosses: [{ id: "some_other_boss", status: "defeated" }] as GameState["bosses"],
|
||||
});
|
||||
expect(isEligibleForTranscendence(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEchoes", () => {
|
||||
it("handles prestige count of 0 by treating it as 1", () => {
|
||||
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
|
||||
expect(calculateEchoes(0, 1)).toBe(853);
|
||||
});
|
||||
|
||||
it("calculates echoes at count 1", () => {
|
||||
expect(calculateEchoes(1, 1)).toBe(853);
|
||||
});
|
||||
|
||||
it("decreases echoes with higher prestige count", () => {
|
||||
const echoesAt1 = calculateEchoes(1, 1);
|
||||
const echoesAt4 = calculateEchoes(4, 1);
|
||||
expect(echoesAt4).toBeLessThan(echoesAt1);
|
||||
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
|
||||
expect(echoesAt4).toBe(426);
|
||||
});
|
||||
|
||||
it("applies echoMetaMultiplier", () => {
|
||||
const base = calculateEchoes(1, 1);
|
||||
const withMult = calculateEchoes(1, 2);
|
||||
expect(withMult).toBe(base * 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostTranscendenceState", () => {
|
||||
it("increments transcendence count from 0", () => {
|
||||
const state = makeMinimalState();
|
||||
const { transcendenceData } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceData.count).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates echoes", () => {
|
||||
const state = makeMinimalState({
|
||||
transcendence: {
|
||||
count: 1, echoes: 100, purchasedUpgradeIds: [],
|
||||
echoIncomeMultiplier: 1, echoCombatMultiplier: 1,
|
||||
echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1,
|
||||
},
|
||||
});
|
||||
const { transcendenceData, echoesEarned } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceData.echoes).toBe(100 + echoesEarned);
|
||||
});
|
||||
|
||||
it("persists codex from current state", () => {
|
||||
const codex = { entries: [{ id: "e1", unlockedAt: 1000, sourceType: "exploration" as const }] };
|
||||
const state = makeMinimalState({ codex });
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.codex).toEqual(codex);
|
||||
});
|
||||
|
||||
it("persists story from current state", () => {
|
||||
const story = { unlockedChapterIds: ["ch1"], completedChapters: [] };
|
||||
const state = makeMinimalState({ story });
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.story).toEqual(story);
|
||||
});
|
||||
|
||||
it("persists apotheosis from current state", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.apotheosis).toEqual(apotheosis);
|
||||
});
|
||||
|
||||
it("resets prestige to fresh state", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] },
|
||||
});
|
||||
const { transcendenceState } = buildPostTranscendenceState(state, "T");
|
||||
expect(transcendenceState.prestige.count).toBe(0);
|
||||
expect(transcendenceState.prestige.runestones).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("webhook service", () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("grantApotheosisRole", () => {
|
||||
it("does nothing when bot token is missing", async () => {
|
||||
delete process.env["DISCORD_BOT_TOKEN"];
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user123");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls Discord API with correct URL and auth when bot token is set", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
await grantApotheosisRole("user789");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||
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", () => {
|
||||
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
|
||||
|
||||
it("does nothing when webhook URL is missing", async () => {
|
||||
delete process.env["DISCORD_MILESTONE_WEBHOOK"];
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "prestige", counts);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts prestige message with correct body", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "prestige", counts);
|
||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://discord.com/webhook/abc");
|
||||
const body = JSON.parse(options.body as string) as { content: string; flags: number };
|
||||
expect(body.content).toContain("<@user123>");
|
||||
expect(body.content).toContain("prestiged");
|
||||
expect(body.flags).toBe(4096);
|
||||
});
|
||||
|
||||
it("posts transcendence message correctly", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "transcendence", { prestige: 0, transcendence: 1, apotheosis: 0 });
|
||||
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(options.body as string) as { content: string };
|
||||
expect(body.content).toContain("transcended");
|
||||
});
|
||||
|
||||
it("posts apotheosis message correctly", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
await postMilestoneWebhook("user123", "apotheosis", { prestige: 0, transcendence: 0, apotheosis: 1 });
|
||||
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(options.body as string) as { content: string };
|
||||
expect(body.content).toContain("reached apotheosis");
|
||||
});
|
||||
|
||||
it("swallows fetch errors gracefully", async () => {
|
||||
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network timeout"));
|
||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||
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,7 +5,12 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/types/**/*.ts"],
|
||||
exclude: [
|
||||
"src/types/**/*.ts",
|
||||
"src/db/client.ts",
|
||||
"src/index.ts",
|
||||
"src/data/materials.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
import { NaomisConfig } from "@nhcarrigan/eslint-config";
|
||||
import config from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...NaomisConfig];
|
||||
export default [
|
||||
...config,
|
||||
{
|
||||
files: [ "src/**/*.tsx" ],
|
||||
rules: {
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"warn",
|
||||
{
|
||||
format: [ "camelCase", "PascalCase" ],
|
||||
leadingUnderscore: "allow",
|
||||
selector: "variable",
|
||||
trailingUnderscore: "forbid",
|
||||
},
|
||||
{
|
||||
format: [ "camelCase" ],
|
||||
leadingUnderscore: "allow",
|
||||
selector: "function",
|
||||
trailingUnderscore: "forbid",
|
||||
},
|
||||
{
|
||||
format: [ "PascalCase" ],
|
||||
leadingUnderscore: "forbid",
|
||||
selector: "typeLike",
|
||||
trailingUnderscore: "forbid",
|
||||
},
|
||||
{
|
||||
format: [ "PascalCase" ],
|
||||
leadingUnderscore: "forbid",
|
||||
selector: "class",
|
||||
trailingUnderscore: "forbid",
|
||||
},
|
||||
],
|
||||
"react/jsx-no-bind": [
|
||||
"error",
|
||||
{
|
||||
allowFunctions: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,6 +5,39 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Elysium — Idle RPG</title>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/web",
|
||||
"version": "0.0.0",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -13,7 +13,8 @@
|
||||
"dependencies": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react-dom": "19.0.0",
|
||||
"react-markdown": "10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { GameProvider } from "./context/GameContext.js";
|
||||
import { GameLayout } from "./components/game/GameLayout.js";
|
||||
import { LoginPage } from "./components/game/LoginPage.js";
|
||||
import { ProfilePage } from "./components/game/ProfilePage.js";
|
||||
|
||||
const getProfileDiscordId = (): string | null => {
|
||||
const match = /^\/profile\/(\d+)$/.exec(window.location.pathname);
|
||||
return match?.[1] ?? null;
|
||||
};
|
||||
|
||||
const handleAuthCallback = (): boolean => {
|
||||
if (window.location.pathname !== "/auth/callback") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("elysium_token", token);
|
||||
}
|
||||
|
||||
window.history.replaceState(null, "", "/");
|
||||
return Boolean(token);
|
||||
};
|
||||
|
||||
const isAuthenticated = (): boolean => {
|
||||
const fromCallback = handleAuthCallback();
|
||||
return fromCallback || Boolean(localStorage.getItem("elysium_token"));
|
||||
};
|
||||
|
||||
export const App = (): React.JSX.Element => {
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
|
||||
const profileDiscordId = getProfileDiscordId();
|
||||
if (profileDiscordId) {
|
||||
return <ProfilePage discordId={profileDiscordId} />;
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GameProvider>
|
||||
<GameLayout />
|
||||
</GameProvider>
|
||||
);
|
||||
};
|
||||
+287
-42
@@ -1,105 +1,350 @@
|
||||
/**
|
||||
* @file API client for communicating with the Elysium backend.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type {
|
||||
AboutResponse,
|
||||
ApotheosisRequest,
|
||||
ApotheosisResponse,
|
||||
AuthResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyEchoUpgradeRequest,
|
||||
BuyEchoUpgradeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
ExploreStartResponse,
|
||||
ForceUnlocksResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const BASE_URL = "/api";
|
||||
const baseUrl = "/api";
|
||||
|
||||
const getToken = (): string | null => localStorage.getItem("elysium_token");
|
||||
const getToken = (): string | null => {
|
||||
return globalThis.localStorage.getItem("elysium_token");
|
||||
};
|
||||
|
||||
const headers = (): Record<string, string> => {
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const token = getToken();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...token !== null && token.length > 0
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {},
|
||||
};
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
|
||||
|
||||
const request = async <T>(
|
||||
const fetchJson = async <T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: { ...headers(), ...options?.headers },
|
||||
headers: { ...buildHeaders(), ...options?.headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({ error: "Unknown error" }))) as {
|
||||
error: string;
|
||||
};
|
||||
throw new Error(error.error);
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON error response requires type assertion */
|
||||
const errorBody = (await response.json().catch(() => {
|
||||
return { error: "Unknown error" };
|
||||
})) as Record<string, unknown>;
|
||||
const message
|
||||
= typeof errorBody.error === "string"
|
||||
? errorBody.error
|
||||
: "Unknown error";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON response requires type assertion */
|
||||
return await (response.json() as Promise<T>);
|
||||
};
|
||||
|
||||
export const getAbout = async (): Promise<AboutResponse> =>
|
||||
request<AboutResponse>("/about");
|
||||
/**
|
||||
* Fetches the about information from the API.
|
||||
* @returns The about response data.
|
||||
*/
|
||||
const getAbout = async(): Promise<AboutResponse> => {
|
||||
return await fetchJson<AboutResponse>("/about");
|
||||
};
|
||||
|
||||
export const getAuthUrl = async (): Promise<string> => {
|
||||
const data = await request<{ url: string }>("/auth/url");
|
||||
/**
|
||||
* Fetches the Discord OAuth URL from the API.
|
||||
* @returns The authentication URL string.
|
||||
*/
|
||||
const getAuthUrl = async(): Promise<string> => {
|
||||
const data = await fetchJson<{ url: string }>("/auth/url");
|
||||
return data.url;
|
||||
};
|
||||
|
||||
export const handleAuthCallback = async (code: string): Promise<AuthResponse> => {
|
||||
const data = await request<AuthResponse>(`/auth/callback?code=${code}`);
|
||||
localStorage.setItem("elysium_token", data.token);
|
||||
/**
|
||||
* Handles the Discord OAuth callback and stores the auth token.
|
||||
* @param code - The OAuth authorization code from Discord.
|
||||
* @returns The authentication response data.
|
||||
*/
|
||||
const handleAuthCallback = async(code: string): Promise<AuthResponse> => {
|
||||
const data = await fetchJson<AuthResponse>(`/auth/callback?code=${code}`);
|
||||
globalThis.localStorage.setItem("elysium_token", data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const loadGame = async (): Promise<LoadResponse> =>
|
||||
request<LoadResponse>("/game/load");
|
||||
/**
|
||||
* Loads the current game state from the server.
|
||||
* @returns The load response containing the game state.
|
||||
*/
|
||||
const loadGame = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/game/load");
|
||||
};
|
||||
|
||||
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
||||
request<SaveResponse>("/game/save", {
|
||||
/**
|
||||
* Resets all game progress on the server.
|
||||
* @returns The load response after reset.
|
||||
*/
|
||||
const resetProgress = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/game/reset", { method: "POST" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the current game state to the server.
|
||||
* @param body - The save request payload containing the game state.
|
||||
* @returns The save response data.
|
||||
*/
|
||||
const saveGame = async(body: SaveRequest): Promise<SaveResponse> => {
|
||||
return await fetchJson<SaveResponse>("/game/save", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export const challengeBoss = async (
|
||||
/**
|
||||
* Challenges a boss with the current game state.
|
||||
* @param body - The boss challenge request payload.
|
||||
* @returns The boss challenge response data.
|
||||
*/
|
||||
const challengeBoss = async(
|
||||
body: BossChallengeRequest,
|
||||
): Promise<BossChallengeResponse> =>
|
||||
request<BossChallengeResponse>("/boss/challenge", {
|
||||
): Promise<BossChallengeResponse> => {
|
||||
return await fetchJson<BossChallengeResponse>("/boss/challenge", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export const prestige = async (body: PrestigeRequest): Promise<PrestigeResponse> =>
|
||||
request<PrestigeResponse>("/prestige", {
|
||||
/**
|
||||
* Triggers a prestige reset on the server.
|
||||
* @param body - The prestige request payload.
|
||||
* @returns The prestige response data.
|
||||
*/
|
||||
const prestige = async(body: PrestigeRequest): Promise<PrestigeResponse> => {
|
||||
return await fetchJson<PrestigeResponse>("/prestige", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export const buyPrestigeUpgrade = async (
|
||||
/**
|
||||
* Purchases a prestige upgrade on the server.
|
||||
* @param body - The buy prestige upgrade request payload.
|
||||
* @returns The buy prestige upgrade response data.
|
||||
*/
|
||||
const buyPrestigeUpgrade = async(
|
||||
body: BuyPrestigeUpgradeRequest,
|
||||
): Promise<BuyPrestigeUpgradeResponse> =>
|
||||
request<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
|
||||
): Promise<BuyPrestigeUpgradeResponse> => {
|
||||
return await fetchJson<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export const getPublicProfile = async (
|
||||
/**
|
||||
* Triggers a transcendence reset on the server.
|
||||
* @param body - The transcendence request payload.
|
||||
* @returns The transcendence response data.
|
||||
*/
|
||||
const transcend = async(
|
||||
body: TranscendenceRequest,
|
||||
): Promise<TranscendenceResponse> => {
|
||||
return await fetchJson<TranscendenceResponse>("/transcendence", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases an echo upgrade on the server.
|
||||
* @param body - The buy echo upgrade request payload.
|
||||
* @returns The buy echo upgrade response data.
|
||||
*/
|
||||
const buyEchoUpgrade = async(
|
||||
body: BuyEchoUpgradeRequest,
|
||||
): Promise<BuyEchoUpgradeResponse> => {
|
||||
return await fetchJson<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers an apotheosis reset on the server.
|
||||
* @param body - The apotheosis request payload.
|
||||
* @returns The apotheosis response data.
|
||||
*/
|
||||
const achieveApotheosis = async(
|
||||
body: ApotheosisRequest,
|
||||
): Promise<ApotheosisResponse> => {
|
||||
return await fetchJson<ApotheosisResponse>("/apotheosis", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts an exploration in a given area.
|
||||
* @param body - The exploration start request payload.
|
||||
* @returns The exploration start response data.
|
||||
*/
|
||||
const startExploration = async(
|
||||
body: ExploreStartRequest,
|
||||
): Promise<ExploreStartResponse> => {
|
||||
return await fetchJson<ExploreStartResponse>("/explore/start", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the rewards from a completed exploration.
|
||||
* @param body - The exploration collect request payload.
|
||||
* @returns The exploration collect response data.
|
||||
*/
|
||||
const collectExploration = async(
|
||||
body: ExploreCollectRequest,
|
||||
): Promise<ExploreCollectResponse> => {
|
||||
return await fetchJson<ExploreCollectResponse>("/explore/collect", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param body - The craft recipe request payload.
|
||||
* @returns The craft recipe response data.
|
||||
*/
|
||||
const craftRecipe = async(
|
||||
body: CraftRecipeRequest,
|
||||
): Promise<CraftRecipeResponse> => {
|
||||
return await fetchJson<CraftRecipeResponse>("/craft", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param discordId - The Discord ID of the player to look up.
|
||||
* @returns The public profile response data.
|
||||
*/
|
||||
const getPublicProfile = async(
|
||||
discordId: string,
|
||||
): Promise<PublicProfileResponse> =>
|
||||
request<PublicProfileResponse>(`/profile/${discordId}`);
|
||||
): Promise<PublicProfileResponse> => {
|
||||
return await fetchJson<PublicProfileResponse>(`/profile/${discordId}`);
|
||||
};
|
||||
|
||||
export const updateProfile = async (
|
||||
/**
|
||||
* Updates the current player's profile.
|
||||
* @param body - The update profile request payload.
|
||||
* @returns The update profile response data.
|
||||
*/
|
||||
const updateProfile = async(
|
||||
body: UpdateProfileRequest,
|
||||
): Promise<UpdateProfileResponse> =>
|
||||
request<UpdateProfileResponse>("/profile", {
|
||||
): Promise<UpdateProfileResponse> => {
|
||||
return await fetchJson<UpdateProfileResponse>("/profile", {
|
||||
body: JSON.stringify(body),
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
achieveApotheosis,
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
challengeBoss,
|
||||
checkExplorationClaimable,
|
||||
collectExploration,
|
||||
craftRecipe,
|
||||
debugHardReset,
|
||||
forceUnlocks,
|
||||
syncNewContent,
|
||||
getAbout,
|
||||
getAuthUrl,
|
||||
getPublicProfile,
|
||||
handleAuthCallback,
|
||||
loadGame,
|
||||
prestige,
|
||||
resetProgress,
|
||||
saveGame,
|
||||
startExploration,
|
||||
transcend,
|
||||
updateProfile,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @file Root application component that handles routing and authentication.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type JSX, useState } from "react";
|
||||
import { CharacterPage } from "./components/game/characterPage.js";
|
||||
import { GameLayout } from "./components/game/gameLayout.js";
|
||||
import { LeaderboardPage } from "./components/game/leaderboardPage.js";
|
||||
import { LoginPage } from "./components/game/loginPage.js";
|
||||
import { ProfilePage } from "./components/game/profilePage.js";
|
||||
import { GameProvider } from "./context/gameContext.js";
|
||||
|
||||
const getProfileDiscordId = (): string | null => {
|
||||
const match = /^\/profile\/(?<id>\d+)$/.exec(window.location.pathname);
|
||||
return match?.groups?.id ?? null;
|
||||
};
|
||||
|
||||
const getCharacterDiscordId = (): string | null => {
|
||||
const match = /^\/character\/(?<id>\d+)$/.exec(window.location.pathname);
|
||||
return match?.groups?.id ?? null;
|
||||
};
|
||||
|
||||
const handleAuthCallback = (): boolean => {
|
||||
if (window.location.pathname !== "/auth/callback") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const token = parameters.get("token");
|
||||
|
||||
if (token !== null && token.length > 0) {
|
||||
localStorage.setItem("elysium_token", token);
|
||||
}
|
||||
|
||||
window.history.replaceState(null, "", "/");
|
||||
return token !== null && token.length > 0;
|
||||
};
|
||||
|
||||
const isAuthenticated = (): boolean => {
|
||||
const fromCallback = handleAuthCallback();
|
||||
if (fromCallback) {
|
||||
return true;
|
||||
}
|
||||
const storedToken = localStorage.getItem("elysium_token");
|
||||
return storedToken !== null && storedToken.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the root application component, handling routing and authentication.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const app = (): JSX.Element => {
|
||||
const [ loggedIn, setLoggedIn ] = useState(isAuthenticated);
|
||||
|
||||
const profileDiscordId = getProfileDiscordId();
|
||||
if (profileDiscordId !== null) {
|
||||
return <ProfilePage discordId={profileDiscordId} />;
|
||||
}
|
||||
|
||||
const characterDiscordId = getCharacterDiscordId();
|
||||
if (characterDiscordId !== null) {
|
||||
return <CharacterPage discordId={characterDiscordId} />;
|
||||
}
|
||||
|
||||
if (window.location.pathname === "/leaderboards") {
|
||||
return <LeaderboardPage />;
|
||||
}
|
||||
|
||||
function handleLogin(): void {
|
||||
setLoggedIn(true);
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return <LoginPage onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GameProvider>
|
||||
<GameLayout />
|
||||
</GameProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export { app as App };
|
||||
@@ -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 };
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAbout } from "../../api/client.js";
|
||||
import type { AboutResponse } from "@elysium/types";
|
||||
|
||||
const HOW_TO_PLAY = [
|
||||
{
|
||||
title: "⚔️ Adventurers",
|
||||
body: "Hire adventurers to earn gold and essence automatically. Each tier is more powerful than the last. Adventurers also contribute combat power for boss fights — the more you recruit, the stronger your party becomes.",
|
||||
},
|
||||
{
|
||||
title: "👆 Clicking",
|
||||
body: "Click the guild hall to earn gold manually. Upgrades and equipment can dramatically increase your gold per click. Clicking is especially powerful in the early game and when saving up for big purchases.",
|
||||
},
|
||||
{
|
||||
title: "🔧 Upgrades",
|
||||
body: "Purchase upgrades to multiply the gold and essence output of specific adventurer tiers, or boost your whole guild. Upgrades are permanent for the current run and compound with each other.",
|
||||
},
|
||||
{
|
||||
title: "📜 Quests",
|
||||
body: "Send your guild on quests that complete over time and reward gold, essence, crystals, equipment, and upgrades. Multiple quests can run simultaneously. Completing quests also unlocks new zones.",
|
||||
},
|
||||
{
|
||||
title: "👹 Boss Fights",
|
||||
body: "Challenge zone bosses to earn large one-time rewards and unlock new zones. Your party's combat power is based on the number and tier of adventurers you've recruited. Defeated bosses cannot be re-fought, but undefeated bosses regenerate HP over time.",
|
||||
},
|
||||
{
|
||||
title: "🗺️ Zones",
|
||||
body: "New zones unlock when you defeat the final boss AND complete the final quest of the previous zone. Each zone contains new bosses and quests with progressively greater rewards.",
|
||||
},
|
||||
{
|
||||
title: "🗡️ Equipment & Sets",
|
||||
body: "Earn equipment from boss drops and quest rewards. Each piece provides bonuses to gold income, click power, or combat. Rarer equipment provides stronger bonuses. 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: "⭐ Prestige",
|
||||
body: "When you've progressed far enough, you can prestige to earn runestones — a permanent currency that persists across all runs. Prestige resets your current run but grants a production multiplier that stacks with every prestige. Name your prestige character to commemorate the run!",
|
||||
},
|
||||
{
|
||||
title: "🔮 Runestones & Prestige Upgrades",
|
||||
body: "Spend runestones in the Prestige Shop on permanent upgrades that carry over across all future runs. These upgrades multiply income, click power, essence, and crystal gain — making each new run more powerful than the last.",
|
||||
},
|
||||
{
|
||||
title: "⚙️ Auto-Prestige",
|
||||
body: "Purchase the Autonomous Ascension upgrade in the Prestige Shop (100 runestones) to unlock the Auto-Prestige toggle. When enabled, you will automatically ascend the moment you reach the prestige threshold, using your current character name. Toggle it on and off freely from the Prestige Shop.",
|
||||
},
|
||||
{
|
||||
title: "🏆 Achievements",
|
||||
body: "Earn achievements by hitting milestones — total gold earned, bosses defeated, quests completed, and more. Achievements are purely cosmetic and track your long-term progress across all prestige runs.",
|
||||
},
|
||||
{
|
||||
title: "📅 Daily Challenges",
|
||||
body: "Complete daily challenges for bonus rewards including gold, essence, crystals, and runestones. Challenges reset each day and vary in difficulty. Completing all daily challenges gives an extra bonus reward.",
|
||||
},
|
||||
{
|
||||
title: "☁️ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateStr: string): string =>
|
||||
new Date(dateStr).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export const AboutPanel = (): React.JSX.Element => {
|
||||
const [about, setAbout] = useState<AboutResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRelease, setExpandedRelease] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAbout()
|
||||
.then(setAbout)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load about data.");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="panel about-panel">
|
||||
<h2>ℹ️ About</h2>
|
||||
|
||||
<div className="about-versions">
|
||||
<div className="about-version-card">
|
||||
<span className="about-version-label">🌐 Client Version</span>
|
||||
<span className="about-version-value">{__WEB_VERSION__}</span>
|
||||
</div>
|
||||
<div className="about-version-card">
|
||||
<span className="about-version-label">⚙️ API Version</span>
|
||||
<span className="about-version-value">{about?.apiVersion ?? "Loading..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">📋 Changelog</h3>
|
||||
{error !== null && <p className="about-error">{error}</p>}
|
||||
{about === null && error === null && <p className="about-loading">Loading changelog...</p>}
|
||||
{about !== null && about.releases.length === 0 && (
|
||||
<p className="about-empty">No releases yet.</p>
|
||||
)}
|
||||
{about !== null && about.releases.length > 0 && (
|
||||
<ul className="about-releases">
|
||||
{about.releases.map((release) => (
|
||||
<li key={release.tag_name} className="about-release">
|
||||
<button
|
||||
className="about-release-header"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedRelease(
|
||||
expandedRelease === release.tag_name ? null : release.tag_name,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="about-release-tag">{release.name || release.tag_name}</span>
|
||||
<span className="about-release-date">{formatDate(release.published_at)}</span>
|
||||
<span className="about-release-chevron">
|
||||
{expandedRelease === release.tag_name ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRelease === release.tag_name && (
|
||||
<pre className="about-release-body">{release.body}</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="stats-section-header">📖 How to Play</h3>
|
||||
<ul className="about-how-to-play">
|
||||
{HOW_TO_PLAY.map((section) => (
|
||||
<li key={section.title} className="about-htp-section">
|
||||
<h4 className="about-htp-title">{section.title}</h4>
|
||||
<p className="about-htp-body">{section.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { Achievement } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const conditionDescription = (achievement: Achievement, formatNumber: (n: number) => string): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total gold`;
|
||||
case "totalClicks":
|
||||
return `Click ${formatNumber(condition.amount)} times`;
|
||||
case "bossesDefeated":
|
||||
return `Defeat ${condition.amount} boss${condition.amount > 1 ? "es" : ""}`;
|
||||
case "questsCompleted":
|
||||
return `Complete ${condition.amount} quest${condition.amount > 1 ? "s" : ""}`;
|
||||
case "adventurerTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
|
||||
case "prestigeCount":
|
||||
return `Prestige ${condition.amount} time${condition.amount > 1 ? "s" : ""}`;
|
||||
case "equipmentOwned":
|
||||
return `Own ${condition.amount} equipment item${condition.amount > 1 ? "s" : ""}`;
|
||||
}
|
||||
};
|
||||
|
||||
interface AchievementCardProps {
|
||||
achievement: Achievement;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const AchievementCard = ({ achievement, formatNumber }: AchievementCardProps): React.JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked ? "unlocked" : "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">{conditionDescription(achievement, formatNumber)}</p>
|
||||
{achievement.reward?.crystals != null && (
|
||||
<p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked ? (
|
||||
<span className="achievement-unlocked-badge">✓ Unlocked</span>
|
||||
) : (
|
||||
<span className="achievement-locked-badge">🔒</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AchievementPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const achievements = state.achievements ?? [];
|
||||
const unlocked = achievements.filter((a) => a.unlockedAt !== null);
|
||||
const locked = achievements.filter((a) => a.unlockedAt === null);
|
||||
const visible = showLocked ? achievements : unlocked;
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Achievements</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length} / {achievements.length} unlocked
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => (
|
||||
<AchievementCard key={achievement.id} achievement={achievement} formatNumber={formatNumber} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface ToastItemProps {
|
||||
achievement: Achievement;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastItem = ({ achievement, onDismiss }: ToastItemProps): React.JSX.Element => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(achievement.id);
|
||||
}, 4000);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [achievement.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div className="achievement-toast" onClick={() => { onDismiss(achievement.id); }}>
|
||||
<span className="toast-icon">{achievement.icon}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">Achievement Unlocked!</span>
|
||||
<span className="toast-name">{achievement.name}</span>
|
||||
{achievement.reward?.crystals != null && (
|
||||
<span className="toast-reward">💎 +{achievement.reward.crystals}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AchievementToast = (): React.JSX.Element | null => {
|
||||
const { newAchievements, dismissAchievement } = useGame();
|
||||
|
||||
if (newAchievements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{newAchievements.map((achievement) => (
|
||||
<ToastItem
|
||||
key={achievement.id}
|
||||
achievement={achievement}
|
||||
onDismiss={dismissAchievement}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const CLASS_ICONS: Record<string, string> = {
|
||||
warrior: "🗡️",
|
||||
mage: "🔮",
|
||||
rogue: "🗝️",
|
||||
cleric: "✝️",
|
||||
ranger: "🏹",
|
||||
paladin: "🛡️",
|
||||
};
|
||||
|
||||
const adventurerCost = (adventurer: Adventurer): number =>
|
||||
Math.ceil(10 * Math.pow(1.15, adventurer.count));
|
||||
|
||||
interface AdventurerCardProps {
|
||||
adventurer: Adventurer;
|
||||
currentGold: number;
|
||||
unlockHint?: string | undefined;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
const cost = adventurerCost(adventurer);
|
||||
const canAfford = currentGold >= cost;
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
|
||||
<div className="adventurer-icon">{CLASS_ICONS[adventurer.class] ?? "⚔️"}</div>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>{formatNumber(adventurer.goldPerSecond)} gold/s each</p>
|
||||
{adventurer.essencePerSecond > 0 && (
|
||||
<p>{formatNumber(adventurer.essencePerSecond)} essence/s each</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="adventurer-count">×{adventurer.count}</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={() => { buyAdventurer(adventurer.id); }}
|
||||
type="button"
|
||||
>
|
||||
{adventurer.unlocked ? `🪙 ${formatNumber(cost)}` : "🔒 Locked"}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint && (
|
||||
<p className="unlock-hint">📜 Complete: {unlockHint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdventurerPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const locked = state.adventurers.filter((a) => !a.unlocked);
|
||||
const visible = showLocked ? state.adventurers : state.adventurers.filter((a) => a.unlocked);
|
||||
|
||||
const adventurerUnlockHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId) {
|
||||
adventurerUnlockHints.set(reward.targetId, quest.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Adventurers</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="adventurer-list">
|
||||
{visible.map((adventurer) => (
|
||||
<AdventurerCard
|
||||
key={adventurer.id}
|
||||
adventurer={adventurer}
|
||||
currentGold={state.resources.gold}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { BattleResult } from "../../context/GameContext.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface BattleModalProps {
|
||||
battle: BattleResult;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const BattleModal = ({
|
||||
battle,
|
||||
onDismiss,
|
||||
}: BattleModalProps): React.JSX.Element => {
|
||||
const { result, bossName } = battle;
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
const [phase, setPhase] = useState<"animating" | "result">("animating");
|
||||
|
||||
// Starting HP percentages
|
||||
const bossStartPercent = (result.bossHpBefore / result.bossMaxHp) * 100;
|
||||
const partyStartPercent = 100;
|
||||
|
||||
// Target HP percentages (after battle)
|
||||
const bossEndPercent = (result.bossHpAtBattleEnd / result.bossMaxHp) * 100;
|
||||
const partyEndPercent = result.partyMaxHp > 0
|
||||
? (result.partyHpRemaining / result.partyMaxHp) * 100
|
||||
: 0;
|
||||
|
||||
const [bossHpPercent, setBossHpPercent] = useState(bossStartPercent);
|
||||
const [partyHpPercent, setPartyHpPercent] = useState(partyStartPercent);
|
||||
|
||||
useEffect(() => {
|
||||
// Brief delay so CSS transition has a starting point to animate from
|
||||
const startAnimation = setTimeout(() => {
|
||||
setBossHpPercent(bossEndPercent);
|
||||
setPartyHpPercent(partyEndPercent);
|
||||
}, 200);
|
||||
|
||||
// Reveal result after animation completes
|
||||
const revealResult = setTimeout(() => {
|
||||
setPhase("result");
|
||||
}, 5_200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(startAnimation);
|
||||
clearTimeout(revealResult);
|
||||
};
|
||||
}, [bossEndPercent, partyEndPercent]);
|
||||
|
||||
const bossHpBarColour = bossHpPercent > 50
|
||||
? "#e74c3c"
|
||||
: bossHpPercent > 25
|
||||
? "#e67e22"
|
||||
: "#c0392b";
|
||||
|
||||
const partyHpBarColour = partyHpPercent > 50
|
||||
? "#27ae60"
|
||||
: partyHpPercent > 25
|
||||
? "#f39c12"
|
||||
: "#e74c3c";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal battle-modal">
|
||||
<h2>⚔️ Battle: {bossName}</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">Your Party DPS</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat-divider">vs</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">Boss DPS</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="battle-bars">
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">👹 {bossName}</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill"
|
||||
style={{
|
||||
width: `${bossHpPercent.toFixed(1)}%`,
|
||||
backgroundColor: bossHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.bossHpAtBattleEnd)} / {formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vs-divider">⚔️ VS ⚔️</div>
|
||||
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">🛡️ Your Party</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill party-hp"
|
||||
style={{
|
||||
width: `${partyHpPercent.toFixed(1)}%`,
|
||||
backgroundColor: partyHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.partyHpRemaining)} / {formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === "animating" && (
|
||||
<p className="battle-in-progress">Battling…</p>
|
||||
)}
|
||||
|
||||
{phase === "result" && (
|
||||
<div className={`battle-outcome ${result.won ? "victory" : "defeat"}`}>
|
||||
{result.won ? (
|
||||
<>
|
||||
<h3>🏆 Victory!</h3>
|
||||
{result.rewards && (
|
||||
<div className="battle-rewards">
|
||||
<p>Rewards:</p>
|
||||
<span>🪙 {formatNumber(result.rewards.gold)} gold</span>
|
||||
{result.rewards.essence > 0 && (
|
||||
<span>✨ {formatNumber(result.rewards.essence)} essence</span>
|
||||
)}
|
||||
{result.rewards.crystals > 0 && (
|
||||
<span>💎 {formatNumber(result.rewards.crystals)} crystals</span>
|
||||
)}
|
||||
{result.rewards.bountyRunestones > 0 && (
|
||||
<span className="battle-bounty">🔮 {formatNumber(result.rewards.bountyRunestones)} runestones (first kill!)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>💀 Defeat</h3>
|
||||
<p>Your party was defeated. The boss has reset.</p>
|
||||
{result.casualties && result.casualties.length > 0 && (
|
||||
<div className="battle-casualties">
|
||||
<p>Casualties:</p>
|
||||
{result.casualties.map((c) => (
|
||||
<span key={c.adventurerId}>
|
||||
☠️ {c.killed} {c.adventurerId} lost
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { Boss } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
interface BossCardProps {
|
||||
boss: Boss;
|
||||
prestigeCount: number;
|
||||
onChallenge: (bossId: string) => void;
|
||||
isChallenging: boolean;
|
||||
unlockHint?: string | undefined;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const BossCard = ({
|
||||
boss,
|
||||
prestigeCount,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: BossCardProps): React.JSX.Element => {
|
||||
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
|
||||
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
||||
const canChallenge =
|
||||
(boss.status === "available" || boss.status === "in_progress") && !isChallenging;
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{isPrestigeLocked && boss.status === "locked" && (
|
||||
<p className="prestige-lock">
|
||||
🔒 Requires Prestige {boss.prestigeRequirement}
|
||||
</p>
|
||||
)}
|
||||
{!isPrestigeLocked && boss.status === "locked" && unlockHint && (
|
||||
<p className="unlock-hint">{unlockHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated" && (
|
||||
<div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} HP
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
<span>🪙 {formatNumber(boss.goldReward)}</span>
|
||||
{boss.essenceReward > 0 && (
|
||||
<span>✨ {formatNumber(boss.essenceReward)}</span>
|
||||
)}
|
||||
{boss.crystalReward > 0 && (
|
||||
<span>💎 {formatNumber(boss.crystalReward)}</span>
|
||||
)}
|
||||
{(boss.equipmentRewards ?? []).length > 0 && (
|
||||
<span>🗡️ {boss.equipmentRewards.length} Equipment</span>
|
||||
)}
|
||||
{boss.status !== "defeated" && boss.bountyRunestones > 0 && (
|
||||
<span className="boss-bounty">🔮 {boss.bountyRunestones} (first kill)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(boss.status === "available" || boss.status === "in_progress") && (
|
||||
<button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={() => {
|
||||
onChallenge(boss.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{boss.status === "defeated" && (
|
||||
<span className="boss-badge defeated">☠️ Defeated</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BossPanel = (): React.JSX.Element => {
|
||||
const { state, challengeBoss, formatNumber } = useGame();
|
||||
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
|
||||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
// Calculate party combat stats including equipment multiplier
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
const equipmentCombatMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyHP = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) continue;
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased &&
|
||||
upgrade.target === "adventurer" &&
|
||||
upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
partyDPS +=
|
||||
adventurer.combatPower *
|
||||
adventurer.count *
|
||||
adventurerMultiplier *
|
||||
globalMultiplier *
|
||||
prestigeMultiplier;
|
||||
partyHP += adventurer.level * 50 * adventurer.count;
|
||||
}
|
||||
partyDPS *= equipmentCombatMultiplier;
|
||||
|
||||
const handleChallenge = async (bossId: string): Promise<void> => {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const zoneBosses = state.bosses.filter((b) => b.zoneId === activeZoneId);
|
||||
const lockedCount = zoneBosses.filter((b) => b.status === "locked").length;
|
||||
const visibleBosses = showLocked
|
||||
? zoneBosses
|
||||
: zoneBosses.filter((b) => b.status !== "locked");
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const allZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
|
||||
for (let i = 0; i < allZoneBosses.length; i++) {
|
||||
const boss = allZoneBosses[i];
|
||||
if (!boss || boss.status !== "locked") continue;
|
||||
if (i === 0) {
|
||||
const parts: string[] = [];
|
||||
if (zone.unlockBossId) {
|
||||
const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
|
||||
if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
if (zone.unlockQuestId) {
|
||||
const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId);
|
||||
if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const prevBoss = allZoneBosses[i - 1];
|
||||
if (prevBoss) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Boss Encounters</h2>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={setActiveZoneId}
|
||||
/>
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">⚔️ Party DPS</span>
|
||||
<span className="stat-value">{formatNumber(partyDPS)}</span>
|
||||
</div>
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">❤️ Party HP</span>
|
||||
<span className="stat-value">{formatNumber(partyHP)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boss-list">
|
||||
{visibleBosses.map((boss) => (
|
||||
<BossCard
|
||||
key={boss.id}
|
||||
boss={boss}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
prestigeCount={state.prestige.count}
|
||||
unlockHint={bossUnlockHints.get(boss.id)}
|
||||
onChallenge={(id) => {
|
||||
void handleChallenge(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{visibleBosses.length === 0 && (
|
||||
<p className="empty-zone">No bosses to show in this zone.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { calculateClickPower } from "../../engine/tick.js";
|
||||
|
||||
interface FloatText {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ClickArea = (): React.JSX.Element => {
|
||||
const { state, handleClick, formatNumber } = useGame();
|
||||
const [floats, setFloats] = useState<FloatText[]>([]);
|
||||
const nextIdRef = useRef(0);
|
||||
|
||||
const handleClickWithFloat = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!state) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = nextIdRef.current++;
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
setFloats((prev) => [...prev, { id, x, y, text: `+${formatNumber(clickPower)}` }]);
|
||||
handleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
setFloats((prev) => prev.filter((f) => f.id !== id));
|
||||
}, 900);
|
||||
},
|
||||
[state, handleClick],
|
||||
);
|
||||
|
||||
if (!state) return <div className="click-area-placeholder" />;
|
||||
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
return (
|
||||
<section className="click-area">
|
||||
<h2>Guild Hall</h2>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
className="click-button"
|
||||
onClick={handleClickWithFloat}
|
||||
type="button"
|
||||
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
|
||||
>
|
||||
⚔️
|
||||
</button>
|
||||
{floats.map((float) => (
|
||||
<span
|
||||
key={float.id}
|
||||
className="click-float"
|
||||
style={{ left: float.x, top: float.y }}
|
||||
>
|
||||
{float.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="click-power">+{formatNumber(clickPower)} gold/click</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const formatTimeUntilReset = (): string => {
|
||||
const now = new Date();
|
||||
// Mirror the server's PST/PDT-based rollover: challenges reset at PST midnight
|
||||
const nowAsPST = new Date(now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }));
|
||||
const tomorrowMidnightPST = new Date(nowAsPST);
|
||||
tomorrowMidnightPST.setDate(tomorrowMidnightPST.getDate() + 1);
|
||||
tomorrowMidnightPST.setHours(0, 0, 0, 0);
|
||||
const pstOffset = nowAsPST.getTime() - now.getTime();
|
||||
const resetAt = new Date(tomorrowMidnightPST.getTime() - pstOffset);
|
||||
const msRemaining = resetAt.getTime() - now.getTime();
|
||||
const hoursRemaining = Math.floor(msRemaining / (1000 * 60 * 60));
|
||||
const minutesRemaining = Math.floor((msRemaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`;
|
||||
};
|
||||
|
||||
export const DailyChallengePanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { dailyChallenges } = state;
|
||||
|
||||
if (!dailyChallenges) {
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>📅 Daily Challenges</h2>
|
||||
<p className="daily-challenge-subtitle">Load the game to generate today's challenges!</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = dailyChallenges.challenges.filter((c) => c.completed).length;
|
||||
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>📅 Daily Challenges</h2>
|
||||
<div className="daily-challenge-header">
|
||||
<p className="daily-challenge-subtitle">
|
||||
Complete challenges for bonus 💎 crystals! Resets in{" "}
|
||||
<strong>{formatTimeUntilReset()}</strong> (PST midnight).
|
||||
</p>
|
||||
<p className="daily-challenge-progress">
|
||||
{completedCount} / {dailyChallenges.challenges.length} completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-list">
|
||||
{dailyChallenges.challenges.map((challenge) => {
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.floor((challenge.progress / challenge.target) * 100),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className={`daily-challenge-card ${challenge.completed ? "completed" : ""}`}
|
||||
>
|
||||
<div className="daily-challenge-info">
|
||||
<h3 className="daily-challenge-label">{challenge.label}</h3>
|
||||
<p className="daily-challenge-reward">
|
||||
Reward: <strong>💎 {formatNumber(challenge.rewardCrystals)} crystals</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-right">
|
||||
{challenge.completed ? (
|
||||
<span className="daily-challenge-done">✅ Complete!</span>
|
||||
) : (
|
||||
<>
|
||||
<p className="daily-challenge-count">
|
||||
{formatNumber(challenge.progress)} / {formatNumber(challenge.target)}
|
||||
</p>
|
||||
<div className="daily-challenge-bar-track">
|
||||
<div
|
||||
className="daily-challenge-bar-fill"
|
||||
style={{ width: `${String(progressPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { NumberFormat, ProfileSettings } from "@elysium/types";
|
||||
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface EditProfileModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const STAT_TOGGLES: { key: keyof ProfileSettings; label: string; icon: string }[] = [
|
||||
{ key: "showTotalGold", label: "Total Gold Earned", icon: "🪙" },
|
||||
{ key: "showTotalClicks", label: "Total Clicks", icon: "👆" },
|
||||
{ key: "showPrestige", label: "Prestige Level", icon: "⭐" },
|
||||
{ key: "showBossesDefeated", label: "Bosses Defeated", icon: "💀" },
|
||||
{ key: "showQuestsCompleted", label: "Quests Completed", icon: "📜" },
|
||||
{ key: "showAdventurersRecruited", label: "Adventurers Recruited", icon: "⚔️" },
|
||||
{ key: "showAchievementsUnlocked", label: "Achievements Unlocked", icon: "🏆" },
|
||||
{ key: "showGuildFounded", label: "Guild Founded Date", icon: "📅" },
|
||||
];
|
||||
|
||||
export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.Element => {
|
||||
const { state, numberFormat: currentNumberFormat, setNumberFormat } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [characterName, setCharacterName] = useState(player?.characterName ?? "");
|
||||
const [bio, setBio] = useState("");
|
||||
const [settings, setSettings] = useState<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
numberFormat: currentNumberFormat,
|
||||
});
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Fetch current profile to auto-populate bio and settings
|
||||
useEffect(() => {
|
||||
if (!player?.discordId) return;
|
||||
fetch(`/api/profile/${player.discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
characterName: string;
|
||||
};
|
||||
setBio(data.bio ?? "");
|
||||
setSettings({ ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings });
|
||||
setCharacterName(data.characterName ?? player.characterName ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to local state if fetch fails — not a blocking error
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [player?.discordId, player?.characterName]);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({ characterName, bio, profileSettings: settings });
|
||||
setNumberFormat(settings.numberFormat);
|
||||
setSaved(true);
|
||||
setTimeout(onClose, 900);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSetting = (key: keyof ProfileSettings): void => {
|
||||
setSettings((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div className="modal edit-profile-modal">
|
||||
<div className="modal-header">
|
||||
<h2>Edit Profile</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingProfile ? (
|
||||
<p className="edit-profile-loading">Loading your profile…</p>
|
||||
) : (
|
||||
<div className="edit-profile-form">
|
||||
<label className="edit-profile-label" htmlFor="edit-char-name">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
className="edit-profile-input"
|
||||
id="edit-char-name"
|
||||
maxLength={32}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={characterName}
|
||||
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||
/>
|
||||
<span className="edit-profile-hint">{characterName.length} / 32</span>
|
||||
|
||||
<label className="edit-profile-label" htmlFor="edit-bio">
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
className="edit-profile-textarea"
|
||||
id="edit-bio"
|
||||
maxLength={200}
|
||||
placeholder="Tell the world about your guild… (optional)"
|
||||
rows={3}
|
||||
value={bio}
|
||||
onChange={(e) => { setBio(e.target.value); }}
|
||||
/>
|
||||
<span className="edit-profile-hint">{bio.length} / 200</span>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Visible Stats</p>
|
||||
<p className="edit-profile-sublabel">Choose which stats appear on your public profile.</p>
|
||||
<div className="stat-toggles">
|
||||
{STAT_TOGGLES.map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`stat-toggle-btn ${settings[key] ? "stat-toggle-on" : "stat-toggle-off"}`}
|
||||
onClick={() => { toggleSetting(key); }}
|
||||
type="button"
|
||||
>
|
||||
<span>{icon} {label}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{settings[key] ? "✓ Shown" : "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Number Format</p>
|
||||
<p className="edit-profile-sublabel">How large numbers appear across the game.</p>
|
||||
<div className="number-format-picker">
|
||||
{(
|
||||
[
|
||||
{ value: "suffix", label: "Suffix", example: "1.23Qa" },
|
||||
{ value: "scientific", label: "Scientific", example: "1.23e15" },
|
||||
{ value: "engineering", label: "Engineering", example: "1.23E15" },
|
||||
] as { value: NumberFormat; label: string; example: string }[]
|
||||
).map(({ value, label, example }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`number-format-btn ${settings.numberFormat === value ? "number-format-active" : ""}`}
|
||||
onClick={() => { setSettings((prev) => ({ ...prev, numberFormat: value })); }}
|
||||
type="button"
|
||||
>
|
||||
<span className="number-format-label">{label}</span>
|
||||
<span className="number-format-example">{example}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="edit-profile-error">{error}</p>}
|
||||
|
||||
<div className="edit-profile-actions">
|
||||
<button
|
||||
className="edit-profile-cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="edit-profile-save"
|
||||
disabled={saving || !characterName.trim()}
|
||||
onClick={() => { void handleSave(); }}
|
||||
type="button"
|
||||
>
|
||||
{saved ? "✓ Saved!" : saving ? "Saving…" : "Save Profile"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
const RARITY_LABEL: Record<string, string> = {
|
||||
common: "Common",
|
||||
rare: "Rare",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
const TYPE_ICON: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️",
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
};
|
||||
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: string[] = [];
|
||||
if (item.bonus.combatMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.combatMultiplier - 1) * 100)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.goldMultiplier - 1) * 100)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.clickMultiplier - 1) * 100)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProps {
|
||||
item: Equipment;
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
dropBossName?: string | undefined;
|
||||
setName?: string | undefined;
|
||||
}
|
||||
|
||||
const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => {
|
||||
const parts: string[] = [];
|
||||
if (cost.gold > 0) parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
if (cost.essence > 0) parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
if (cost.crystals > 0) parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const EquipmentCard = ({ item, gold, essence, crystals, dropBossName, setName }: EquipmentCardProps): React.JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford = item.cost
|
||||
? gold >= item.cost.gold && essence >= item.cost.essence && crystals >= item.cost.crystals
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}>
|
||||
<div className="equipment-icon">{TYPE_ICON[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>{RARITY_LABEL[item.rarity]}</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName && <span className="equipment-set-badge">🔗 {setName}</span>}
|
||||
{!item.owned && item.cost && (
|
||||
<p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && !item.cost && (
|
||||
<span className="equipment-locked">
|
||||
{dropBossName ? `⚔️ Drop: ${dropBossName}` : "🔒 Boss drop"}
|
||||
</span>
|
||||
)}
|
||||
{!item.owned && item.cost && (
|
||||
<button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={() => { buyEquipment(item.id); }}
|
||||
type="button"
|
||||
>
|
||||
{canAfford ? "Purchase" : "Can't afford"}
|
||||
</button>
|
||||
)}
|
||||
{item.owned && item.equipped && <span className="equipment-equipped-badge">✓ Equipped</span>}
|
||||
{item.owned && !item.equipped && (
|
||||
<button
|
||||
className="equip-button"
|
||||
onClick={() => { equipItem(item.id); }}
|
||||
type="button"
|
||||
>
|
||||
Equip
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"];
|
||||
const SLOT_LABEL: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️ Weapons",
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
};
|
||||
|
||||
export const EquipmentPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const equipment = state.equipment ?? [];
|
||||
const unownedCount = equipment.filter((e) => !e.owned).length;
|
||||
|
||||
const equipmentDropSources = new Map<string, string>();
|
||||
for (const boss of state.bosses) {
|
||||
for (const equipmentId of (boss.equipmentRewards ?? [])) {
|
||||
equipmentDropSources.set(equipmentId, boss.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build set name lookup for card badges
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((s) => [s.id, s.name]),
|
||||
);
|
||||
|
||||
// Compute active set bonuses for the summary strip
|
||||
const equippedItemIds = equipment.filter((e) => e.equipped).map((e) => e.id);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length;
|
||||
return { set, count };
|
||||
}).filter(({ count }) => count >= 2);
|
||||
|
||||
const setBonusDescription = (set: typeof EQUIPMENT_SETS[number], count: number): string => {
|
||||
const parts: string[] = [];
|
||||
for (const threshold of [2, 3] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = set.bonuses[threshold];
|
||||
if (bonus.goldMultiplier) parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold/s (${threshold}pc)`);
|
||||
if (bonus.combatMultiplier) parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat (${threshold}pc)`);
|
||||
if (bonus.clickMultiplier) parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click (${threshold}pc)`);
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Equipment</h2>
|
||||
<LockToggle
|
||||
lockedCount={unownedCount}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time. Equip matching set pieces for bonus effects!
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0 && (
|
||||
<div className="active-sets">
|
||||
<h3 className="active-sets-heading">✨ Active Set Bonuses</h3>
|
||||
{activeSets.map(({ set, count }) => (
|
||||
<div key={set.id} className="active-set-row">
|
||||
<span className="active-set-name">{set.name} ({count}/{set.pieces.length})</span>
|
||||
<span className="active-set-bonus">{setBonusDescription(set, count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SLOT_ORDER.map((slotType) => {
|
||||
const items = equipment.filter(
|
||||
(e) => e.type === slotType && (showLocked || e.owned),
|
||||
);
|
||||
return (
|
||||
<div key={slotType} className="equipment-slot-section">
|
||||
<h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => (
|
||||
<EquipmentCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
gold={state.resources.gold}
|
||||
essence={state.resources.essence}
|
||||
crystals={state.resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
setName={item.setId ? setNameById.get(item.setId) : undefined}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="empty-zone">No items to show in this slot.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { ResourceBar } from "../ui/ResourceBar.js";
|
||||
import { AboutPanel } from "./AboutPanel.js";
|
||||
import { AchievementPanel } from "./AchievementPanel.js";
|
||||
import { AchievementToast } from "./AchievementToast.js";
|
||||
import { AdventurerPanel } from "./AdventurerPanel.js";
|
||||
import { BattleModal } from "./BattleModal.js";
|
||||
import { BossPanel } from "./BossPanel.js";
|
||||
import { ClickArea } from "./ClickArea.js";
|
||||
import { EditProfileModal } from "./EditProfileModal.js";
|
||||
import { EquipmentPanel } from "./EquipmentPanel.js";
|
||||
import { OfflineModal } from "./OfflineModal.js";
|
||||
import { PrestigePanel } from "./PrestigePanel.js";
|
||||
import { QuestPanel } from "./QuestPanel.js";
|
||||
import { StatisticsPanel } from "./StatisticsPanel.js";
|
||||
import { UpgradePanel } from "./UpgradePanel.js";
|
||||
import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily" | "about";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
{ id: "quests", label: "📜 Quests" },
|
||||
{ id: "bosses", label: "👹 Bosses" },
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "daily", label: "📅 Daily" },
|
||||
{ id: "about", label: "ℹ️ About" },
|
||||
];
|
||||
|
||||
export const GameLayout = (): React.JSX.Element => {
|
||||
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync } = useGame();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>Loading your adventure...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-screen">
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state) return <div className="loading-screen"><p>Loading...</p></div>;
|
||||
|
||||
const profileUrl = `/profile/${state.player.discordId}`;
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<ResourceBar
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
prestigeCount={state.prestige.count}
|
||||
profileUrl={profileUrl}
|
||||
onEditProfile={() => { setEditingProfile(true); }}
|
||||
lastSavedAt={lastSavedAt}
|
||||
isSyncing={isSyncing}
|
||||
onForceSync={forceSync}
|
||||
/>
|
||||
<OfflineModal />
|
||||
<AchievementToast />
|
||||
{battleResult && (
|
||||
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
)}
|
||||
{editingProfile && (
|
||||
<EditProfileModal onClose={() => { setEditingProfile(false); }} />
|
||||
)}
|
||||
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
<ClickArea />
|
||||
</aside>
|
||||
|
||||
<main className="game-content">
|
||||
<nav className="tab-bar">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab(tab.id); }}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
export const LoginPage = ({ onLogin }: LoginPageProps): React.JSX.Element => {
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle OAuth callback
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
|
||||
if (code) {
|
||||
setIsLoading(true);
|
||||
handleAuthCallback(code)
|
||||
.then(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
onLogin();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the Discord OAuth URL
|
||||
getAuthUrl()
|
||||
.then((url) => {
|
||||
setAuthUrl(url);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Failed to load authentication URL");
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [onLogin]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p className="error">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.reload(); }}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>⚔️ Elysium</h1>
|
||||
<p>An idle fantasy RPG. Hire adventurers, defeat bosses, and ascend to glory.</p>
|
||||
<a
|
||||
className="discord-login-button"
|
||||
href={authUrl ?? "#"}
|
||||
>
|
||||
Login with Discord
|
||||
</a>
|
||||
<p className="login-note">
|
||||
Your progress is saved to your Discord account and shareable with others!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
export const OfflineModal = (): React.JSX.Element | null => {
|
||||
const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber } = useGame();
|
||||
|
||||
if (offlineGold <= 0 && offlineEssence <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>Welcome back!</h2>
|
||||
<p>Your adventurers kept working whilst you were away and earned:</p>
|
||||
{offlineGold > 0 && (
|
||||
<p>
|
||||
<strong>🪙 {formatNumber(offlineGold)} gold</strong>
|
||||
</p>
|
||||
)}
|
||||
{offlineEssence > 0 && (
|
||||
<p>
|
||||
<strong>✨ {formatNumber(offlineEssence)} essence</strong>
|
||||
</p>
|
||||
)}
|
||||
<p className="modal-note">Offline progress is calculated up to 8 hours.</p>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={dismissOfflineGold}
|
||||
type="button"
|
||||
>
|
||||
Collect!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
|
||||
const BASE_THRESHOLD = 1_000_000;
|
||||
const THRESHOLD_SCALE = 5;
|
||||
const RUNESTONES_PER_LEVEL = 10;
|
||||
|
||||
const calculateThreshold = (prestigeCount: number): number =>
|
||||
BASE_THRESHOLD * Math.pow(THRESHOLD_SCALE, prestigeCount);
|
||||
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number =>
|
||||
Math.pow(1.15, prestigeCount);
|
||||
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: string[],
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base = Math.floor(Math.sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_LEVEL;
|
||||
const runestoneMult = PRESTIGE_UPGRADES
|
||||
.filter((u) => u.category === "runestones" && purchasedUpgradeIds.includes(u.id))
|
||||
.reduce((mult, u) => mult * u.multiplier, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: PrestigeUpgradeCategory[] = [
|
||||
"income",
|
||||
"click",
|
||||
"essence",
|
||||
"crystals",
|
||||
"runestones",
|
||||
"utility",
|
||||
];
|
||||
|
||||
export const PrestigePanel = (): React.JSX.Element => {
|
||||
const { state, reload, formatNumber, buyPrestigeUpgrade, toggleAutoPrestige } = useGame();
|
||||
const [characterName, setCharacterName] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [result, setResult] = useState<{ runestones: number; count: number; milestoneRunestones: number } | null>(null);
|
||||
const [prestigeError, setPrestigeError] = useState<string | null>(null);
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"prestige" | "shop">("prestige");
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
const handlePrestige = async (): Promise<void> => {
|
||||
if (!characterName.trim()) return;
|
||||
setIsPending(true);
|
||||
setPrestigeError(null);
|
||||
try {
|
||||
const data = await prestige({ characterName: characterName.trim() });
|
||||
setResult({ runestones: data.runestones, count: data.newPrestigeCount, milestoneRunestones: data.milestoneRunestones });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setPrestigeError(err instanceof Error ? err.message : "Prestige failed");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyUpgrade = async (upgradeId: string): Promise<void> => {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyPrestigeUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upgradesByCategory = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: PRESTIGE_UPGRADE_CATEGORY_LABELS[category] ?? category,
|
||||
upgrades: PRESTIGE_UPGRADES.filter((u) => u.category === category),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="panel prestige-panel">
|
||||
<h2>⭐ Prestige</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "prestige" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("prestige"); }}
|
||||
type="button"
|
||||
>
|
||||
Ascend
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("shop"); }}
|
||||
type="button"
|
||||
>
|
||||
🔮 Runestone Shop ({formatNumber(prestigeData.runestones)} stones)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "prestige" && (
|
||||
<>
|
||||
<p>
|
||||
Prestige resets your progress but grants <strong>Runestones</strong> — permanent
|
||||
currency used for powerful upgrades. Each prestige multiplies your global production
|
||||
by ×1.15 (compounding each run).
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
Total gold this run:{" "}
|
||||
<strong>{formatNumber(player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Required to prestige: <strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Prestige count: <strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Current production multiplier:{" "}
|
||||
<strong>×{prestigeData.productionMultiplier.toFixed(2)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
After next prestige:{" "}
|
||||
<strong>×{nextMultiplier.toFixed(2)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Runestones: <strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible && (
|
||||
<p className="runestone-preview">
|
||||
Runestones on prestige: <strong>+{formatNumber(runestonePreview)}</strong>
|
||||
</p>
|
||||
)}
|
||||
{!isEligible && (
|
||||
<p className="prestige-progress">
|
||||
Progress: {formatNumber(player.totalGoldEarned)} / {formatNumber(threshold)}{" "}
|
||||
({((player.totalGoldEarned / threshold) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEligible ? (
|
||||
<div className="prestige-form">
|
||||
<p>You are ready to prestige! Choose your new character name:</p>
|
||||
<input
|
||||
disabled={isPending}
|
||||
maxLength={32}
|
||||
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||
placeholder="Character name..."
|
||||
type="text"
|
||||
value={characterName}
|
||||
/>
|
||||
<button
|
||||
className="prestige-button"
|
||||
disabled={isPending || !characterName.trim()}
|
||||
onClick={() => { void handlePrestige(); }}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Ascending..." : `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError && <p className="error">{prestigeError}</p>}
|
||||
{result && (
|
||||
<p className="success">
|
||||
Ascended to Prestige {result.count}! Earned {formatNumber(result.runestones)} Runestones.
|
||||
{result.milestoneRunestones > 0 && (
|
||||
<> 🎉 Milestone bonus: +{formatNumber(result.milestoneRunestones)} Runestones!</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="prestige-locked">
|
||||
Earn {formatNumber(threshold - player.totalGoldEarned)} more gold to unlock prestige.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "shop" && (
|
||||
<div className="runestone-shop">
|
||||
<p className="shop-balance">
|
||||
Balance: <strong>{formatNumber(prestigeData.runestones)} Runestones</strong>
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ category, label, upgrades }) => (
|
||||
<div key={category} className="shop-category">
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = prestigeData.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = prestigeData.runestones >= upgrade.runestonesCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
const isAutoPrestigeToggle = upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled = prestigeData.autoPrestigeEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`shop-upgrade-card ${purchased ? "purchased" : ""} ${!canAfford && !purchased ? "unaffordable" : ""}`}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased ? "✅ Purchased" : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoPrestigeToggle && (
|
||||
<button
|
||||
className={`auto-prestige-toggle ${autoPrestigeEnabled ? "enabled" : "disabled"}`}
|
||||
onClick={() => { toggleAutoPrestige(); }}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled ? "⚡ Auto ON" : "⏸ Auto OFF"}
|
||||
</button>
|
||||
)}
|
||||
{!purchased && (
|
||||
<button
|
||||
className="buy-upgrade-button"
|
||||
disabled={!canAfford || isLoading || buyingId !== null}
|
||||
onClick={() => { void handleBuyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "Buying..." : "Buy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user