generated from nhcarrigan/template
Compare commits
36 Commits
v0.1.0
...
b0ed976a1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
b0ed976a1d
|
|||
|
b27454669e
|
|||
|
acda4c2fc4
|
|||
|
078ae50e69
|
|||
|
48bf74e713
|
|||
|
f84654263e
|
|||
|
3ff17bda84
|
|||
|
dc1353a15c
|
|||
|
aaeece1a18
|
|||
|
a7d4b72805
|
|||
|
5b4661b398
|
|||
|
6bc116a86a
|
|||
|
59c417e75e
|
|||
|
4e32709e07
|
|||
|
5aae3eb389
|
|||
|
50fe905610
|
|||
|
3e20613851
|
|||
|
46f095ff8b
|
|||
|
5ad2c44399
|
|||
|
24beaf3131
|
|||
|
285c38255b
|
|||
|
18ff4ce547
|
|||
|
fa1c46f17f
|
|||
|
e780dc5f6c
|
|||
|
b9a230f40f
|
|||
|
268504265c
|
|||
|
c5ea59ffb4
|
|||
|
42db6e1991
|
|||
|
772d733e86
|
|||
|
653c36c886
|
|||
|
7e04daa073
|
|||
|
32c13f73c4
|
|||
|
f734176965
|
|||
|
897eba5f64
|
|||
|
e9e0df31fd
|
|||
|
a3daed1683
|
@@ -0,0 +1,57 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Lint, Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check dependency pins
|
||||
uses: naomi-lgbt/dependency-pin-check@main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint (types package)
|
||||
run: pnpm --filter @elysium/types lint
|
||||
|
||||
- name: Lint (API)
|
||||
run: pnpm --filter @elysium/api lint
|
||||
|
||||
- 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 (API)
|
||||
run: pnpm --filter @elysium/api test
|
||||
|
||||
- name: Test (web)
|
||||
run: pnpm --filter @elysium/web test
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
prod/
|
||||
dist/
|
||||
.env
|
||||
*.env.local
|
||||
coverage/
|
||||
@@ -0,0 +1,5 @@
|
||||
# Elysium Project Notes
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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,3 @@
|
||||
import { NaomisConfig } from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...NaomisConfig];
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@elysium/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
"scripts": {
|
||||
"build": "prisma generate && tsc -p tsconfig.json",
|
||||
"db:push": "prisma db push",
|
||||
"dev": "op run --env-file=./prod.env -- tsx watch src/index.ts",
|
||||
"lint": "eslint --max-warnings 0 src",
|
||||
"start": "op run --env-file=./prod.env -- node prod/src/index.js",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"@hono/node-server": "1.13.7",
|
||||
"@prisma/client": "6.5.0",
|
||||
"hono": "4.7.4",
|
||||
"prisma": "6.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@types/node": "25.3.5",
|
||||
"@vitest/coverage-v8": "3.0.8",
|
||||
"eslint": "9.22.0",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "5.8.2",
|
||||
"vitest": "3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mongodb"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Player {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
discordId String @unique
|
||||
username String
|
||||
discriminator String
|
||||
avatar String?
|
||||
characterName String @default("")
|
||||
bio String @default("")
|
||||
profileSettings Json?
|
||||
createdAt Float
|
||||
lastSavedAt Float
|
||||
totalGoldEarned Float @default(0)
|
||||
totalClicks Float @default(0)
|
||||
}
|
||||
|
||||
model GameState {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
discordId String @unique
|
||||
state Json
|
||||
updatedAt Float
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
|
||||
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
||||
DISCORD_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"
|
||||
@@ -0,0 +1,359 @@
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
|
||||
// Click milestones
|
||||
{
|
||||
id: "first_click",
|
||||
name: "First Strike",
|
||||
description: "Click the Guild Hall for the first time.",
|
||||
icon: "👆",
|
||||
condition: { type: "totalClicks", amount: 1 },
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_enthusiast",
|
||||
name: "Click Enthusiast",
|
||||
description: "Click the Guild Hall 100 times.",
|
||||
icon: "🖱️",
|
||||
condition: { type: "totalClicks", amount: 100 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_master",
|
||||
name: "Click Master",
|
||||
description: "Click the Guild Hall 1,000 times.",
|
||||
icon: "⚡",
|
||||
condition: { type: "totalClicks", amount: 1_000 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_legend",
|
||||
name: "Click Legend",
|
||||
description: "Click the Guild Hall 10,000 times.",
|
||||
icon: "🌩️",
|
||||
condition: { type: "totalClicks", amount: 10_000 },
|
||||
reward: { crystals: 300 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Gold milestones
|
||||
{
|
||||
id: "first_gold",
|
||||
name: "First Gold",
|
||||
description: "Earn your first 100 gold.",
|
||||
icon: "🪙",
|
||||
condition: { type: "totalGoldEarned", amount: 100 },
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "wealthy",
|
||||
name: "Wealthy",
|
||||
description: "Earn 10,000 gold in total.",
|
||||
icon: "💰",
|
||||
condition: { type: "totalGoldEarned", amount: 10_000 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "rich",
|
||||
name: "Rich",
|
||||
description: "Earn 1,000,000 gold in total.",
|
||||
icon: "👑",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "billionaire",
|
||||
name: "Billionaire",
|
||||
description: "Earn 1,000,000,000 gold in total.",
|
||||
icon: "🏦",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
|
||||
reward: { crystals: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "trillionaire",
|
||||
name: "Trillionaire",
|
||||
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,
|
||||
},
|
||||
// Quest milestones
|
||||
{
|
||||
id: "first_quest",
|
||||
name: "Adventurous Spirit",
|
||||
description: "Complete your first quest.",
|
||||
icon: "📜",
|
||||
condition: { type: "questsCompleted", amount: 1 },
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_veteran",
|
||||
name: "Quest Veteran",
|
||||
description: "Complete 5 quests.",
|
||||
icon: "📚",
|
||||
condition: { type: "questsCompleted", amount: 5 },
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_master",
|
||||
name: "Quest Master",
|
||||
description: "Complete 15 quests.",
|
||||
icon: "🗺️",
|
||||
condition: { type: "questsCompleted", amount: 15 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Boss milestones
|
||||
{
|
||||
id: "boss_slayer",
|
||||
name: "Boss Slayer",
|
||||
description: "Defeat your first boss.",
|
||||
icon: "⚔️",
|
||||
condition: { type: "bossesDefeated", amount: 1 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "boss_veteran",
|
||||
name: "Boss Veteran",
|
||||
description: "Defeat 5 bosses.",
|
||||
icon: "🗡️",
|
||||
condition: { type: "bossesDefeated", amount: 5 },
|
||||
reward: { crystals: 150 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "legendary_hunter",
|
||||
name: "Legendary Hunter",
|
||||
description: "Defeat 10 bosses.",
|
||||
icon: "🏆",
|
||||
condition: { type: "bossesDefeated", amount: 10 },
|
||||
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,
|
||||
},
|
||||
// Adventurer milestones
|
||||
{
|
||||
id: "guild_master",
|
||||
name: "Guild Master",
|
||||
description: "Recruit a total of 50 adventurers.",
|
||||
icon: "🏰",
|
||||
condition: { type: "adventurerTotal", amount: 50 },
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "army_commander",
|
||||
name: "Army Commander",
|
||||
description: "Recruit a total of 500 adventurers.",
|
||||
icon: "🛡️",
|
||||
condition: { type: "adventurerTotal", amount: 500 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "army_legend",
|
||||
name: "Legendary Commander",
|
||||
description: "Recruit a total of 5,000 adventurers.",
|
||||
icon: "⚜️",
|
||||
condition: { type: "adventurerTotal", amount: 5_000 },
|
||||
reward: { crystals: 750 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Prestige milestones
|
||||
{
|
||||
id: "first_prestige",
|
||||
name: "Born Again",
|
||||
description: "Prestige for the first time.",
|
||||
icon: "⭐",
|
||||
condition: { type: "prestigeCount", amount: 1 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Collection milestones
|
||||
{
|
||||
id: "collector",
|
||||
name: "Collector",
|
||||
description: "Acquire your first piece of boss-dropped equipment.",
|
||||
icon: "🎒",
|
||||
condition: { type: "equipmentOwned", amount: 4 },
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "arsenal",
|
||||
name: "Arsenal",
|
||||
description: "Own 12 pieces of equipment.",
|
||||
icon: "🗃️",
|
||||
condition: { type: "equipmentOwned", amount: 12 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "well_armed",
|
||||
name: "Well Armed",
|
||||
description: "Own 25 pieces of equipment.",
|
||||
icon: "⚔️",
|
||||
condition: { type: "equipmentOwned", amount: 25 },
|
||||
reward: { crystals: 1_000 },
|
||||
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,
|
||||
},
|
||||
// Higher click milestones
|
||||
{
|
||||
id: "click_obsessed",
|
||||
name: "Click Obsessed",
|
||||
description: "Click the Guild Hall 100,000 times.",
|
||||
icon: "💥",
|
||||
condition: { type: "totalClicks", amount: 100_000 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_deity",
|
||||
name: "Click Deity",
|
||||
description: "Click the Guild Hall 1,000,000 times.",
|
||||
icon: "☄️",
|
||||
condition: { type: "totalClicks", amount: 1_000_000 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Endgame gold milestones
|
||||
{
|
||||
id: "quadrillionaire",
|
||||
name: "Quadrillionaire",
|
||||
description: "Earn 1 quadrillion gold in total.",
|
||||
icon: "✨",
|
||||
condition: { type: "totalGoldEarned", amount: 1e15 },
|
||||
reward: { crystals: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "void_hoarder",
|
||||
name: "Void Hoarder",
|
||||
description: "Earn 1 quintillion gold in total.",
|
||||
icon: "🌀",
|
||||
condition: { type: "totalGoldEarned", amount: 1e18 },
|
||||
reward: { crystals: 50_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher quest milestones
|
||||
{
|
||||
id: "quest_champion",
|
||||
name: "Quest Champion",
|
||||
description: "Complete 30 quests.",
|
||||
icon: "🏅",
|
||||
condition: { type: "questsCompleted", amount: 30 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_grandmaster",
|
||||
name: "Quest Grandmaster",
|
||||
description: "Complete 50 quests.",
|
||||
icon: "🎖️",
|
||||
condition: { type: "questsCompleted", amount: 50 },
|
||||
reward: { crystals: 5_000 },
|
||||
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,
|
||||
},
|
||||
// Higher boss milestones
|
||||
{
|
||||
id: "boss_champion",
|
||||
name: "Champion of the Realm",
|
||||
description: "Defeat 20 bosses.",
|
||||
icon: "🦁",
|
||||
condition: { type: "bossesDefeated", amount: 20 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "boss_grandmaster",
|
||||
name: "Grandmaster Hunter",
|
||||
description: "Defeat 30 bosses.",
|
||||
icon: "🔱",
|
||||
condition: { type: "bossesDefeated", amount: 30 },
|
||||
reward: { crystals: 5_000 },
|
||||
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,
|
||||
},
|
||||
// Higher adventurer milestones
|
||||
{
|
||||
id: "army_titan",
|
||||
name: "Titan Commander",
|
||||
description: "Recruit a total of 50,000 adventurers.",
|
||||
icon: "⚡",
|
||||
condition: { type: "adventurerTotal", amount: 50_000 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Higher prestige milestones
|
||||
{
|
||||
id: "prestige_veteran",
|
||||
name: "Veteran of Ages",
|
||||
description: "Prestige 5 times.",
|
||||
icon: "🌟",
|
||||
condition: { type: "prestigeCount", amount: 5 },
|
||||
reward: { crystals: 1_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "prestige_master",
|
||||
name: "Master of Cycles",
|
||||
description: "Prestige 10 times.",
|
||||
icon: "💫",
|
||||
condition: { type: "prestigeCount", amount: 10 },
|
||||
reward: { crystals: 5_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "prestige_legend",
|
||||
name: "Legend of Eternity",
|
||||
description: "Prestige 25 times.",
|
||||
icon: "🌠",
|
||||
condition: { type: "prestigeCount", amount: 25 },
|
||||
reward: { crystals: 25_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
{
|
||||
id: "peasant",
|
||||
name: "Peasant",
|
||||
class: "warrior",
|
||||
level: 1,
|
||||
goldPerSecond: 0.1,
|
||||
essencePerSecond: 0,
|
||||
combatPower: 1,
|
||||
count: 0,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "militia",
|
||||
name: "Militia",
|
||||
class: "warrior",
|
||||
level: 2,
|
||||
goldPerSecond: 0.5,
|
||||
essencePerSecond: 0,
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "apprentice",
|
||||
name: "Apprentice Mage",
|
||||
class: "mage",
|
||||
level: 3,
|
||||
goldPerSecond: 1.5,
|
||||
essencePerSecond: 0.01,
|
||||
combatPower: 8,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "scout",
|
||||
name: "Scout",
|
||||
class: "rogue",
|
||||
level: 4,
|
||||
goldPerSecond: 4,
|
||||
essencePerSecond: 0.02,
|
||||
combatPower: 20,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "acolyte",
|
||||
name: "Acolyte",
|
||||
class: "cleric",
|
||||
level: 5,
|
||||
goldPerSecond: 10,
|
||||
essencePerSecond: 0.05,
|
||||
combatPower: 50,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "ranger",
|
||||
name: "Ranger",
|
||||
class: "ranger",
|
||||
level: 6,
|
||||
goldPerSecond: 25,
|
||||
essencePerSecond: 0.1,
|
||||
combatPower: 120,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "knight",
|
||||
name: "Knight",
|
||||
class: "warrior",
|
||||
level: 7,
|
||||
goldPerSecond: 75,
|
||||
essencePerSecond: 0.2,
|
||||
combatPower: 300,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "archmage",
|
||||
name: "Archmage",
|
||||
class: "mage",
|
||||
level: 8,
|
||||
goldPerSecond: 200,
|
||||
essencePerSecond: 0.5,
|
||||
combatPower: 800,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "paladin",
|
||||
name: "Paladin",
|
||||
class: "paladin",
|
||||
level: 9,
|
||||
goldPerSecond: 600,
|
||||
essencePerSecond: 1,
|
||||
combatPower: 2000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "dragon_rider",
|
||||
name: "Dragon Rider",
|
||||
class: "ranger",
|
||||
level: 10,
|
||||
goldPerSecond: 2000,
|
||||
essencePerSecond: 3,
|
||||
combatPower: 6000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "shadow_assassin",
|
||||
name: "Shadow Assassin",
|
||||
class: "rogue",
|
||||
level: 11,
|
||||
goldPerSecond: 5_000,
|
||||
essencePerSecond: 6,
|
||||
combatPower: 18_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "arcane_scholar",
|
||||
name: "Arcane Scholar",
|
||||
class: "mage",
|
||||
level: 12,
|
||||
goldPerSecond: 14_000,
|
||||
essencePerSecond: 15,
|
||||
combatPower: 45_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_walker",
|
||||
name: "Void Walker",
|
||||
class: "rogue",
|
||||
level: 13,
|
||||
goldPerSecond: 40_000,
|
||||
essencePerSecond: 35,
|
||||
combatPower: 130_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "celestial_guard",
|
||||
name: "Celestial Guard",
|
||||
class: "paladin",
|
||||
level: 14,
|
||||
goldPerSecond: 120_000,
|
||||
essencePerSecond: 100,
|
||||
combatPower: 400_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "divine_champion",
|
||||
name: "Divine Champion",
|
||||
class: "warrior",
|
||||
level: 15,
|
||||
goldPerSecond: 400_000,
|
||||
essencePerSecond: 300,
|
||||
combatPower: 1_200_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "seraph_knight",
|
||||
name: "Seraph Knight",
|
||||
class: "paladin",
|
||||
level: 16,
|
||||
goldPerSecond: 1_200_000,
|
||||
essencePerSecond: 800,
|
||||
combatPower: 4_000_000,
|
||||
count: 0,
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "crystal_sage",
|
||||
name: "Crystal Sage",
|
||||
class: "mage",
|
||||
level: 19,
|
||||
goldPerSecond: 30_000_000,
|
||||
essencePerSecond: 12_000,
|
||||
combatPower: 100_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_sentinel",
|
||||
name: "Void Sentinel",
|
||||
class: "rogue",
|
||||
level: 20,
|
||||
goldPerSecond: 90_000_000,
|
||||
essencePerSecond: 30_000,
|
||||
combatPower: 300_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "eternal_champion",
|
||||
name: "Eternal Champion",
|
||||
class: "warrior",
|
||||
level: 21,
|
||||
goldPerSecond: 270_000_000,
|
||||
essencePerSecond: 80_000,
|
||||
combatPower: 900_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "aether_weaver",
|
||||
name: "Aether Weaver",
|
||||
class: "mage",
|
||||
level: 22,
|
||||
goldPerSecond: 800_000_000,
|
||||
essencePerSecond: 220_000,
|
||||
combatPower: 2_700_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "titan_warrior",
|
||||
name: "Titan Warrior",
|
||||
class: "warrior",
|
||||
level: 23,
|
||||
goldPerSecond: 2_500_000_000,
|
||||
essencePerSecond: 600_000,
|
||||
combatPower: 8_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "nexus_sage",
|
||||
name: "Nexus Sage",
|
||||
class: "mage",
|
||||
level: 24,
|
||||
goldPerSecond: 7_500_000_000,
|
||||
essencePerSecond: 1_600_000,
|
||||
combatPower: 24_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "cosmos_knight",
|
||||
name: "Cosmos Knight",
|
||||
class: "paladin",
|
||||
level: 25,
|
||||
goldPerSecond: 22_000_000_000,
|
||||
essencePerSecond: 4_500_000,
|
||||
combatPower: 72_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "astral_sovereign",
|
||||
name: "Astral Sovereign",
|
||||
class: "warrior",
|
||||
level: 26,
|
||||
goldPerSecond: 65_000_000_000,
|
||||
essencePerSecond: 12_000_000,
|
||||
combatPower: 200_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "primordial_mage",
|
||||
name: "Primordial Mage",
|
||||
class: "mage",
|
||||
level: 27,
|
||||
goldPerSecond: 200_000_000_000,
|
||||
essencePerSecond: 35_000_000,
|
||||
combatPower: 600_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "reality_warden",
|
||||
name: "Reality Warden",
|
||||
class: "paladin",
|
||||
level: 28,
|
||||
goldPerSecond: 600_000_000_000,
|
||||
essencePerSecond: 100_000_000,
|
||||
combatPower: 1_800_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "infinity_ranger",
|
||||
name: "Infinity Ranger",
|
||||
class: "ranger",
|
||||
level: 29,
|
||||
goldPerSecond: 1_800_000_000_000,
|
||||
essencePerSecond: 300_000_000,
|
||||
combatPower: 5_500_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "oblivion_paladin",
|
||||
name: "Oblivion Paladin",
|
||||
class: "paladin",
|
||||
level: 30,
|
||||
goldPerSecond: 5_500_000_000_000,
|
||||
essencePerSecond: 850_000_000,
|
||||
combatPower: 16_000_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "transcendent_rogue",
|
||||
name: "Transcendent Rogue",
|
||||
class: "rogue",
|
||||
level: 31,
|
||||
goldPerSecond: 16_000_000_000_000,
|
||||
essencePerSecond: 2_500_000_000,
|
||||
combatPower: 50_000_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "omniversal_champion",
|
||||
name: "Omniversal Champion",
|
||||
class: "warrior",
|
||||
level: 32,
|
||||
goldPerSecond: 50_000_000_000_000,
|
||||
essencePerSecond: 7_000_000_000,
|
||||
combatPower: 150_000_000_000_000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import type { DailyChallengeType } from "@elysium/types";
|
||||
|
||||
interface DailyChallengeTemplate {
|
||||
type: DailyChallengeType;
|
||||
label: string;
|
||||
target: number;
|
||||
rewardCrystals: number;
|
||||
}
|
||||
|
||||
export const DAILY_CHALLENGE_TEMPLATES: 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 },
|
||||
// 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 },
|
||||
// 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 },
|
||||
// Prestige — the big one
|
||||
{ type: "prestige", label: "Prestige once", target: 1, rewardCrystals: 750 },
|
||||
];
|
||||
@@ -0,0 +1,703 @@
|
||||
import type { Equipment } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_EQUIPMENT: Equipment[] = [
|
||||
// ── Weapons ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "rusty_sword",
|
||||
name: "Rusty Sword",
|
||||
description: "A battered blade, but still sharp enough to draw blood.",
|
||||
type: "weapon",
|
||||
rarity: "common",
|
||||
bonus: { combatMultiplier: 1.1 },
|
||||
owned: true,
|
||||
equipped: true,
|
||||
},
|
||||
{
|
||||
id: "iron_sword",
|
||||
name: "Iron Sword",
|
||||
description: "A sturdy weapon issued to veterans of the guild.",
|
||||
type: "weapon",
|
||||
rarity: "rare",
|
||||
bonus: { combatMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "iron_vanguard",
|
||||
},
|
||||
{
|
||||
id: "enchanted_blade",
|
||||
name: "Enchanted Blade",
|
||||
description: "A sword imbued with ancient magic that makes every strike count.",
|
||||
type: "weapon",
|
||||
rarity: "epic",
|
||||
bonus: { combatMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "shadow_dagger",
|
||||
name: "Shadow Dagger",
|
||||
description: "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.",
|
||||
type: "weapon",
|
||||
rarity: "epic",
|
||||
bonus: { combatMultiplier: 1.65 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 500, crystals: 0 },
|
||||
setId: "shadow_infiltrator",
|
||||
},
|
||||
{
|
||||
id: "flame_lance",
|
||||
name: "Flame Lance",
|
||||
description: "A spear tipped with a shard of the Primordial Forge's eternal fire.",
|
||||
type: "weapon",
|
||||
rarity: "epic",
|
||||
bonus: { combatMultiplier: 1.7 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "volcanic_forger",
|
||||
},
|
||||
{
|
||||
id: "vorpal_sword",
|
||||
name: "Vorpal Sword",
|
||||
description: "A legendary blade that severs even the strongest bonds.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 2.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "soul_reaper",
|
||||
name: "Soul Reaper",
|
||||
description: "A scythe that harvests not flesh but essence itself. Every swing drains the will to resist.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 2.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 0, crystals: 300 },
|
||||
},
|
||||
{
|
||||
id: "celestial_blade",
|
||||
name: "Celestial Blade",
|
||||
description: "Forged from the heart of a dying star by the Cosmic Horror itself. Its edge exists in three realities simultaneously.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 3.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "void_edge",
|
||||
name: "Void Edge",
|
||||
description: "A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 2.75 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 2_000, crystals: 500 },
|
||||
},
|
||||
// ── Armour ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "leather_armour",
|
||||
name: "Leather Armour",
|
||||
description: "Simple protection that keeps your adventurers moving efficiently.",
|
||||
type: "armour",
|
||||
rarity: "common",
|
||||
bonus: { goldMultiplier: 1.1 },
|
||||
owned: true,
|
||||
equipped: true,
|
||||
},
|
||||
{
|
||||
id: "chainmail",
|
||||
name: "Chainmail",
|
||||
description: "Interlocked rings that guard against most mundane threats.",
|
||||
type: "armour",
|
||||
rarity: "rare",
|
||||
bonus: { goldMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "iron_vanguard",
|
||||
},
|
||||
{
|
||||
id: "hide_armour",
|
||||
name: "Giant's Hide Armour",
|
||||
description: "Cured hide from a Forest Giant, worked into armour that radiates primal authority.",
|
||||
type: "armour",
|
||||
rarity: "rare",
|
||||
bonus: { goldMultiplier: 1.35 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "plate_armour",
|
||||
name: "Plate Armour",
|
||||
description: "Full plate protection that inspires confidence — and gold.",
|
||||
type: "armour",
|
||||
rarity: "epic",
|
||||
bonus: { goldMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "void_shroud",
|
||||
name: "Void Shroud",
|
||||
description: "A cloak woven from the fabric of the Shadow Marshes itself. Wealth flows to those hidden from sight.",
|
||||
type: "armour",
|
||||
rarity: "epic",
|
||||
bonus: { goldMultiplier: 1.75 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 400, crystals: 0 },
|
||||
setId: "shadow_infiltrator",
|
||||
},
|
||||
{
|
||||
id: "volcanic_plate",
|
||||
name: "Volcanic Plate",
|
||||
description: "Armour quenched in magma that hardened into something neither metal nor stone. Burns with inner heat.",
|
||||
type: "armour",
|
||||
rarity: "epic",
|
||||
bonus: { goldMultiplier: 1.65, combatMultiplier: 1.15 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "volcanic_forger",
|
||||
},
|
||||
{
|
||||
id: "dragon_scale",
|
||||
name: "Dragon Scale Armour",
|
||||
description: "Armour forged from the scales of a defeated elder dragon.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 2.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "titan_aegis",
|
||||
name: "Titan's Aegis",
|
||||
description: "A shield-armour hybrid blessed by the celestials. Its bearer becomes a fortress.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 2.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 0, crystals: 250 },
|
||||
},
|
||||
{
|
||||
id: "astral_robe",
|
||||
name: "Astral Robe",
|
||||
description: "Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 2.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Trinkets ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "lucky_coin",
|
||||
name: "Lucky Coin",
|
||||
description: "A coin that always lands on the side you need.",
|
||||
type: "trinket",
|
||||
rarity: "common",
|
||||
bonus: { clickMultiplier: 1.1 },
|
||||
owned: true,
|
||||
equipped: true,
|
||||
},
|
||||
{
|
||||
id: "mages_focus",
|
||||
name: "Mage's Focus",
|
||||
description: "A crystal lens that sharpens magical precision.",
|
||||
type: "trinket",
|
||||
rarity: "rare",
|
||||
bonus: { clickMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "iron_vanguard",
|
||||
},
|
||||
{
|
||||
id: "frost_rune",
|
||||
name: "Frost Rune",
|
||||
description: "A rune carved from bone-ice by the Bone Colossus. It amplifies strikes with cold precision.",
|
||||
type: "trinket",
|
||||
rarity: "rare",
|
||||
bonus: { clickMultiplier: 1.3 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "arcane_orb",
|
||||
name: "Arcane Orb",
|
||||
description: "An orb humming with concentrated arcane energy.",
|
||||
type: "trinket",
|
||||
rarity: "epic",
|
||||
bonus: { clickMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "runestone_amulet",
|
||||
name: "Runestone Amulet",
|
||||
description: "An amulet carved from ancient runestones found in the plague ruins. Its inscriptions hum with forgotten power.",
|
||||
type: "trinket",
|
||||
rarity: "epic",
|
||||
bonus: { clickMultiplier: 1.45, goldMultiplier: 1.15 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "crystal_shard",
|
||||
name: "Crystal Shard",
|
||||
description: "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
||||
type: "trinket",
|
||||
rarity: "epic",
|
||||
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "volcanic_forger",
|
||||
},
|
||||
{
|
||||
id: "void_compass",
|
||||
name: "Void Compass",
|
||||
description: "A compass that points not north but toward the greatest concentration of power — wherever that may be.",
|
||||
type: "trinket",
|
||||
rarity: "epic",
|
||||
bonus: { clickMultiplier: 1.6 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 350, crystals: 0 },
|
||||
setId: "shadow_infiltrator",
|
||||
},
|
||||
{
|
||||
id: "frost_crystal",
|
||||
name: "Frost Crystal",
|
||||
description: "A perfectly formed crystal harvested from the Ice Queen's throne room. Cold enough to burn.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.0, goldMultiplier: 1.2 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "philosophers_stone",
|
||||
name: "Philosopher's Stone",
|
||||
description: "The legendary stone that grants mastery over gold and combat alike.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.0, goldMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "eternal_flame",
|
||||
name: "Eternal Flame",
|
||||
description: "A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "infinity_gem",
|
||||
name: "Infinity Gem",
|
||||
description: "A gem that contains a universe within it. Those who hold it become more than mortal.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.5, goldMultiplier: 1.3, combatMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Celestial Reaches ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: "seraph_wing",
|
||||
name: "Seraph's Wing",
|
||||
description: "A weapon forged from a fallen seraph's primary feather — impossibly sharp, burning with divine light.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 3.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "celestial_guardian",
|
||||
},
|
||||
{
|
||||
id: "angels_halo",
|
||||
name: "Angel's Halo",
|
||||
description: "Torn from the Fallen Archangel. It radiates with grief and power in equal measure.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.75, goldMultiplier: 1.3 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "celestial_guardian",
|
||||
},
|
||||
{
|
||||
id: "celestial_armour",
|
||||
name: "Celestial Armour",
|
||||
description: "Forged in heavenly smithies from light compressed so hard it became solid. Your gold flows like sunbeams.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 2.75 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "celestial_guardian",
|
||||
},
|
||||
{
|
||||
id: "divine_edge",
|
||||
name: "The Divine Edge",
|
||||
description: "The First Light's own blade — a weapon of pure divine will given form. It does not cut. It declares.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 4.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "heaven_mantle",
|
||||
name: "Heaven's Mantle",
|
||||
description: "The outermost garment of the celestial realm, woven from captured starlight and divine intention.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 3.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Abyssal Trench ────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "depth_blade",
|
||||
name: "The Depth Blade",
|
||||
description: "Crystallised from the Depth Leviathan's venom — a weapon that strikes through armour as if it were water.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 4.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "abyssal_predator",
|
||||
},
|
||||
{
|
||||
id: "leviathan_eye",
|
||||
name: "The Leviathan's Eye",
|
||||
description: "The Elder Kraken's eye, preserved in brine from the deepest trench. It sees through all deception.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 3.0, goldMultiplier: 1.35 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "abyssal_predator",
|
||||
},
|
||||
{
|
||||
id: "pressure_plate",
|
||||
name: "Pressure Plate",
|
||||
description: "Armour forged under conditions that would crush a city. Nothing that wears it can be broken by ordinary force.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 3.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "abyssal_predator",
|
||||
},
|
||||
{
|
||||
id: "abyssal_edge",
|
||||
name: "The Abyssal Edge",
|
||||
description: "The Elder Abomination's own appendage, reshaped by your artificers into something that passes for a weapon.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 5.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "abyss_shroud",
|
||||
name: "The Abyss Shroud",
|
||||
description: "Woven from the darkness at the very bottom of everything. Gold flows to those who wear the dark.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 3.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Infernal Court ────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "demon_hide",
|
||||
name: "Demon Hide Armour",
|
||||
description: "The Demon Prince's own hide, worked into armour that whispers the strategies of ten thousand campaigns.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 3.75 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "infernal_conqueror",
|
||||
},
|
||||
{
|
||||
id: "hellfire_edge",
|
||||
name: "The Hellfire Edge",
|
||||
description: "A fragment of the Hellfire Titan's core — constantly burning with a heat that ignores armour.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 5.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "infernal_conqueror",
|
||||
},
|
||||
{
|
||||
id: "soul_gem",
|
||||
name: "The Soul Gem",
|
||||
description: "Crystallised from the Lord of Sin's tears — which had never been shed before. The rarest thing in the infernal court.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 3.25, goldMultiplier: 1.4 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "infernal_conqueror",
|
||||
},
|
||||
{
|
||||
id: "infernal_edge",
|
||||
name: "The Infernal Edge",
|
||||
description: "Forged from what The Fallen once was — something good, hardened into a weapon of absolute purpose.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 6.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "sinslayer_aegis",
|
||||
name: "The Sinslayer Aegis",
|
||||
description: "Armour assembled from The Fallen's regrets. Every piece of it remembers what righteousness felt like.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 4.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Crystalline Spire ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: "prism_blade",
|
||||
name: "The Prism Blade",
|
||||
description: "A sword that refracts into thousands of simultaneous strikes. Defenders cannot guard against every angle.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 6.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "crystal_domain",
|
||||
},
|
||||
{
|
||||
id: "faceted_armour",
|
||||
name: "The Faceted Armour",
|
||||
description: "Armour that intersects with adjacent realities — attacks pass through versions of you that chose differently.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 4.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "crystal_domain",
|
||||
},
|
||||
{
|
||||
id: "prism_eye",
|
||||
name: "The Prism Eye",
|
||||
description: "A lens from the Diamond Colossus's own perception — through it, your guild sees every moment simultaneously.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "crystal_domain",
|
||||
},
|
||||
{
|
||||
id: "crystal_sovereign_blade",
|
||||
name: "The Sovereign's Blade",
|
||||
description: "The Crystal Sovereign's own instrument of computation — repurposed for something it calculated was inevitable.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 7.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "diamond_plate",
|
||||
name: "Diamond Plate",
|
||||
description: "Armour compressed from crystallised possibilities — the optimal defensive configuration across all timelines.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 5.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Void Sanctum ──────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "void_annihilator",
|
||||
name: "The Void Annihilator",
|
||||
description: "A weapon of pure absence — it does not strike, it simply removes the thing it is aimed at from existence.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 8.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "void_emperor",
|
||||
},
|
||||
{
|
||||
id: "eternal_shroud",
|
||||
name: "The Eternal Shroud",
|
||||
description: "Woven from the Eternal Shade itself — armour that exists in every moment simultaneously, impossible to find.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 5.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "void_emperor",
|
||||
},
|
||||
{
|
||||
id: "void_heart_gem",
|
||||
name: "The Void Heart Gem",
|
||||
description: "Crystallised from the Void Progenitor's core — the original absence, given form. It makes the impossible routine.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 4.0, goldMultiplier: 1.6 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "void_emperor",
|
||||
},
|
||||
{
|
||||
id: "sanctum_breaker",
|
||||
name: "The Sanctum Breaker",
|
||||
description: "The Void Emperor's own sceptre of authority, seized in the moment of its defeat. It commands even nothingness.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 9.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "void_emperor_plate",
|
||||
name: "Void Emperor's Plate",
|
||||
description: "The armour the Void Emperor wore for all of existence — now worn by something that dared to challenge all of existence.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 6.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// ── Eternal Throne ────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "eternal_armour",
|
||||
name: "Eternal Armour",
|
||||
description: "The Throne Warden's own defensive shell — protection that has never been breached across all of time.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 7.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
id: "throne_blade",
|
||||
name: "The Throne Blade",
|
||||
description: "The Eternal Knight's sword — a weapon that has served the throne since the concept of service was invented.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 10.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
id: "apex_sword",
|
||||
name: "The Apex Sword",
|
||||
description: "The Apex's own instrument — not a weapon in any sense your guild understands, but it functions as one now.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 12.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "apex_plate",
|
||||
name: "The Apex Plate",
|
||||
description: "Armour assembled from the Eternal Throne itself — the absolute seat of power, now serving those who claimed it.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 8.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "eternity_stone",
|
||||
name: "The Eternity Stone",
|
||||
description: "The source of the Apex's power — the thing that makes the Eternal Throne eternal. It is yours now. All of it.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 5.0, goldMultiplier: 2.0, combatMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
setId: "eternal_throne",
|
||||
},
|
||||
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
||||
{
|
||||
id: "celestial_focus",
|
||||
name: "Celestial Focus",
|
||||
description: "A lens of compressed celestial light that sharpens every strike with divine precision.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 20_000_000, crystals: 0 },
|
||||
},
|
||||
{
|
||||
id: "abyssal_tome",
|
||||
name: "Abyssal Tome",
|
||||
description: "A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 3.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 50_000_000, crystals: 0 },
|
||||
},
|
||||
{
|
||||
id: "void_conduit",
|
||||
name: "Void Conduit",
|
||||
description: "A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 4.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 100_000_000, crystals: 0 },
|
||||
},
|
||||
{
|
||||
id: "infernal_gem",
|
||||
name: "Infernal Gem",
|
||||
description: "A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 0, crystals: 5_000_000 },
|
||||
},
|
||||
{
|
||||
id: "crystal_matrix",
|
||||
name: "Crystal Matrix",
|
||||
description: "Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 4.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 0, crystals: 20_000_000 },
|
||||
},
|
||||
{
|
||||
id: "eternal_prism",
|
||||
name: "The Eternal Prism",
|
||||
description: "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 5.0, goldMultiplier: 2.0, combatMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
cost: { gold: 0, essence: 0, crystals: 100_000_000 },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { EquipmentSet } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_EQUIPMENT_SETS: 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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
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";
|
||||
|
||||
export const INITIAL_PRESTIGE: PrestigeData = {
|
||||
count: 0,
|
||||
runestones: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
};
|
||||
|
||||
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(),
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { PrestigeUpgrade } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_PRESTIGE_UPGRADES: 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",
|
||||
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",
|
||||
runestonesCost: 25,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
{
|
||||
id: "income_3",
|
||||
name: "Runestone Blessing III",
|
||||
description: "The runes sing with accumulated wisdom. All production ×2.",
|
||||
category: "income",
|
||||
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",
|
||||
runestonesCost: 150,
|
||||
multiplier: 3,
|
||||
},
|
||||
{
|
||||
id: "income_5",
|
||||
name: "Runic Surge II",
|
||||
description: "The surge intensifies, pushing limits thought impossible. All production ×5.",
|
||||
category: "income",
|
||||
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",
|
||||
runestonesCost: 800,
|
||||
multiplier: 10,
|
||||
},
|
||||
{
|
||||
id: "income_7",
|
||||
name: "Ancient Inscription I",
|
||||
description:
|
||||
"You decipher ancient runic inscriptions that unlock vast potential. All production ×25.",
|
||||
category: "income",
|
||||
runestonesCost: 2_000,
|
||||
multiplier: 25,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "income_9",
|
||||
name: "Ancient Inscription III",
|
||||
description: "The full inscription blazes with world-shaping power. All production ×100.",
|
||||
category: "income",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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,
|
||||
},
|
||||
// ── Essence Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "essence_1",
|
||||
name: "Essence Attunement I",
|
||||
description: "Runestone resonance amplifies your essence gathering. Essence production ×2.",
|
||||
category: "essence",
|
||||
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",
|
||||
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",
|
||||
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,
|
||||
},
|
||||
// ── Crystal Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "crystal_1",
|
||||
name: "Crystal Resonance I",
|
||||
description: "Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
|
||||
category: "crystals",
|
||||
runestonesCost: 30,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "crystal_2",
|
||||
name: "Crystal Resonance II",
|
||||
description: "The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
|
||||
category: "crystals",
|
||||
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,
|
||||
},
|
||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||
{
|
||||
id: "auto_prestige",
|
||||
name: "Autonomous Ascension",
|
||||
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",
|
||||
runestonesCost: 100,
|
||||
multiplier: 1,
|
||||
},
|
||||
// ── Runestone Meta-Upgrade ────────────────────────────────────────────────
|
||||
{
|
||||
id: "runestone_gain_1",
|
||||
name: "Runic Legacy",
|
||||
description:
|
||||
"Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
runestonesCost: 50,
|
||||
multiplier: 1.25,
|
||||
},
|
||||
{
|
||||
id: "runestone_gain_2",
|
||||
name: "Eternal Legacy",
|
||||
description:
|
||||
"Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
runestonesCost: 500,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_UPGRADES: Upgrade[] = [
|
||||
// ── Click upgrades ────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "click_1",
|
||||
name: "Keen Eye",
|
||||
description: "Your strikes find weak points. Doubles click power.",
|
||||
target: "click",
|
||||
multiplier: 2,
|
||||
costGold: 100,
|
||||
costEssence: 0,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "click_2",
|
||||
name: "Battle Hardened",
|
||||
description: "Years of combat sharpen your instincts. Doubles click power again.",
|
||||
target: "click",
|
||||
multiplier: 2,
|
||||
costGold: 1_000,
|
||||
costEssence: 0,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "click_3",
|
||||
name: "Legendary Weapon",
|
||||
description: "A weapon of ancient power. Triples click power.",
|
||||
target: "click",
|
||||
multiplier: 3,
|
||||
costGold: 50_000,
|
||||
costEssence: 10,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "crystal_focus",
|
||||
name: "Crystal Focus",
|
||||
description: "Channel crystallised power into every strike. Doubles click power.",
|
||||
target: "click",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 100,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
// ── Global gold upgrades ──────────────────────────────────────────────────
|
||||
{
|
||||
id: "global_1",
|
||||
name: "Guild Charter",
|
||||
description: "Formalising the guild structure increases all income by 25%.",
|
||||
target: "global",
|
||||
multiplier: 1.25,
|
||||
costGold: 500,
|
||||
costEssence: 0,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "global_2",
|
||||
name: "Merchant Alliance",
|
||||
description: "Trade routes boost all income by 50%.",
|
||||
target: "global",
|
||||
multiplier: 1.5,
|
||||
costGold: 10_000,
|
||||
costEssence: 5,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "global_3",
|
||||
name: "Royal Patronage",
|
||||
description: "The king himself backs your guild. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 1_000_000,
|
||||
costEssence: 100,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "essence_guild",
|
||||
name: "Essence Guild",
|
||||
description: "Forge partnerships with mage guilds across the realm. All income +50%.",
|
||||
target: "global",
|
||||
multiplier: 1.5,
|
||||
costGold: 50_000,
|
||||
costEssence: 50,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "grand_council",
|
||||
name: "Grand Council",
|
||||
description: "A council of the realm's greatest minds organises your operations. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 500_000,
|
||||
costEssence: 250,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "crystal_resonance",
|
||||
name: "Crystal Resonance",
|
||||
description: "Align crystalline frequencies across your guild. All income +50%.",
|
||||
target: "global",
|
||||
multiplier: 1.5,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 250,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "crystal_mastery",
|
||||
name: "Crystal Mastery",
|
||||
description: "Master the art of crystal amplification. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 600,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Adventurer-specific upgrades ──────────────────────────────────────────
|
||||
{
|
||||
id: "peasant_1",
|
||||
name: "Better Tools",
|
||||
description: "Peasants work twice as hard with proper equipment.",
|
||||
target: "adventurer",
|
||||
adventurerId: "peasant",
|
||||
multiplier: 2,
|
||||
costGold: 200,
|
||||
costEssence: 0,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "militia_1",
|
||||
name: "Militia Training",
|
||||
description: "Formal training doubles militia effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "militia",
|
||||
multiplier: 2,
|
||||
costGold: 1_000,
|
||||
costEssence: 0,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "mage_1",
|
||||
name: "Arcane Tomes",
|
||||
description: "Ancient books of magic double mage output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "apprentice",
|
||||
multiplier: 2,
|
||||
costGold: 5_000,
|
||||
costEssence: 2,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "cleric_1",
|
||||
name: "Holy Rites",
|
||||
description: "Sacred ceremonies double the output of your clerics.",
|
||||
target: "adventurer",
|
||||
adventurerId: "acolyte",
|
||||
multiplier: 2,
|
||||
costGold: 8_000,
|
||||
costEssence: 3,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "scout_1",
|
||||
name: "Stealth Training",
|
||||
description: "Advanced scouting techniques double ranger effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "ranger",
|
||||
multiplier: 2,
|
||||
costGold: 15_000,
|
||||
costEssence: 5,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "knight_1",
|
||||
name: "Tempered Steel",
|
||||
description: "Superior forging techniques double the output of your knights.",
|
||||
target: "adventurer",
|
||||
adventurerId: "knight",
|
||||
multiplier: 2,
|
||||
costGold: 50_000,
|
||||
costEssence: 10,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "archmage_1",
|
||||
name: "Leyline Binding",
|
||||
description: "Tap into the world's leylines to double archmage output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "archmage",
|
||||
multiplier: 2,
|
||||
costGold: 100_000,
|
||||
costEssence: 75,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "paladin_1",
|
||||
name: "Holy Vanguard",
|
||||
description: "Divine blessings from the gods themselves double paladin output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "paladin",
|
||||
multiplier: 2,
|
||||
costGold: 200_000,
|
||||
costEssence: 150,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "dragon_rider_1",
|
||||
name: "Bond of Wings",
|
||||
description: "The unbreakable bond between rider and dragon doubles their combined output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "dragon_rider",
|
||||
multiplier: 2,
|
||||
costGold: 500_000,
|
||||
costEssence: 200,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "shadow_assassin_1",
|
||||
name: "Shadow Arts",
|
||||
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "shadow_assassin",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 50,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "arcane_scholar_1",
|
||||
name: "Ancient Tomes",
|
||||
description: "Access to forbidden libraries doubles scholar output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "arcane_scholar",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 150,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_walker_1",
|
||||
name: "Void Step",
|
||||
description: "Walking through the void itself doubles the output of your void walkers.",
|
||||
target: "adventurer",
|
||||
adventurerId: "void_walker",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 300,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "celestial_guard_1",
|
||||
name: "Divine Ward",
|
||||
description: "A blessing from the celestials themselves doubles guard output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "celestial_guard",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 750,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "divine_champion_1",
|
||||
name: "Champion's Oath",
|
||||
description: "An unbreakable oath to the divine doubles champion output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "divine_champion",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 2_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Click upgrades (new zones) ────────────────────────────────────────────
|
||||
{
|
||||
id: "click_4",
|
||||
name: "Celestial Strike",
|
||||
description: "Blessed by the celestials themselves. Click power quadrupled.",
|
||||
target: "click",
|
||||
multiplier: 4,
|
||||
costGold: 100_000_000,
|
||||
costEssence: 5_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "click_5",
|
||||
name: "Infernal Slash",
|
||||
description: "A strike that burns with infernal fire. Click power quintupled.",
|
||||
target: "click",
|
||||
multiplier: 5,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 10_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Global upgrades (new zones) ───────────────────────────────────────────
|
||||
{
|
||||
id: "divine_covenant",
|
||||
name: "Divine Covenant",
|
||||
description: "A covenant with celestial forces multiplies your guild's potential. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 500_000_000,
|
||||
costEssence: 10_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "global_4",
|
||||
name: "Imperial Decree",
|
||||
description: "The empire formally sponsors your guild at the highest level. All income x2.5.",
|
||||
target: "global",
|
||||
multiplier: 2.5,
|
||||
costGold: 100_000_000_000,
|
||||
costEssence: 50_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "abyssal_pact",
|
||||
name: "Abyssal Pact",
|
||||
description: "A pact with the denizens of the deepest trench. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 2_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "celestial_mandate",
|
||||
name: "Celestial Mandate",
|
||||
description: "The celestials themselves decree your guild's dominion over all realms. All income x3.",
|
||||
target: "global",
|
||||
multiplier: 3,
|
||||
costGold: 50_000_000_000_000,
|
||||
costEssence: 100_000_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_ascendancy",
|
||||
name: "Void Ascendancy",
|
||||
description: "Transcend mortal limits through void energy. All income x3.",
|
||||
target: "global",
|
||||
multiplier: 3,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 10_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "divine_harmony",
|
||||
name: "Divine Harmony",
|
||||
description: "Perfect harmony with celestial forces amplifies all output. All income x2.5.",
|
||||
target: "global",
|
||||
multiplier: 2.5,
|
||||
costGold: 1_000_000_000_000_000,
|
||||
costEssence: 500_000_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "infernal_fury",
|
||||
name: "Infernal Fury",
|
||||
description: "Channel infernal rage into production. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 50_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
|
||||
{
|
||||
id: "essence_nexus",
|
||||
name: "Essence Nexus",
|
||||
description: "Tap into a vast network of essence flows. All income +50%.",
|
||||
target: "global",
|
||||
multiplier: 1.5,
|
||||
costGold: 0,
|
||||
costEssence: 5_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "essence_overdrive",
|
||||
name: "Essence Overdrive",
|
||||
description: "Flood your guild's operations with raw essence power. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 50_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "primal_essence",
|
||||
name: "Primal Essence",
|
||||
description: "Harness the oldest essence in existence. All income x3.",
|
||||
target: "global",
|
||||
multiplier: 3,
|
||||
costGold: 0,
|
||||
costEssence: 500_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "crystal_overdrive",
|
||||
name: "Crystal Overdrive",
|
||||
description: "Push crystal resonance beyond its limits. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 50_000_000,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "eternal_bond",
|
||||
name: "Eternal Bond",
|
||||
description: "Forge an eternal pact that triples all income permanently.",
|
||||
target: "global",
|
||||
multiplier: 3,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 200_000_000,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
id: "apex_mandate",
|
||||
name: "Apex Mandate",
|
||||
description: "The supreme decree from the Eternal Throne itself. All income x5.",
|
||||
target: "global",
|
||||
multiplier: 5,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 1_000_000_000,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
// ── New adventurer upgrades ───────────────────────────────────────────────
|
||||
{
|
||||
id: "seraph_knight_1",
|
||||
name: "Seraphic Wings",
|
||||
description: "Seraph knights gain divine flight, doubling their effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "seraph_knight",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 10_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "abyss_diver_1",
|
||||
name: "Pressure Adaptation",
|
||||
description: "Full adaptation to abyssal pressure doubles diver effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "abyss_diver",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 25_000_000,
|
||||
costCrystals: 0,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "infernal_warden_1",
|
||||
name: "Infernal Tempering",
|
||||
description: "Tempered in hellfire itself, warden effectiveness is doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "infernal_warden",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 2_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "crystal_sage_1",
|
||||
name: "Prismatic Mastery",
|
||||
description: "Complete mastery of prismatic crystallomancy doubles sage output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "crystal_sage",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 5_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "void_sentinel_1",
|
||||
name: "Void Resonance",
|
||||
description: "Perfect resonance with the void doubles sentinel effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "void_sentinel",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 15_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "eternal_champion_1",
|
||||
name: "Eternal Oath",
|
||||
description: "An oath that transcends time itself doubles champion output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "eternal_champion",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 50_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "aether_weaver_1",
|
||||
name: "Aetheric Mastery",
|
||||
description: "Complete mastery of aetheric forces doubles the weaver's output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "aether_weaver",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 200_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "titan_warrior_1",
|
||||
name: "Titanic Fury",
|
||||
description: "The fury of a titan unleashed — warrior effectiveness doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "titan_warrior",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 700_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "nexus_sage_1",
|
||||
name: "Nexus Convergence",
|
||||
description: "The sage converges all ley lines through their body — output doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "nexus_sage",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 2_500_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "cosmos_knight_1",
|
||||
name: "Cosmic Tempering",
|
||||
description: "Tempered by the heat of dying stars, the knight's effectiveness is doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "cosmos_knight",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 9_000_000_000,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "astral_sovereign_1",
|
||||
name: "Sovereign Ascension",
|
||||
description: "Ascension to true sovereignty over the astral plane doubles output.",
|
||||
target: "adventurer",
|
||||
adventurerId: "astral_sovereign",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 3e10,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "primordial_mage_1",
|
||||
name: "Primordial Awakening",
|
||||
description: "Awakening of the mage's primordial heritage doubles their power.",
|
||||
target: "adventurer",
|
||||
adventurerId: "primordial_mage",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 1e11,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "reality_warden_1",
|
||||
name: "Reality Binding",
|
||||
description: "The warden binds themselves to the structure of reality — effectiveness doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "reality_warden",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 4e11,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "infinity_ranger_1",
|
||||
name: "Infinite Aim",
|
||||
description: "The ranger's arrows travel through infinity itself — output doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "infinity_ranger",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 1.5e12,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "oblivion_paladin_1",
|
||||
name: "Oblivion Consecration",
|
||||
description: "Consecrated by the void between all things — paladin effectiveness doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "oblivion_paladin",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 5e12,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "transcendent_rogue_1",
|
||||
name: "Transcendent Shadow",
|
||||
description: "The rogue becomes one with the space between states — effectiveness doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "transcendent_rogue",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 2e13,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "omniversal_champion_1",
|
||||
name: "Omniversal Dominion",
|
||||
description: "Dominion over all versions of all universes — champion output doubled.",
|
||||
target: "adventurer",
|
||||
adventurerId: "omniversal_champion",
|
||||
multiplier: 2,
|
||||
costGold: 0,
|
||||
costEssence: 0,
|
||||
costCrystals: 8e13,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Zone } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ZONES: 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,
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
unlockQuestId: "sanctum_chronicle",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { aboutRouter } from "./routes/about.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { bossRouter } from "./routes/boss.js";
|
||||
import { gameRouter } from "./routes/game.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", logger());
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: process.env["CORS_ORIGIN"] ?? "http://localhost:5173",
|
||||
allowHeaders: ["Authorization", "Content-Type"],
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.route("/about", aboutRouter);
|
||||
app.route("/auth", authRouter);
|
||||
app.route("/game", gameRouter);
|
||||
app.route("/boss", bossRouter);
|
||||
app.route("/prestige", prestigeRouter);
|
||||
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}`);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { verifyToken } from "../services/jwt.js";
|
||||
|
||||
export const authMiddleware: MiddlewareHandler<HonoEnv> = 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;
|
||||
}
|
||||
|
||||
const token = authorization.slice(7);
|
||||
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
context.set("discordId", payload.discordId);
|
||||
await next();
|
||||
} catch {
|
||||
context.status(401);
|
||||
context.json({ error: "Invalid or expired token" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Hono } from "hono";
|
||||
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 };
|
||||
|
||||
const GITEA_RELEASES_URL =
|
||||
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan-ideation/elysium/releases";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
let releasesCache: GiteaRelease[] = [];
|
||||
let cacheTimestamp = 0;
|
||||
|
||||
const fetchReleases = async (): Promise<GiteaRelease[]> => {
|
||||
const now = Date.now();
|
||||
if (releasesCache.length > 0 && now - cacheTimestamp < CACHE_TTL_MS) {
|
||||
return releasesCache;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(GITEA_RELEASES_URL);
|
||||
if (!response.ok) {
|
||||
return releasesCache;
|
||||
}
|
||||
releasesCache = (await response.json()) as GiteaRelease[];
|
||||
cacheTimestamp = now;
|
||||
return releasesCache;
|
||||
} catch {
|
||||
return releasesCache;
|
||||
}
|
||||
};
|
||||
|
||||
export const aboutRouter = new Hono();
|
||||
|
||||
aboutRouter.get("/", async (context) => {
|
||||
const releases = await fetchReleases();
|
||||
const body: AboutResponse = {
|
||||
apiVersion: API_VERSION,
|
||||
releases,
|
||||
};
|
||||
return context.json(body);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Player } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
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";
|
||||
|
||||
export const authRouter = new Hono();
|
||||
|
||||
authRouter.get("/url", (context) => {
|
||||
try {
|
||||
const url = buildOAuthUrl();
|
||||
return context.json({ url });
|
||||
} catch {
|
||||
return context.json({ error: "Failed to build OAuth URL" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/callback", async (context) => {
|
||||
const code = context.req.query("code");
|
||||
|
||||
if (!code) {
|
||||
return context.json({ error: "Missing code parameter" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenData = await exchangeCode(code);
|
||||
const discordUser = await fetchDiscordUser(tokenData.access_token);
|
||||
|
||||
const existing = await prisma.player.findUnique({
|
||||
where: { discordId: discordUser.id },
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
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,
|
||||
totalGoldEarned: 0,
|
||||
totalClicks: 0,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const initialState = INITIAL_GAME_STATE(playerShape, playerShape.characterName);
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
discordId: player.discordId,
|
||||
state: initialState as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const jwtToken = signToken(player.discordId);
|
||||
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
|
||||
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`);
|
||||
}
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
where: { discordId: discordUser.id },
|
||||
data: {
|
||||
username: discordUser.username,
|
||||
discriminator: discordUser.discriminator,
|
||||
avatar: discordUser.avatar,
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
return context.redirect(`${clientUrl}/auth/callback?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { BossChallengeResponse, GameState } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.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";
|
||||
|
||||
export const bossRouter = new Hono<HonoEnv>();
|
||||
|
||||
bossRouter.use("*", authMiddleware);
|
||||
|
||||
const calculatePartyStats = (
|
||||
state: GameState,
|
||||
): { partyDPS: number; partyMaxHp: number } => {
|
||||
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;
|
||||
|
||||
// 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);
|
||||
|
||||
const equippedItemIds = (state.equipment ?? []).filter((e) => e.equipped).map((e) => e.id);
|
||||
const setCombatMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).combatMultiplier;
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 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;
|
||||
|
||||
partyMaxHp += adventurer.level * 50 * adventurer.count;
|
||||
}
|
||||
|
||||
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
bossRouter.post("/challenge", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,628 @@
|
||||
import type { GameState, SaveRequest } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js";
|
||||
import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
|
||||
import { DEFAULT_BOSSES } from "../data/bosses.js";
|
||||
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
|
||||
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { DEFAULT_QUESTS } from "../data/quests.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||
|
||||
const RESOURCE_CAP = 1e300;
|
||||
|
||||
/** Maximum elapsed seconds credited for passive income — mirrors the offline earnings cap. */
|
||||
const ELAPSED_CAP_SECONDS = 8 * 3600;
|
||||
|
||||
/**
|
||||
* Multiplier applied to passive income when computing the maximum legitimate gold/essence
|
||||
* increase per save. The 2× buffer covers mid-session purchases (adventurers, upgrades)
|
||||
* that increase income beyond what the previous DB snapshot can predict.
|
||||
*/
|
||||
const INCOME_BUFFER_MULTIPLIER = 2;
|
||||
|
||||
/** Generous clicks-per-second estimate used to bound click income between saves. */
|
||||
const CLICK_BUFFER_CPS = 10;
|
||||
|
||||
/** 60-second grace period when checking whether a quest timer has expired. */
|
||||
const QUEST_GRACE_MS = 60_000;
|
||||
|
||||
const computeHmac = (data: string, secret: string): string =>
|
||||
createHmac("sha256", secret).update(data).digest("hex");
|
||||
|
||||
/**
|
||||
* Calculates the maximum passive gold and essence income per second from the given state,
|
||||
* using the same formula as applyTick in tick.ts. Must be kept in sync with that function.
|
||||
*/
|
||||
const computeMaxPassiveIncome = (
|
||||
state: GameState,
|
||||
): { goldPerSecond: number; essencePerSecond: number } => {
|
||||
const equippedItems = (state.equipment ?? []).filter((e) => e.equipped);
|
||||
const equipmentGoldMultiplier = equippedItems.reduce(
|
||||
(mult, e) => mult * (e.bonus.goldMultiplier ?? 1),
|
||||
1,
|
||||
);
|
||||
const equippedItemIds = equippedItems.map((e) => e.id);
|
||||
const setGoldMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).goldMultiplier;
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) 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 prestige = state.prestige.productionMultiplier;
|
||||
|
||||
goldPerSecond +=
|
||||
adventurer.goldPerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesIncome *
|
||||
equipmentGoldMultiplier *
|
||||
setGoldMultiplier;
|
||||
|
||||
essencePerSecond +=
|
||||
adventurer.essencePerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesEssence;
|
||||
}
|
||||
|
||||
return { goldPerSecond, essencePerSecond };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the maximum gold a player could earn per second via clicking.
|
||||
* Mirrors calculateClickPower from tick.ts — must be kept in sync with that function.
|
||||
* Uses CLICK_BUFFER_CPS as a generous upper bound on clicks per second.
|
||||
*/
|
||||
const computeMaxClickGoldPerSecond = (state: GameState): number => {
|
||||
const clickMultiplier = state.upgrades
|
||||
.filter((u) => u.purchased && u.target === "click")
|
||||
.reduce((mult, u) => mult * u.multiplier, 1);
|
||||
|
||||
const equippedItems = (state.equipment ?? []).filter((e) => e.equipped);
|
||||
const equipmentClickMultiplier = equippedItems
|
||||
.filter((e) => e.bonus.clickMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
|
||||
const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), DEFAULT_EQUIPMENT_SETS).clickMultiplier;
|
||||
|
||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||
|
||||
const clickPower =
|
||||
state.baseClickPower *
|
||||
clickMultiplier *
|
||||
state.prestige.productionMultiplier *
|
||||
runestonesClick *
|
||||
equipmentClickMultiplier *
|
||||
setClickMultiplier;
|
||||
|
||||
return clickPower * CLICK_BUFFER_CPS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sums the gold and essence rewards for quests that legitimately completed during
|
||||
* this save interval. A quest is eligible when:
|
||||
* - It was "active" in the previous (DB-trusted) state, and
|
||||
* - Its timer has genuinely expired by the current server time (plus QUEST_GRACE_MS), and
|
||||
* - It is now "completed" in the incoming state.
|
||||
*
|
||||
* Reward amounts and durations are taken from DEFAULT_QUESTS (authoritative game data)
|
||||
* to prevent client-side reward or duration tampering.
|
||||
*/
|
||||
const computeQuestRewards = (
|
||||
incoming: GameState,
|
||||
previous: GameState,
|
||||
now: number,
|
||||
): { gold: number; essence: number } => {
|
||||
let gold = 0;
|
||||
let essence = 0;
|
||||
|
||||
for (const incomingQuest of incoming.quests) {
|
||||
if (incomingQuest.status !== "completed") continue;
|
||||
|
||||
const prevQuest = previous.quests.find((q) => q.id === incomingQuest.id);
|
||||
if (!prevQuest || prevQuest.status === "completed") continue;
|
||||
|
||||
if (prevQuest.status !== "active" || prevQuest.startedAt == null) continue;
|
||||
|
||||
// Use authoritative duration from game data so a tampered durationSeconds in the
|
||||
// saved state cannot cause a timer to appear expired prematurely.
|
||||
const questData = DEFAULT_QUESTS.find((q) => q.id === incomingQuest.id);
|
||||
if (!questData) continue;
|
||||
|
||||
if (prevQuest.startedAt + questData.durationSeconds * 1000 > now + QUEST_GRACE_MS) continue;
|
||||
|
||||
for (const reward of questData.rewards) {
|
||||
if (reward.type === "gold" && reward.amount != null) gold += reward.amount;
|
||||
if (reward.type === "essence" && reward.amount != null) essence += reward.amount;
|
||||
}
|
||||
}
|
||||
|
||||
return { gold, essence };
|
||||
};
|
||||
|
||||
/**
|
||||
* Sums the gold and essence rewards for bosses newly defeated during this save interval.
|
||||
*
|
||||
* Boss fights are fully server-authoritative (boss.ts writes rewards directly to the DB),
|
||||
* so in the normal flow previousState already reflects the boss rewards and this function
|
||||
* returns zero. It exists solely as a safety buffer for the rare race condition where a
|
||||
* boss DB write and a save request arrive simultaneously, leaving previousState stale.
|
||||
*
|
||||
* Reward amounts are taken from DEFAULT_BOSSES (authoritative game data) to prevent
|
||||
* client-side reward tampering.
|
||||
*/
|
||||
const computeBossRewards = (
|
||||
incoming: GameState,
|
||||
previous: GameState,
|
||||
): { gold: number; essence: number } => {
|
||||
let gold = 0;
|
||||
let essence = 0;
|
||||
|
||||
for (const incomingBoss of incoming.bosses) {
|
||||
if (incomingBoss.status !== "defeated") continue;
|
||||
|
||||
const prevBoss = previous.bosses.find((b) => b.id === incomingBoss.id);
|
||||
if (!prevBoss || prevBoss.status === "defeated") continue;
|
||||
|
||||
// Only credit bosses that were actually challengeable in the previous state,
|
||||
// ruling out bosses that somehow skipped the server-authoritative fight flow.
|
||||
if (prevBoss.status !== "available" && prevBoss.status !== "in_progress") continue;
|
||||
|
||||
const bossData = DEFAULT_BOSSES.find((b) => b.id === incomingBoss.id);
|
||||
if (!bossData) continue;
|
||||
|
||||
gold += bossData.goldReward;
|
||||
essence += bossData.essenceReward;
|
||||
}
|
||||
|
||||
return { gold, essence };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the incoming state against the previous saved state and returns a
|
||||
* sanitised copy. Protects against:
|
||||
* - Gold or essence exceeding what could legitimately be earned since the last save
|
||||
* - Resources exceeding the absolute cap
|
||||
* - Runestones increasing between saves (only granted server-side via prestige)
|
||||
* - Defeating a boss being reversed
|
||||
* - Completing a quest being reversed
|
||||
* - Unlocking an achievement being reversed or backdated to a future timestamp
|
||||
* - Prestige count going backwards
|
||||
*/
|
||||
const validateAndSanitize = (incoming: GameState, previous: GameState): GameState => {
|
||||
const now = Date.now();
|
||||
|
||||
// Elapsed seconds since the last trusted tick, capped at 8 hours to match the
|
||||
// offline earnings cap and prevent a stale lastTickAt from inflating the allowance.
|
||||
// Falls back to 30 s for old saves that predate the lastTickAt field.
|
||||
const rawElapsed = previous.lastTickAt > 0 ? (now - previous.lastTickAt) / 1000 : 30;
|
||||
const elapsedSeconds = Math.max(0, Math.min(rawElapsed, ELAPSED_CAP_SECONDS));
|
||||
|
||||
// Per-second income rates from the previous (DB-trusted) state.
|
||||
const { goldPerSecond, essencePerSecond } = computeMaxPassiveIncome(previous);
|
||||
const clickGoldPerSecond = computeMaxClickGoldPerSecond(previous);
|
||||
|
||||
// Precise one-time rewards for events that could have occurred this interval.
|
||||
const questRewards = computeQuestRewards(incoming, previous, now);
|
||||
const bossRewards = computeBossRewards(incoming, previous);
|
||||
|
||||
// Passive and click income receive a 2× buffer to cover mid-session adventurer/upgrade
|
||||
// purchases that raise income beyond what the previous snapshot can predict.
|
||||
// Quest and boss rewards are exact (sourced from authoritative game data) and need no buffer.
|
||||
const maxGoldIncrease =
|
||||
(goldPerSecond + clickGoldPerSecond) * elapsedSeconds * INCOME_BUFFER_MULTIPLIER +
|
||||
questRewards.gold +
|
||||
bossRewards.gold;
|
||||
|
||||
const maxEssenceIncrease =
|
||||
essencePerSecond * elapsedSeconds * INCOME_BUFFER_MULTIPLIER +
|
||||
questRewards.essence +
|
||||
bossRewards.essence;
|
||||
|
||||
const resources = {
|
||||
gold: Math.min(
|
||||
incoming.resources.gold,
|
||||
previous.resources.gold + maxGoldIncrease,
|
||||
RESOURCE_CAP,
|
||||
),
|
||||
essence: Math.min(
|
||||
incoming.resources.essence,
|
||||
previous.resources.essence + maxEssenceIncrease,
|
||||
RESOURCE_CAP,
|
||||
),
|
||||
crystals: Math.min(incoming.resources.crystals, RESOURCE_CAP),
|
||||
// Runestones are only granted server-side via prestige and can only decrease between
|
||||
// saves (spent on prestige upgrades via the buy-upgrade endpoint). Cap at the previous
|
||||
// value to block client-side inflation.
|
||||
runestones: Math.min(incoming.resources.runestones, previous.resources.runestones),
|
||||
};
|
||||
|
||||
const bosses = incoming.bosses.map((b) => {
|
||||
const prev = previous.bosses.find((p) => p.id === b.id);
|
||||
if (!prev) return b;
|
||||
if (prev.status === "defeated" && b.status !== "defeated") {
|
||||
return { ...b, status: "defeated" as const, currentHp: 0 };
|
||||
}
|
||||
return b;
|
||||
});
|
||||
|
||||
const quests = incoming.quests.map((q) => {
|
||||
const prev = previous.quests.find((p) => p.id === q.id);
|
||||
if (!prev) return q;
|
||||
if (prev.status === "completed" && q.status !== "completed") {
|
||||
return { ...prev };
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
const achievements = incoming.achievements.map((a) => {
|
||||
const prev = previous.achievements.find((p) => p.id === a.id);
|
||||
if (!prev) return a;
|
||||
if (prev.unlockedAt !== null && a.unlockedAt === null) {
|
||||
return { ...a, unlockedAt: prev.unlockedAt };
|
||||
}
|
||||
if (a.unlockedAt !== null && a.unlockedAt > now) {
|
||||
return { ...a, unlockedAt: prev.unlockedAt ?? null };
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
const prestige =
|
||||
incoming.prestige.count < previous.prestige.count ? previous.prestige : incoming.prestige;
|
||||
|
||||
return { ...incoming, resources, bosses, quests, achievements, prestige };
|
||||
};
|
||||
|
||||
export const gameRouter = new Hono<HonoEnv>();
|
||||
|
||||
gameRouter.use("*", authMiddleware);
|
||||
|
||||
gameRouter.get("/load", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
|
||||
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;
|
||||
|
||||
let needsBackfill = false;
|
||||
|
||||
// Backfill combatPower on saves that predate the field
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.combatPower == null) {
|
||||
const defaults = DEFAULT_ADVENTURERS.find((d) => d.id === adventurer.id);
|
||||
adventurer.combatPower = defaults?.combatPower ?? 1;
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill equipment on saves that predate the feature
|
||||
if (!Array.isArray(state.equipment) || state.equipment.length === 0) {
|
||||
state.equipment = structuredClone(DEFAULT_EQUIPMENT);
|
||||
needsBackfill = true;
|
||||
} else {
|
||||
// Merge in any equipment items missing from existing saves (new items added later)
|
||||
for (const defaultItem of DEFAULT_EQUIPMENT) {
|
||||
if (!state.equipment.some((e) => e.id === defaultItem.id)) {
|
||||
state.equipment.push(structuredClone(defaultItem));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill achievements on saves that predate the feature
|
||||
if (!Array.isArray(state.achievements) || state.achievements.length === 0) {
|
||||
state.achievements = structuredClone(DEFAULT_ACHIEVEMENTS);
|
||||
needsBackfill = true;
|
||||
} else {
|
||||
// Merge in any achievements missing from existing saves
|
||||
for (const defaultAchievement of DEFAULT_ACHIEVEMENTS) {
|
||||
if (!state.achievements.some((a) => a.id === defaultAchievement.id)) {
|
||||
state.achievements.push(structuredClone(defaultAchievement));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill equipmentRewards on bosses that predate the field (will be synced below after defaults load)
|
||||
for (const boss of state.bosses) {
|
||||
if (!Array.isArray(boss.equipmentRewards)) {
|
||||
boss.equipmentRewards = [];
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill new quests, upgrades, zones, and bosses from defaults (add missing ones)
|
||||
const { DEFAULT_QUESTS } = await import("../data/quests.js");
|
||||
const { DEFAULT_UPGRADES } = await import("../data/upgrades.js");
|
||||
const { DEFAULT_ZONES } = await import("../data/zones.js");
|
||||
const { DEFAULT_BOSSES } = await import("../data/bosses.js");
|
||||
|
||||
for (const defaultQuest of DEFAULT_QUESTS) {
|
||||
if (!state.quests.some((q) => q.id === defaultQuest.id)) {
|
||||
state.quests.push(structuredClone(defaultQuest));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync zoneId and rewards on quests to match current defaults
|
||||
for (const quest of state.quests) {
|
||||
const defaults = DEFAULT_QUESTS.find((d) => d.id === quest.id);
|
||||
if (defaults && quest.zoneId !== defaults.zoneId) {
|
||||
quest.zoneId = defaults.zoneId;
|
||||
needsBackfill = true;
|
||||
}
|
||||
if (!quest.zoneId) {
|
||||
quest.zoneId = defaults?.zoneId ?? "verdant_vale";
|
||||
needsBackfill = true;
|
||||
}
|
||||
// Sync rewards to match defaults so newly-added rewards take effect
|
||||
if (defaults && JSON.stringify(quest.rewards) !== JSON.stringify(defaults.rewards)) {
|
||||
quest.rewards = structuredClone(defaults.rewards);
|
||||
needsBackfill = true;
|
||||
}
|
||||
// Revert "available" quests back to "locked" if their zone is still locked
|
||||
if (quest.status === "available") {
|
||||
const zone = state.zones.find((z) => z.id === quest.zoneId);
|
||||
if (zone?.status === "locked") {
|
||||
quest.status = "locked";
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
// Retroactively apply adventurer unlocks from already-completed quests
|
||||
if (quest.status === "completed") {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId) {
|
||||
const adventurer = state.adventurers.find((a) => a.id === reward.targetId);
|
||||
if (adventurer && !adventurer.unlocked) {
|
||||
adventurer.unlocked = true;
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const defaultUpgrade of DEFAULT_UPGRADES) {
|
||||
if (!state.upgrades.some((u) => u.id === defaultUpgrade.id)) {
|
||||
state.upgrades.push(structuredClone(defaultUpgrade));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill costCrystals on upgrades that predate the field
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.costCrystals == null) {
|
||||
upgrade.costCrystals = 0;
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new adventurers from defaults
|
||||
for (const defaultAdventurer of DEFAULT_ADVENTURERS) {
|
||||
if (!state.adventurers.some((a) => a.id === defaultAdventurer.id)) {
|
||||
state.adventurers.push(structuredClone(defaultAdventurer));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
const zoneUnlockConditionsMet = (zone: { unlockBossId: string | null; unlockQuestId: string | null }): boolean => {
|
||||
const bossOk =
|
||||
zone.unlockBossId == null ||
|
||||
state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated");
|
||||
const questOk =
|
||||
zone.unlockQuestId == null ||
|
||||
state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed");
|
||||
return bossOk && questOk;
|
||||
};
|
||||
|
||||
// Backfill zones
|
||||
if (!Array.isArray(state.zones) || state.zones.length === 0) {
|
||||
state.zones = structuredClone(DEFAULT_ZONES);
|
||||
// Infer unlock state from current boss + quest completion
|
||||
for (const zone of state.zones) {
|
||||
if (zone.unlockBossId != null || zone.unlockQuestId != null) {
|
||||
if (zoneUnlockConditionsMet(zone)) {
|
||||
zone.status = "unlocked";
|
||||
}
|
||||
}
|
||||
}
|
||||
needsBackfill = true;
|
||||
} else {
|
||||
// Merge new zones from defaults
|
||||
for (const defaultZone of DEFAULT_ZONES) {
|
||||
if (!state.zones.some((z) => z.id === defaultZone.id)) {
|
||||
const newZone = structuredClone(defaultZone);
|
||||
// Infer unlock state from current boss + quest completion
|
||||
if (newZone.unlockBossId != null || newZone.unlockQuestId != null) {
|
||||
if (zoneUnlockConditionsMet(newZone)) {
|
||||
newZone.status = "unlocked";
|
||||
}
|
||||
}
|
||||
state.zones.push(newZone);
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync unlockBossId and unlockQuestId from defaults in case zone gate requirements changed
|
||||
for (const zone of state.zones) {
|
||||
const defaultZone = DEFAULT_ZONES.find((z) => z.id === zone.id);
|
||||
if (!defaultZone) continue;
|
||||
if (zone.unlockBossId !== defaultZone.unlockBossId) {
|
||||
zone.unlockBossId = defaultZone.unlockBossId;
|
||||
needsBackfill = true;
|
||||
}
|
||||
if (!("unlockQuestId" in zone) || zone.unlockQuestId !== defaultZone.unlockQuestId) {
|
||||
zone.unlockQuestId = defaultZone.unlockQuestId;
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-verify zone unlock status against current unlock conditions
|
||||
// (handles cases where gate requirements changed in a data update)
|
||||
for (const zone of state.zones) {
|
||||
if (zone.unlockBossId == null && zone.unlockQuestId == null) continue;
|
||||
if (zone.status === "unlocked" && !zoneUnlockConditionsMet(zone)) {
|
||||
zone.status = "locked";
|
||||
// Revert any "available" bosses in this zone back to "locked"
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.zoneId === zone.id && boss.status === "available") {
|
||||
boss.status = "locked";
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill zoneId and sync rewards on bosses to match current defaults
|
||||
for (const boss of state.bosses) {
|
||||
const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id);
|
||||
if (!boss.zoneId) {
|
||||
boss.zoneId = defaults?.zoneId ?? "verdant_vale";
|
||||
needsBackfill = true;
|
||||
}
|
||||
// Sync equipmentRewards, upgradeRewards, and prestigeRequirement to match defaults
|
||||
if (defaults) {
|
||||
if (JSON.stringify(boss.equipmentRewards) !== JSON.stringify(defaults.equipmentRewards)) {
|
||||
boss.equipmentRewards = structuredClone(defaults.equipmentRewards);
|
||||
needsBackfill = true;
|
||||
}
|
||||
if (JSON.stringify(boss.upgradeRewards) !== JSON.stringify(defaults.upgradeRewards)) {
|
||||
boss.upgradeRewards = structuredClone(defaults.upgradeRewards);
|
||||
needsBackfill = true;
|
||||
}
|
||||
if (boss.prestigeRequirement !== defaults.prestigeRequirement) {
|
||||
boss.prestigeRequirement = defaults.prestigeRequirement;
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new bosses from defaults (new zones' bosses)
|
||||
for (const defaultBoss of DEFAULT_BOSSES) {
|
||||
if (!state.bosses.some((b) => b.id === defaultBoss.id)) {
|
||||
const newBoss = structuredClone(defaultBoss);
|
||||
// If the zone for this boss is already unlocked, make the first boss in that zone available
|
||||
const zone = state.zones.find((z) => z.id === newBoss.zoneId);
|
||||
if (zone?.status === "unlocked") {
|
||||
const zoneBossesInState = state.bosses.filter((b) => b.zoneId === newBoss.zoneId);
|
||||
if (zoneBossesInState.length === 0 && newBoss.status === "locked") {
|
||||
newBoss.status = "available";
|
||||
}
|
||||
}
|
||||
state.bosses.push(newBoss);
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now);
|
||||
|
||||
if (offlineGold > 0) {
|
||||
state.resources.gold += offlineGold;
|
||||
state.player.totalGoldEarned += offlineGold;
|
||||
}
|
||||
|
||||
if (offlineEssence > 0) {
|
||||
state.resources.essence += offlineEssence;
|
||||
}
|
||||
|
||||
// Generate or reset daily challenges if a new day has begun
|
||||
state.dailyChallenges = getOrResetDailyChallenges(state);
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
|
||||
await prisma.gameState.update({
|
||||
where: { discordId },
|
||||
data: { state: state as object, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined;
|
||||
return context.json({ state, offlineGold, offlineEssence, offlineSeconds, signature });
|
||||
});
|
||||
|
||||
gameRouter.post("/save", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<SaveRequest>();
|
||||
|
||||
if (!body.state) {
|
||||
return context.json({ error: "Missing state in request body" }, 400);
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
let stateToSave = body.state;
|
||||
|
||||
if (record) {
|
||||
const previousState = record.state as unknown as GameState;
|
||||
|
||||
// Option D: verify HMAC signature if the secret is configured and client sent one
|
||||
if (secret && body.signature) {
|
||||
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
|
||||
if (body.signature !== expectedSig) {
|
||||
return context.json({ error: "Save rejected: signature mismatch" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
|
||||
stateToSave = validateAndSanitize(body.state, previousState);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Stamp the authoritative save timestamp into the state blob so that on the
|
||||
// next load the client reads the correct value from state.player.lastSavedAt.
|
||||
stateToSave = {
|
||||
...stateToSave,
|
||||
player: { ...stateToSave.player, lastSavedAt: now },
|
||||
};
|
||||
|
||||
await prisma.player.update({
|
||||
where: { discordId },
|
||||
data: {
|
||||
lastSavedAt: now,
|
||||
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||
totalClicks: stateToSave.player.totalClicks,
|
||||
characterName: stateToSave.player.characterName,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.gameState.upsert({
|
||||
where: { discordId },
|
||||
create: { discordId, state: stateToSave as unknown as never, updatedAt: now },
|
||||
update: { state: stateToSave as unknown as never, updatedAt: now },
|
||||
});
|
||||
|
||||
const signature = secret ? computeHmac(JSON.stringify(stateToSave), secret) : undefined;
|
||||
return context.json({ savedAt: now, signature });
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { BuyPrestigeUpgradeRequest, GameState, PrestigeRequest } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.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 {
|
||||
buildPostPrestigeState,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../services/prestige.js";
|
||||
|
||||
export const prestigeRouter = new Hono<HonoEnv>();
|
||||
|
||||
prestigeRouter.use("*", authMiddleware);
|
||||
|
||||
prestigeRouter.post("/", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<PrestigeRequest>();
|
||||
|
||||
const characterName = body.characterName?.trim();
|
||||
if (!characterName) {
|
||||
return context.json({ error: "characterName is required" }, 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;
|
||||
|
||||
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 { 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>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
GameState,
|
||||
ProfileSettings,
|
||||
UpdateProfileRequest,
|
||||
} from "@elysium/types";
|
||||
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../types/hono.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
|
||||
export const profileRouter = new Hono<HonoEnv>();
|
||||
|
||||
const VALID_NUMBER_FORMATS = new Set(["suffix", "scientific", "engineering"]);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_PROFILE_SETTINGS };
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
profileRouter.put("/", authMiddleware, async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
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,
|
||||
};
|
||||
|
||||
if (!characterName) {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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());
|
||||
|
||||
/** Simple deterministic pseudo-random based on a numeric seed. */
|
||||
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);
|
||||
|
||||
/** 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]!];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const CHALLENGE_TYPES: DailyChallengeType[] = [
|
||||
"clicks",
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
"prestige",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates 3 daily challenges for the given date string, deterministically.
|
||||
* Picks one challenge from 3 different randomly-selected types.
|
||||
*/
|
||||
export const generateDailyChallenges = (dateStr: string): DailyChallenge[] => {
|
||||
const seed = dateSeed(dateStr);
|
||||
const selectedTypes = shuffleWithSeed([...CHALLENGE_TYPES], 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 template = templates[templateIndex]!;
|
||||
|
||||
return {
|
||||
id: `${dateStr}_${type}`,
|
||||
type: template.type,
|
||||
label: template.label,
|
||||
target: template.target,
|
||||
progress: 0,
|
||||
completed: false,
|
||||
rewardCrystals: template.rewardCrystals,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current daily challenge state, generating fresh challenges if
|
||||
* the stored date doesn't match today (i.e. a new day has begun).
|
||||
*/
|
||||
export const getOrResetDailyChallenges = (state: GameState): DailyChallengeState => {
|
||||
const today = getTodayString();
|
||||
if (state.dailyChallenges?.date === today) {
|
||||
return state.dailyChallenges;
|
||||
}
|
||||
return { date: today, challenges: generateDailyChallenges(today) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Increments progress for challenges matching the given type.
|
||||
* Returns the updated challenge state and total crystals awarded for newly completed challenges.
|
||||
*/
|
||||
export const updateChallengeProgress = (
|
||||
challengeState: DailyChallengeState,
|
||||
type: DailyChallengeType,
|
||||
amount: number,
|
||||
): { updatedChallenges: DailyChallengeState; crystalsAwarded: number } => {
|
||||
let crystalsAwarded = 0;
|
||||
|
||||
const updatedChallenges: DailyChallengeState = {
|
||||
...challengeState,
|
||||
challenges: challengeState.challenges.map((challenge) => {
|
||||
if (challenge.type !== type || challenge.completed) return challenge;
|
||||
|
||||
const newProgress = Math.min(challenge.progress + amount, challenge.target);
|
||||
const nowCompleted = newProgress >= challenge.target;
|
||||
|
||||
if (nowCompleted) crystalsAwarded += challenge.rewardCrystals;
|
||||
|
||||
return { ...challenge, progress: newProgress, completed: nowCompleted };
|
||||
}),
|
||||
};
|
||||
|
||||
return { updatedChallenges, crystalsAwarded };
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
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"];
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
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}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "identify",
|
||||
});
|
||||
|
||||
return `https://discord.com/api/oauth2/authorize?${params.toString()}`;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
interface JwtPayload {
|
||||
discordId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const base64UrlEncode = (data: string): string =>
|
||||
Buffer.from(data).toString("base64url");
|
||||
|
||||
const base64UrlDecode = (data: string): string =>
|
||||
Buffer.from(data, "base64url").toString("utf8");
|
||||
|
||||
export const signToken = (discordId: string): string => {
|
||||
const secret = process.env["JWT_SECRET"];
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const payload = base64UrlEncode(
|
||||
JSON.stringify({
|
||||
discordId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days
|
||||
}),
|
||||
);
|
||||
|
||||
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) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
const [header, payload, signature] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
|
||||
if (signature !== expectedSignature) {
|
||||
throw new Error("Invalid token signature");
|
||||
}
|
||||
|
||||
const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload;
|
||||
|
||||
if (decoded.exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new Error("Token has expired");
|
||||
}
|
||||
|
||||
return decoded;
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const MAX_OFFLINE_SECONDS = 8 * 60 * 60; // 8 hours
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const calculateOfflineEarnings = (
|
||||
state: GameState,
|
||||
nowMs: number,
|
||||
): { offlineGold: number; offlineEssence: number; offlineSeconds: number } => {
|
||||
const elapsedSeconds = Math.min(
|
||||
(nowMs - state.lastTickAt) / 1000,
|
||||
MAX_OFFLINE_SECONDS,
|
||||
);
|
||||
|
||||
const equipmentGoldMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped)
|
||||
.reduce((mult, e) => mult * (e.bonus.goldMultiplier ?? 1), 1);
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||
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 prestige = state.prestige.productionMultiplier;
|
||||
|
||||
goldPerSecond +=
|
||||
adventurer.goldPerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesIncome *
|
||||
equipmentGoldMultiplier;
|
||||
|
||||
essencePerSecond +=
|
||||
adventurer.essencePerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesEssence;
|
||||
}
|
||||
|
||||
return {
|
||||
offlineGold: goldPerSecond * elapsedSeconds,
|
||||
offlineEssence: essencePerSecond * elapsedSeconds,
|
||||
offlineSeconds: elapsedSeconds,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
|
||||
*/
|
||||
export const calculatePrestigeThreshold = (prestigeCount: number): number =>
|
||||
BASE_PRESTIGE_GOLD_THRESHOLD * Math.pow(THRESHOLD_SCALE_FACTOR, prestigeCount);
|
||||
|
||||
export const isEligibleForPrestige = (state: GameState): boolean =>
|
||||
state.player.totalGoldEarned >= calculatePrestigeThreshold(state.prestige.count);
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedUpgradeIds: string[],
|
||||
category: PrestigeUpgradeCategory,
|
||||
): number =>
|
||||
DEFAULT_PRESTIGE_UPGRADES.filter(
|
||||
(u) => u.category === category && purchasedUpgradeIds.includes(u.id),
|
||||
).reduce((mult, u) => mult * u.multiplier, 1);
|
||||
|
||||
export const computeRunestoneMultipliers = (
|
||||
purchasedUpgradeIds: string[],
|
||||
): {
|
||||
runestonesIncomeMultiplier: number;
|
||||
runestonesClickMultiplier: number;
|
||||
runestonesEssenceMultiplier: number;
|
||||
runestonesCrystalMultiplier: number;
|
||||
} => ({
|
||||
runestonesIncomeMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "income"),
|
||||
runestonesClickMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "click"),
|
||||
runestonesEssenceMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "essence"),
|
||||
runestonesCrystalMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "crystals"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates how many runestones the player earns from a prestige.
|
||||
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier
|
||||
*/
|
||||
export const calculateRunestones = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: string[],
|
||||
): number => {
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the new prestige production multiplier.
|
||||
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
|
||||
*/
|
||||
export const calculateProductionMultiplier = (prestigeCount: number): number =>
|
||||
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.
|
||||
*/
|
||||
export const calculateMilestoneBonus = (newPrestigeCount: number): number => {
|
||||
if (newPrestigeCount % MILESTONE_INTERVAL !== 0) return 0;
|
||||
const milestoneNumber = newPrestigeCount / MILESTONE_INTERVAL;
|
||||
return milestoneNumber * MILESTONE_RUNESTONES_PER_INTERVAL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the reset game state after a prestige.
|
||||
* Carries over prestige data and runestones; resets everything else.
|
||||
*/
|
||||
export 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),
|
||||
purchasedUpgradeIds,
|
||||
lastPrestigedAt: Date.now(),
|
||||
...computeRunestoneMultipliers(purchasedUpgradeIds),
|
||||
...(currentState.prestige.autoPrestigeEnabled !== undefined
|
||||
? { autoPrestigeEnabled: currentState.prestige.autoPrestigeEnabled }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
|
||||
const newState: GameState = {
|
||||
...freshState,
|
||||
prestige: newPrestigeData,
|
||||
lastTickAt: Date.now(),
|
||||
};
|
||||
|
||||
return { newState, newPrestigeData, runestonesEarned, milestoneRunestones };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type HonoEnv = { Variables: { discordId: string } };
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": "."
|
||||
},
|
||||
"exclude": ["test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/types/**/*.ts"],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
include: ["test/**/*.spec.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { NaomisConfig } from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...NaomisConfig];
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@elysium/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"dev": "vite",
|
||||
"lint": "eslint --max-warnings 0 src",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysium/types": "workspace:*",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@vitest/coverage-v8": "3.0.8",
|
||||
"eslint": "9.22.0",
|
||||
"jsdom": "26.0.0",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.1",
|
||||
"vitest": "3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
AboutResponse,
|
||||
AuthResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const BASE_URL = "/api";
|
||||
|
||||
const getToken = (): string | null => localStorage.getItem("elysium_token");
|
||||
|
||||
const headers = (): Record<string, string> => {
|
||||
const token = getToken();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const request = async <T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: { ...headers(), ...options?.headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({ error: "Unknown error" }))) as {
|
||||
error: string;
|
||||
};
|
||||
throw new Error(error.error);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
|
||||
export const getAbout = async (): Promise<AboutResponse> =>
|
||||
request<AboutResponse>("/about");
|
||||
|
||||
export const getAuthUrl = async (): Promise<string> => {
|
||||
const data = await request<{ 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);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const loadGame = async (): Promise<LoadResponse> =>
|
||||
request<LoadResponse>("/game/load");
|
||||
|
||||
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
||||
request<SaveResponse>("/game/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const challengeBoss = async (
|
||||
body: BossChallengeRequest,
|
||||
): Promise<BossChallengeResponse> =>
|
||||
request<BossChallengeResponse>("/boss/challenge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const prestige = async (body: PrestigeRequest): Promise<PrestigeResponse> =>
|
||||
request<PrestigeResponse>("/prestige", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const buyPrestigeUpgrade = async (
|
||||
body: BuyPrestigeUpgradeRequest,
|
||||
): Promise<BuyPrestigeUpgradeResponse> =>
|
||||
request<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const getPublicProfile = async (
|
||||
discordId: string,
|
||||
): Promise<PublicProfileResponse> =>
|
||||
request<PublicProfileResponse>(`/profile/${discordId}`);
|
||||
|
||||
export const updateProfile = async (
|
||||
body: UpdateProfileRequest,
|
||||
): Promise<UpdateProfileResponse> =>
|
||||
request<UpdateProfileResponse>("/profile", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { PublicProfileResponse } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface ProfilePageProps {
|
||||
discordId: string;
|
||||
}
|
||||
|
||||
export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => {
|
||||
const { formatNumber } = useGame();
|
||||
const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error("Player not found");
|
||||
return res.json() as Promise<PublicProfileResponse>;
|
||||
})
|
||||
.then(setProfile)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load profile");
|
||||
});
|
||||
}, [discordId]);
|
||||
|
||||
const handleCopy = (): void => {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => { setCopied(false); }, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-error">
|
||||
<p>⚠️ {error}</p>
|
||||
<a className="profile-play-link" href="/">← Play Elysium</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-loading">Loading profile…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = profile.profileSettings;
|
||||
|
||||
const avatarUrl = profile.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
|
||||
|
||||
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const visibleStats = [
|
||||
s.showTotalGold && {
|
||||
icon: "🪙",
|
||||
value: formatNumber(profile.totalGoldEarned),
|
||||
label: "Total Gold Earned",
|
||||
date: false,
|
||||
},
|
||||
s.showTotalClicks && {
|
||||
icon: "👆",
|
||||
value: formatNumber(profile.totalClicks),
|
||||
label: "Total Clicks",
|
||||
date: false,
|
||||
},
|
||||
s.showBossesDefeated && {
|
||||
icon: "💀",
|
||||
value: String(profile.bossesDefeated),
|
||||
label: "Bosses Defeated",
|
||||
date: false,
|
||||
},
|
||||
s.showQuestsCompleted && {
|
||||
icon: "📜",
|
||||
value: String(profile.questsCompleted),
|
||||
label: "Quests Completed",
|
||||
date: false,
|
||||
},
|
||||
s.showAdventurersRecruited && {
|
||||
icon: "⚔️",
|
||||
value: formatNumber(profile.adventurersRecruited),
|
||||
label: "Adventurers Recruited",
|
||||
date: false,
|
||||
},
|
||||
s.showAchievementsUnlocked && {
|
||||
icon: "🏆",
|
||||
value: String(profile.achievementsUnlocked),
|
||||
label: "Achievements Unlocked",
|
||||
date: false,
|
||||
},
|
||||
s.showGuildFounded && {
|
||||
icon: "📅",
|
||||
value: memberSince,
|
||||
label: "Guild Founded",
|
||||
date: true,
|
||||
},
|
||||
].filter(Boolean) as Array<{ icon: string; value: string; label: string; date: boolean }>;
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-card">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
alt={`${profile.username}'s avatar`}
|
||||
className="profile-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="profile-identity">
|
||||
<h1 className="profile-character-name">{profile.characterName}</h1>
|
||||
<p className="profile-username">@{profile.username}</p>
|
||||
{s.showPrestige && profile.prestigeCount > 0 && (
|
||||
<span className="profile-prestige-badge">
|
||||
⭐ Prestige {profile.prestigeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio && (
|
||||
<p className="profile-bio">{profile.bio}</p>
|
||||
)}
|
||||
|
||||
{visibleStats.length > 0 && (
|
||||
<div className="profile-stats">
|
||||
{visibleStats.map((stat) => (
|
||||
<div key={stat.label} className="profile-stat">
|
||||
<span className="profile-stat-icon">{stat.icon}</span>
|
||||
<span className={`profile-stat-value ${stat.date ? "profile-stat-date" : ""}`}>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="profile-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="profile-share-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? "✓ Copied!" : "🔗 Copy Profile Link"}
|
||||
</button>
|
||||
<a className="profile-play-link" href="/">
|
||||
⚔️ Play Elysium
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Quest } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
import { ZoneSelector } from "./ZoneSelector.js";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const questTimeRemaining = (quest: Quest): number => {
|
||||
if (quest.status !== "active" || quest.startedAt == null) return 0;
|
||||
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
||||
return Math.max(0, quest.durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface QuestCardProps {
|
||||
quest: Quest;
|
||||
partyCombatPower: number;
|
||||
unlockHint?: string | undefined;
|
||||
zoneHint?: string | undefined;
|
||||
}
|
||||
|
||||
const QuestCard = ({ quest, partyCombatPower, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => {
|
||||
const { startQuest, formatNumber } = useGame();
|
||||
const cpRequired = quest.combatPowerRequired ?? 0;
|
||||
const meetsCP = partyCombatPower >= cpRequired;
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
{cpRequired > 0 && (
|
||||
<p className={`quest-cp-requirement ${meetsCP ? "cp-met" : "cp-unmet"}`}>
|
||||
⚔️ Requires {formatNumber(cpRequired)} Combat Power
|
||||
{quest.status === "available" && (meetsCP ? " ✓" : ` (you have ${formatNumber(partyCombatPower)})`)}
|
||||
</p>
|
||||
)}
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key -- rewards have no unique id
|
||||
<span key={index} className="reward-tag">
|
||||
{reward.type === "gold" && `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "essence" && `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "crystals" && `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked" && (
|
||||
<>
|
||||
<span className="quest-badge locked">🔒 Locked</span>
|
||||
{zoneHint && <p className="unlock-hint">🗺️ Unlock zone: {zoneHint}</p>}
|
||||
{!zoneHint && unlockHint && <p className="unlock-hint">📜 Complete: {unlockHint}</p>}
|
||||
</>
|
||||
)}
|
||||
{quest.status === "available" && (
|
||||
<button
|
||||
className="start-quest-button"
|
||||
disabled={!meetsCP}
|
||||
onClick={() => { startQuest(quest.id); }}
|
||||
title={meetsCP ? undefined : `Need ${formatNumber(cpRequired)} combat power`}
|
||||
type="button"
|
||||
>
|
||||
Send Party ({formatDuration(quest.durationSeconds)})
|
||||
</button>
|
||||
)}
|
||||
{quest.status === "active" && (
|
||||
<span className="quest-badge active">
|
||||
⏳ {formatDuration(Math.ceil(questTimeRemaining(quest)))} remaining
|
||||
</span>
|
||||
)}
|
||||
{quest.status === "completed" && <span className="quest-badge completed">✅ Complete</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const partyCombatPower = state.adventurers.reduce(
|
||||
(total, a) => total + a.combatPower * a.count,
|
||||
0,
|
||||
);
|
||||
|
||||
const zones = state.zones ?? [];
|
||||
const zoneQuests = state.quests.filter((q) => q.zoneId === activeZoneId);
|
||||
const lockedCount = zoneQuests.filter((q) => q.status === "locked").length;
|
||||
const visibleQuests = showLocked
|
||||
? zoneQuests
|
||||
: zoneQuests.filter((q) => q.status !== "locked");
|
||||
|
||||
const questNameById = new Map(state.quests.map((q) => [q.id, q.name]));
|
||||
const zoneById = new Map(zones.map((z) => [z.id, z]));
|
||||
const questUnlockHints = new Map<string, string>();
|
||||
const questZoneHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
if (quest.status !== "locked") continue;
|
||||
const zone = zoneById.get(quest.zoneId);
|
||||
if (zone?.status === "locked") {
|
||||
questZoneHints.set(quest.id, zone.name);
|
||||
} else if (quest.prerequisiteIds.length > 0) {
|
||||
const prereqId = quest.prerequisiteIds[0];
|
||||
if (prereqId) {
|
||||
const prereqName = questNameById.get(prereqId);
|
||||
if (prereqName) {
|
||||
questUnlockHints.set(quest.id, prereqName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel quest-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Quests</h2>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
zones={zones}
|
||||
onSelectZone={setActiveZoneId}
|
||||
/>
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => (
|
||||
<QuestCard
|
||||
key={quest.id}
|
||||
partyCombatPower={partyCombatPower}
|
||||
quest={quest}
|
||||
unlockHint={questUnlockHints.get(quest.id)}
|
||||
zoneHint={questZoneHints.get(quest.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleQuests.length === 0 && (
|
||||
<p className="empty-zone">No quests to show in this zone.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js";
|
||||
|
||||
const formatDate = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
interface StatCardProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
const StatCard = ({ icon, label, value, sub }: StatCardProps): React.JSX.Element => (
|
||||
<div className="profile-stat">
|
||||
<span className="profile-stat-icon">{icon}</span>
|
||||
<span className="profile-stat-value">{value}</span>
|
||||
<span className="profile-stat-label">{label}</span>
|
||||
{sub !== undefined && <span className="profile-stat-date">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const StatisticsPanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const { player, resources, prestige, bosses, quests, zones, adventurers, upgrades, equipment, achievements } = state;
|
||||
|
||||
const bossesDefeated = bosses.filter((b) => b.status === "defeated").length;
|
||||
const questsCompleted = quests.filter((q) => q.status === "completed").length;
|
||||
const zonesUnlocked = zones.filter((z) => z.status === "unlocked").length;
|
||||
const adventurersRecruited = adventurers.reduce((sum, a) => sum + a.count, 0);
|
||||
const equipmentOwned = (equipment ?? []).filter((e) => e.owned).length;
|
||||
const upgradesPurchased = upgrades.filter((u) => u.purchased).length;
|
||||
const achievementsUnlocked = (achievements ?? []).filter((a) => a.unlockedAt !== null).length;
|
||||
const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length;
|
||||
|
||||
return (
|
||||
<section className="panel statistics-panel">
|
||||
<h2>📊 Statistics</h2>
|
||||
|
||||
<h3 className="stats-section-header">All-Time</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Total Gold Earned"
|
||||
value={formatNumber(player.totalGoldEarned)}
|
||||
sub="across all runs"
|
||||
/>
|
||||
<StatCard
|
||||
icon="👆"
|
||||
label="Total Clicks"
|
||||
value={formatNumber(player.totalClicks)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⭐"
|
||||
label="Prestiges"
|
||||
value={String(prestige.count)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📅"
|
||||
label="Guild Founded"
|
||||
value={formatDate(player.createdAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="☁️"
|
||||
label="Last Cloud Save"
|
||||
value={formatDate(player.lastSavedAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✖️"
|
||||
label="Production Multiplier"
|
||||
value={`×${prestige.productionMultiplier.toFixed(2)}`}
|
||||
sub="from prestige"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">Current Run</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Gold"
|
||||
value={formatNumber(resources.gold)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✨"
|
||||
label="Essence"
|
||||
value={formatNumber(resources.essence)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
sub="permanent currency"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">Progress</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="👹"
|
||||
label="Bosses Defeated"
|
||||
value={`${String(bossesDefeated)} / ${String(bosses.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📜"
|
||||
label="Quests Completed"
|
||||
value={`${String(questsCompleted)} / ${String(quests.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗺️"
|
||||
label="Zones Unlocked"
|
||||
value={`${String(zonesUnlocked)} / ${String(zones.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚔️"
|
||||
label="Adventurers Recruited"
|
||||
value={formatNumber(adventurersRecruited)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗡️"
|
||||
label="Equipment Owned"
|
||||
value={`${String(equipmentOwned)} / ${String((equipment ?? []).length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔧"
|
||||
label="Upgrades Purchased"
|
||||
value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
label="Achievements"
|
||||
value={`${String(achievementsUnlocked)} / ${String((achievements ?? []).length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Prestige Upgrades"
|
||||
value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { LockToggle } from "../ui/LockToggle.js";
|
||||
|
||||
interface UpgradeCardProps {
|
||||
upgrade: Upgrade;
|
||||
currentGold: number;
|
||||
currentEssence: number;
|
||||
currentCrystals: number;
|
||||
unlockHint?: string | undefined;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals, unlockHint, formatNumber }: UpgradeCardProps): React.JSX.Element => {
|
||||
const { buyUpgrade } = useGame();
|
||||
const canAfford =
|
||||
currentGold >= upgrade.costGold &&
|
||||
currentEssence >= upgrade.costEssence &&
|
||||
currentCrystals >= (upgrade.costCrystals ?? 0);
|
||||
|
||||
if (!upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<div className="upgrade-info">
|
||||
<h3>🔒 {upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0 && <span>🪙 {formatNumber(upgrade.costGold)}</span>}
|
||||
{upgrade.costEssence > 0 && <span>✨ {formatNumber(upgrade.costEssence)}</span>}
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>💎 {formatNumber(upgrade.costCrystals ?? 0)}</span>}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">Locked</span>
|
||||
{unlockHint && <p className="unlock-hint">{unlockHint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
<span className="upgrade-name">✅ {upgrade.name}</span>
|
||||
<span className="upgrade-desc">{upgrade.description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upgrade-card">
|
||||
<div className="upgrade-info">
|
||||
<h3>{upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0 && <span>🪙 {formatNumber(upgrade.costGold)}</span>}
|
||||
{upgrade.costEssence > 0 && <span>✨ {formatNumber(upgrade.costEssence)}</span>}
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>💎 {formatNumber(upgrade.costCrystals ?? 0)}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford}
|
||||
onClick={() => { buyUpgrade(upgrade.id); }}
|
||||
type="button"
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpgradePanel = (): React.JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [showLocked, setShowLocked] = useState(true);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const purchased = state.upgrades.filter((u) => u.purchased);
|
||||
const available = state.upgrades.filter((u) => u.unlocked && !u.purchased);
|
||||
const locked = state.upgrades.filter((u) => !u.unlocked);
|
||||
|
||||
const upgradeUnlockHints = new Map<string, string>();
|
||||
for (const boss of state.bosses) {
|
||||
for (const upgradeId of (boss.upgradeRewards ?? [])) {
|
||||
upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${boss.name}`);
|
||||
}
|
||||
}
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "upgrade" && reward.targetId && !upgradeUnlockHints.has(reward.targetId)) {
|
||||
upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${quest.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel upgrade-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Upgrades</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
showLocked={showLocked}
|
||||
onToggle={() => { setShowLocked((v) => !v); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="upgrade-progress">{purchased.length} / {state.upgrades.length} purchased</p>
|
||||
{state.upgrades.length === 0 ? (
|
||||
<p className="empty-state">No upgrades available yet — keep adventuring!</p>
|
||||
) : (
|
||||
<div className="upgrade-list">
|
||||
{available.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
{purchased.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
{showLocked && locked.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
formatNumber={formatNumber}
|
||||
unlockHint={upgradeUnlockHints.get(upgrade.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Zone } from "@elysium/types";
|
||||
|
||||
interface ZoneSelectorProps {
|
||||
zones: Zone[];
|
||||
activeZoneId: string;
|
||||
onSelectZone: (zoneId: string) => void;
|
||||
}
|
||||
|
||||
export const ZoneSelector = ({
|
||||
zones,
|
||||
activeZoneId,
|
||||
onSelectZone,
|
||||
}: ZoneSelectorProps): React.JSX.Element => (
|
||||
<div className="zone-selector">
|
||||
{zones.map((zone) => (
|
||||
<button
|
||||
key={zone.id}
|
||||
className={`zone-tab ${zone.id === activeZoneId ? "zone-tab-active" : ""}`}
|
||||
onClick={() => {
|
||||
onSelectZone(zone.id);
|
||||
}}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span className="zone-emoji">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
interface LockToggleProps {
|
||||
showLocked: boolean;
|
||||
onToggle: () => void;
|
||||
lockedCount: number;
|
||||
}
|
||||
|
||||
export const LockToggle = ({
|
||||
showLocked,
|
||||
onToggle,
|
||||
lockedCount,
|
||||
}: LockToggleProps): React.JSX.Element => (
|
||||
<button
|
||||
className={`lock-toggle ${showLocked ? "lock-toggle-on" : "lock-toggle-off"}`}
|
||||
onClick={onToggle}
|
||||
title={showLocked ? "Hide locked items" : "Show locked items"}
|
||||
type="button"
|
||||
>
|
||||
{showLocked ? "🔓" : "🔒"} {showLocked ? "Hide" : "Show"} locked ({lockedCount})
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Resource } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
||||
|
||||
interface ResourceBarProps {
|
||||
resources: Resource;
|
||||
runestones: number;
|
||||
prestigeCount: number;
|
||||
profileUrl: string;
|
||||
onEditProfile: () => void;
|
||||
lastSavedAt: number | null;
|
||||
isSyncing: boolean;
|
||||
onForceSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 10) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
};
|
||||
|
||||
const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning.";
|
||||
|
||||
export const ResourceBar = ({
|
||||
resources,
|
||||
runestones,
|
||||
prestigeCount,
|
||||
profileUrl,
|
||||
onEditProfile,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProps): React.JSX.Element => {
|
||||
const { formatNumber, syncError } = useGame();
|
||||
const anyFull = [resources.gold, resources.essence, resources.crystals].some((v) => v >= RESOURCE_CAP);
|
||||
return (
|
||||
<>
|
||||
<header className="resource-bar">
|
||||
<div className={`resource${resources.gold >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||||
<span className="resource-icon">🪙</span>
|
||||
<span className="resource-value">{formatNumber(resources.gold)}</span>
|
||||
<span className="resource-label">Gold</span>
|
||||
{resources.gold >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||||
</div>
|
||||
<div className={`resource${resources.essence >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||||
<span className="resource-icon">✨</span>
|
||||
<span className="resource-value">{formatNumber(resources.essence)}</span>
|
||||
<span className="resource-label">Essence</span>
|
||||
{resources.essence >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||||
</div>
|
||||
<div className={`resource${resources.crystals >= RESOURCE_CAP ? " resource-full" : ""}`}>
|
||||
<span className="resource-icon">💎</span>
|
||||
<span className="resource-value">{formatNumber(resources.crystals)}</span>
|
||||
<span className="resource-label">Crystals</span>
|
||||
{resources.crystals >= RESOURCE_CAP && <span className="resource-cap-badge" title={RESOURCE_FULL_TOOLTIP}>FULL</span>}
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">🔮</span>
|
||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||
<span className="resource-label">Runestones</span>
|
||||
</div>
|
||||
{prestigeCount > 0 && (
|
||||
<div className="prestige-badge">
|
||||
⭐ Prestige {prestigeCount}
|
||||
</div>
|
||||
)}
|
||||
<div className="profile-buttons">
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://donate.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Support the developer"
|
||||
>
|
||||
💜 Donate
|
||||
</a>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://chat.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Join our Discord"
|
||||
>
|
||||
💬 Discord
|
||||
</a>
|
||||
{syncError !== null ? (
|
||||
<span className="save-status save-error" title={syncError}>
|
||||
❌ Save failed
|
||||
</span>
|
||||
) : lastSavedAt !== null ? (
|
||||
<span className="save-status" title={new Date(lastSavedAt).toLocaleString()}>
|
||||
☁️ {formatRelativeTime(lastSavedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
className="force-save-button"
|
||||
disabled={isSyncing}
|
||||
onClick={onForceSync}
|
||||
title="Force cloud save"
|
||||
type="button"
|
||||
>
|
||||
{isSyncing ? "⏳" : "💾"}
|
||||
</button>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href={profileUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View your public profile"
|
||||
>
|
||||
👤 Profile
|
||||
</a>
|
||||
<button
|
||||
className="profile-edit-button"
|
||||
onClick={onEditProfile}
|
||||
title="Edit your profile"
|
||||
type="button"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{anyFull && (
|
||||
<div className="resource-cap-notice">
|
||||
⚠️ One or more resources are full! Consider spending some or prestiging to keep earning.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,622 @@
|
||||
import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
buyPrestigeUpgrade as buyPrestigeUpgradeApi,
|
||||
challengeBoss as challengeBossApi,
|
||||
loadGame,
|
||||
prestige as prestigeApi,
|
||||
saveGame,
|
||||
} from "../api/client.js";
|
||||
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||
|
||||
|
||||
export interface BattleResult {
|
||||
bossName: string;
|
||||
result: BossChallengeResponse;
|
||||
}
|
||||
|
||||
interface GameContextValue {
|
||||
state: GameState | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/** Click the crystal to earn gold */
|
||||
handleClick: () => void;
|
||||
/** Buy an adventurer */
|
||||
buyAdventurer: (adventurerId: string) => void;
|
||||
/** Buy an upgrade */
|
||||
buyUpgrade: (upgradeId: string) => void;
|
||||
/** Purchase a buyable equipment item */
|
||||
buyEquipment: (equipmentId: string) => void;
|
||||
/** Start a quest */
|
||||
startQuest: (questId: string) => void;
|
||||
/** Challenge a boss — runs full server-side simulation */
|
||||
challengeBoss: (bossId: string) => Promise<void>;
|
||||
/** Equip an owned equipment item (auto-unequips the same slot) */
|
||||
equipItem: (equipmentId: string) => void;
|
||||
/** Reload state from the server */
|
||||
reload: () => Promise<void>;
|
||||
/** Unix timestamp of the last successful cloud save (null until first save response) */
|
||||
lastSavedAt: number | null;
|
||||
/** True whilst a forced save is in-flight */
|
||||
isSyncing: boolean;
|
||||
/** Immediately save to the server and reset the auto-save timer */
|
||||
forceSync: () => Promise<void>;
|
||||
/** Error message from the last failed cloud save (null when no error) */
|
||||
syncError: string | null;
|
||||
/** Offline gold earned on login */
|
||||
offlineGold: number;
|
||||
/** Offline essence earned on login */
|
||||
offlineEssence: number;
|
||||
/** Dismiss the offline earnings notification */
|
||||
dismissOfflineGold: () => void;
|
||||
/** Battle result to display in the modal (null when no battle pending) */
|
||||
battleResult: BattleResult | null;
|
||||
/** Dismiss the battle result modal */
|
||||
dismissBattle: () => void;
|
||||
/** Queue of newly unlocked achievements (for toasts) */
|
||||
newAchievements: Achievement[];
|
||||
/** Remove an achievement from the toast queue */
|
||||
dismissAchievement: (id: string) => void;
|
||||
/** The player's chosen number display format */
|
||||
numberFormat: NumberFormat;
|
||||
/** Update the number format preference (persisted to server via profile save) */
|
||||
setNumberFormat: (format: NumberFormat) => void;
|
||||
/** Format a number using the player's chosen notation style */
|
||||
formatNumber: (value: number) => string;
|
||||
/** Buy a prestige upgrade from the runestone shop */
|
||||
buyPrestigeUpgrade: (upgradeId: string) => Promise<void>;
|
||||
/** Toggle the auto-prestige setting on/off (requires auto_prestige upgrade) */
|
||||
toggleAutoPrestige: () => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
|
||||
const AUTO_SAVE_INTERVAL_MS = 30_000;
|
||||
const AUTO_PRESTIGE_THRESHOLD_BASE = 1_000_000;
|
||||
const AUTO_PRESTIGE_THRESHOLD_SCALE = 5;
|
||||
|
||||
export const GameProvider = ({ children }: { children: React.ReactNode }): React.JSX.Element => {
|
||||
const [state, setState] = useState<GameState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = useState(0);
|
||||
const [offlineEssence, setOfflineEssence] = useState(0);
|
||||
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
|
||||
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const syncErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [numberFormat, setNumberFormat] = useState<NumberFormat>("suffix");
|
||||
const stateRef = useRef<GameState | null>(null);
|
||||
const lastSaveRef = useRef<number>(Date.now());
|
||||
const isSyncingRef = useRef(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
||||
const signatureRef = useRef<string | null>(localStorage.getItem("elysium_save_signature"));
|
||||
const isAutoPrestigingRef = useRef(false);
|
||||
const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await loadGame();
|
||||
setState(data.state);
|
||||
setLastSavedAt(data.state.player.lastSavedAt);
|
||||
if (data.signature) {
|
||||
signatureRef.current = data.signature;
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
if (data.offlineGold > 0) {
|
||||
setOfflineGold(data.offlineGold);
|
||||
}
|
||||
if (data.offlineEssence > 0) {
|
||||
setOfflineEssence(data.offlineEssence);
|
||||
}
|
||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||
void fetch(`/api/profile/${data.state.player.discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const profile = await res.json() as { profileSettings?: { numberFormat?: NumberFormat } };
|
||||
const fmt = profile.profileSettings?.numberFormat;
|
||||
if (fmt === "suffix" || fmt === "scientific" || fmt === "engineering") {
|
||||
setNumberFormat(fmt);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* fall back to default "suffix" */ });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load game");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
reloadRef.current = reload;
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// Game loop via requestAnimationFrame
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
|
||||
let lastTime = performance.now();
|
||||
|
||||
const tick = (now: number): void => {
|
||||
const deltaSeconds = (now - lastTime) / 1000;
|
||||
lastTime = now;
|
||||
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = applyTick(prev, deltaSeconds);
|
||||
|
||||
// Detect newly unlocked achievements
|
||||
newlyUnlockedRef.current = next.achievements.filter((a, i) => {
|
||||
const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null;
|
||||
return wasLocked && a.unlockedAt !== null;
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (newlyUnlockedRef.current.length > 0) {
|
||||
setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]);
|
||||
newlyUnlockedRef.current = [];
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
|
||||
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
||||
lastSaveRef.current = Date.now();
|
||||
if (stateRef.current && !isSyncingRef.current) {
|
||||
void saveGame({
|
||||
state: stateRef.current,
|
||||
...(signatureRef.current !== null ? { signature: signatureRef.current } : {}),
|
||||
}).then((response) => {
|
||||
setLastSavedAt(response.savedAt);
|
||||
if (response.signature) {
|
||||
signatureRef.current = response.signature;
|
||||
localStorage.setItem("elysium_save_signature", response.signature);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
// Silently clear a bad signature so the next auto-save can proceed
|
||||
if (err instanceof Error && err.message.includes("signature mismatch")) {
|
||||
signatureRef.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-prestige: fire when unlocked, enabled, and threshold is met
|
||||
const autoState = stateRef.current;
|
||||
if (
|
||||
!isAutoPrestigingRef.current &&
|
||||
autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") &&
|
||||
autoState.prestige.autoPrestigeEnabled &&
|
||||
autoState.player.totalGoldEarned >=
|
||||
AUTO_PRESTIGE_THRESHOLD_BASE *
|
||||
Math.pow(AUTO_PRESTIGE_THRESHOLD_SCALE, autoState.prestige.count)
|
||||
) {
|
||||
isAutoPrestigingRef.current = true;
|
||||
void prestigeApi({ characterName: autoState.player.characterName })
|
||||
.then(() => reloadRef.current())
|
||||
.catch(() => { /* silently ignore — will retry next tick */ })
|
||||
.finally(() => { isAutoPrestigingRef.current = false; });
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available
|
||||
}, [state !== null]);
|
||||
|
||||
const showSyncError = useCallback((message: string) => {
|
||||
setSyncError(message);
|
||||
if (syncErrorTimerRef.current) clearTimeout(syncErrorTimerRef.current);
|
||||
syncErrorTimerRef.current = setTimeout(() => { setSyncError(null); }, 5000);
|
||||
}, []);
|
||||
|
||||
const clearBadSignature = useCallback(() => {
|
||||
signatureRef.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
}, []);
|
||||
|
||||
const forceSync = useCallback(async () => {
|
||||
if (!stateRef.current || isSyncingRef.current) return;
|
||||
isSyncingRef.current = true;
|
||||
lastSaveRef.current = Date.now(); // push auto-save timer back so it doesn't fire concurrently
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const response = await saveGame({
|
||||
state: stateRef.current,
|
||||
...(signatureRef.current !== null ? { signature: signatureRef.current } : {}),
|
||||
});
|
||||
setSyncError(null);
|
||||
setLastSavedAt(response.savedAt);
|
||||
lastSaveRef.current = Date.now();
|
||||
if (response.signature) {
|
||||
signatureRef.current = response.signature;
|
||||
localStorage.setItem("elysium_save_signature", response.signature);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Save failed";
|
||||
showSyncError(message);
|
||||
if (message.includes("signature mismatch")) clearBadSignature();
|
||||
} finally {
|
||||
isSyncingRef.current = false;
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [showSyncError, clearBadSignature]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const clickPower = calculateClickPower(prev);
|
||||
const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP);
|
||||
|
||||
let updatedDailyChallenges = prev.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges) {
|
||||
const result = updateChallengeProgress(updatedDailyChallenges, "clicks", 1);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: {
|
||||
...prev.resources,
|
||||
gold: newGold,
|
||||
crystals: Math.min(prev.resources.crystals + challengeCrystals, RESOURCE_CAP),
|
||||
},
|
||||
player: {
|
||||
...prev.player,
|
||||
totalGoldEarned: prev.player.totalGoldEarned + clickPower,
|
||||
totalClicks: prev.player.totalClicks + 1,
|
||||
},
|
||||
...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyAdventurer = useCallback((adventurerId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const adventurer = prev.adventurers.find((a) => a.id === adventurerId);
|
||||
if (!adventurer || !adventurer.unlocked) return prev;
|
||||
|
||||
const cost = 10 * Math.pow(1.15, adventurer.count);
|
||||
if (prev.resources.gold < cost) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: { ...prev.resources, gold: prev.resources.gold - cost },
|
||||
adventurers: prev.adventurers.map((a) =>
|
||||
a.id === adventurerId ? { ...a, count: a.count + 1 } : a,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyUpgrade = useCallback((upgradeId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const upgrade = prev.upgrades.find((u) => u.id === upgradeId);
|
||||
if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev;
|
||||
if (prev.resources.gold < upgrade.costGold) return prev;
|
||||
if (prev.resources.essence < upgrade.costEssence) return prev;
|
||||
if (prev.resources.crystals < (upgrade.costCrystals ?? 0)) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: {
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold - upgrade.costGold,
|
||||
essence: prev.resources.essence - upgrade.costEssence,
|
||||
crystals: prev.resources.crystals - (upgrade.costCrystals ?? 0),
|
||||
},
|
||||
upgrades: prev.upgrades.map((u) =>
|
||||
u.id === upgradeId ? { ...u, purchased: true } : u,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startQuest = useCallback((questId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const quest = prev.quests.find((q) => q.id === questId);
|
||||
if (!quest || quest.status !== "available") return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
quests: prev.quests.map((q) =>
|
||||
q.id === questId
|
||||
? { ...q, status: "active" as const, startedAt: Date.now() }
|
||||
: q,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const equipItem = useCallback((equipmentId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (!item || !item.owned) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
equipment: (prev.equipment ?? []).map((e) => {
|
||||
if (e.id === equipmentId) return { ...e, equipped: true };
|
||||
// Unequip the previously-equipped item in the same slot
|
||||
if (e.type === item.type && e.equipped) return { ...e, equipped: false };
|
||||
return e;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyEquipment = useCallback((equipmentId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (!item || item.owned || !item.cost) return prev;
|
||||
|
||||
const { gold, essence, crystals } = item.cost;
|
||||
if (prev.resources.gold < gold) return prev;
|
||||
if (prev.resources.essence < essence) return prev;
|
||||
if (prev.resources.crystals < crystals) return prev;
|
||||
|
||||
const slotAlreadyEquipped = (prev.equipment ?? []).some(
|
||||
(e) => e.type === item.type && e.equipped,
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
resources: {
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold - gold,
|
||||
essence: prev.resources.essence - essence,
|
||||
crystals: prev.resources.crystals - crystals,
|
||||
},
|
||||
equipment: (prev.equipment ?? []).map((e) => {
|
||||
if (e.id === equipmentId) return { ...e, owned: true, equipped: !slotAlreadyEquipped };
|
||||
return e;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyPrestigeUpgrade = useCallback(async (upgradeId: string) => {
|
||||
try {
|
||||
const result = await buyPrestigeUpgradeApi({ upgradeId });
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
prestige: {
|
||||
...prev.prestige,
|
||||
runestones: result.runestonesRemaining,
|
||||
purchasedUpgradeIds: result.purchasedUpgradeIds,
|
||||
runestonesIncomeMultiplier: result.runestonesIncomeMultiplier,
|
||||
runestonesClickMultiplier: result.runestonesClickMultiplier,
|
||||
runestonesEssenceMultiplier: result.runestonesEssenceMultiplier,
|
||||
runestonesCrystalMultiplier: result.runestonesCrystalMultiplier,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleAutoPrestige = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
prestige: {
|
||||
...prev.prestige,
|
||||
autoPrestigeEnabled: !prev.prestige.autoPrestigeEnabled,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
if (!boss) return;
|
||||
|
||||
try {
|
||||
const result = await challengeBossApi({ bossId });
|
||||
|
||||
// Update local state to match server result
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
if (result.won) {
|
||||
const defeatedBoss = prev.bosses.find((b) => b.id === bossId);
|
||||
const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId);
|
||||
const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId);
|
||||
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
|
||||
|
||||
// Find newly unlocked zones and their first bosses
|
||||
// A zone unlocks when BOTH the gate boss is defeated AND the gate quest is completed
|
||||
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => {
|
||||
if (z.status !== "locked" || z.unlockBossId !== bossId) return false;
|
||||
const questOk =
|
||||
z.unlockQuestId == null ||
|
||||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
|
||||
return questOk;
|
||||
});
|
||||
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
|
||||
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
|
||||
return firstBoss?.id;
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) => {
|
||||
if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 };
|
||||
if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) {
|
||||
return { ...b, status: "available" as const };
|
||||
}
|
||||
if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) {
|
||||
return { ...b, status: "available" as const };
|
||||
}
|
||||
return b;
|
||||
}),
|
||||
zones: (prev.zones ?? []).map((z) => {
|
||||
if (z.status !== "locked" || z.unlockBossId !== bossId) return z;
|
||||
const questOk =
|
||||
z.unlockQuestId == null ||
|
||||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
|
||||
return questOk ? { ...z, status: "unlocked" as const } : z;
|
||||
}),
|
||||
resources: result.rewards
|
||||
? {
|
||||
...prev.resources,
|
||||
gold: prev.resources.gold + result.rewards.gold,
|
||||
essence: prev.resources.essence + result.rewards.essence,
|
||||
crystals: prev.resources.crystals + result.rewards.crystals,
|
||||
}
|
||||
: prev.resources,
|
||||
prestige: result.rewards?.bountyRunestones
|
||||
? {
|
||||
...prev.prestige,
|
||||
runestones: prev.prestige.runestones + result.rewards.bountyRunestones,
|
||||
}
|
||||
: prev.prestige,
|
||||
player: result.rewards
|
||||
? {
|
||||
...prev.player,
|
||||
totalGoldEarned:
|
||||
prev.player.totalGoldEarned + result.rewards.gold,
|
||||
}
|
||||
: prev.player,
|
||||
upgrades: result.rewards
|
||||
? prev.upgrades.map((u) =>
|
||||
result.rewards!.upgradeIds.includes(u.id)
|
||||
? { ...u, unlocked: true }
|
||||
: u,
|
||||
)
|
||||
: prev.upgrades,
|
||||
equipment: result.rewards
|
||||
? (prev.equipment ?? []).map((e) => {
|
||||
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
|
||||
const slotEmpty = !(prev.equipment ?? []).some(
|
||||
(other) => other.type === e.type && other.equipped,
|
||||
);
|
||||
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
|
||||
})
|
||||
: prev.equipment ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// Loss: reset boss HP and apply casualties
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) =>
|
||||
b.id === bossId
|
||||
? { ...b, status: "available" as const, currentHp: b.maxHp }
|
||||
: b,
|
||||
),
|
||||
adventurers: prev.adventurers.map((a) => {
|
||||
const casualty = result.casualties?.find(
|
||||
(c) => c.adventurerId === a.id,
|
||||
);
|
||||
if (!casualty) return a;
|
||||
return { ...a, count: Math.max(0, a.count - casualty.killed) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
setBattleResult({ bossName: boss.name, result });
|
||||
} catch {
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissOfflineGold = useCallback(() => {
|
||||
setOfflineGold(0);
|
||||
setOfflineEssence(0);
|
||||
}, []);
|
||||
|
||||
const dismissBattle = useCallback(() => {
|
||||
setBattleResult(null);
|
||||
}, []);
|
||||
|
||||
const dismissAchievement = useCallback((id: string) => {
|
||||
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const boundFormatNumber = useCallback(
|
||||
(value: number) => formatNumberUtil(value, numberFormat),
|
||||
[numberFormat],
|
||||
);
|
||||
|
||||
return (
|
||||
<GameContext.Provider
|
||||
value={{
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
handleClick,
|
||||
buyAdventurer,
|
||||
buyUpgrade,
|
||||
buyEquipment,
|
||||
startQuest,
|
||||
challengeBoss,
|
||||
equipItem,
|
||||
reload,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
forceSync,
|
||||
syncError,
|
||||
offlineGold,
|
||||
offlineEssence,
|
||||
dismissOfflineGold,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
newAchievements,
|
||||
dismissAchievement,
|
||||
numberFormat,
|
||||
setNumberFormat,
|
||||
formatNumber: boundFormatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
toggleAutoPrestige,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGame = (): GameContextValue => {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error("useGame must be used within a GameProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { EquipmentSet } from "@elysium/types";
|
||||
|
||||
export const EQUIPMENT_SETS: 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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { PrestigeUpgrade } from "@elysium/types";
|
||||
|
||||
export const PRESTIGE_UPGRADES: 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",
|
||||
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",
|
||||
runestonesCost: 25,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
{
|
||||
id: "income_3",
|
||||
name: "Runestone Blessing III",
|
||||
description: "The runes sing with accumulated wisdom. All production ×2.",
|
||||
category: "income",
|
||||
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",
|
||||
runestonesCost: 150,
|
||||
multiplier: 3,
|
||||
},
|
||||
{
|
||||
id: "income_5",
|
||||
name: "Runic Surge II",
|
||||
description: "The surge intensifies, pushing limits thought impossible. All production ×5.",
|
||||
category: "income",
|
||||
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",
|
||||
runestonesCost: 800,
|
||||
multiplier: 10,
|
||||
},
|
||||
{
|
||||
id: "income_7",
|
||||
name: "Ancient Inscription I",
|
||||
description: "You decipher ancient runic inscriptions that unlock vast potential. All production ×25.",
|
||||
category: "income",
|
||||
runestonesCost: 2_000,
|
||||
multiplier: 25,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "income_9",
|
||||
name: "Ancient Inscription III",
|
||||
description: "The full inscription blazes with world-shaping power. All production ×100.",
|
||||
category: "income",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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,
|
||||
},
|
||||
// ── Essence Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "essence_1",
|
||||
name: "Essence Attunement I",
|
||||
description: "Runestone resonance amplifies your essence gathering. Essence production ×2.",
|
||||
category: "essence",
|
||||
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",
|
||||
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",
|
||||
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,
|
||||
},
|
||||
// ── Crystal Production ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "crystal_1",
|
||||
name: "Crystal Resonance I",
|
||||
description: "Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
|
||||
category: "crystals",
|
||||
runestonesCost: 30,
|
||||
multiplier: 2,
|
||||
},
|
||||
{
|
||||
id: "crystal_2",
|
||||
name: "Crystal Resonance II",
|
||||
description: "The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
|
||||
category: "crystals",
|
||||
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,
|
||||
},
|
||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||
{
|
||||
id: "auto_prestige",
|
||||
name: "Autonomous Ascension",
|
||||
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",
|
||||
runestonesCost: 100,
|
||||
multiplier: 1,
|
||||
},
|
||||
// ── Runestone Meta-Upgrades ───────────────────────────────────────────────
|
||||
{
|
||||
id: "runestone_gain_1",
|
||||
name: "Runic Legacy",
|
||||
description: "Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
runestonesCost: 50,
|
||||
multiplier: 1.25,
|
||||
},
|
||||
{
|
||||
id: "runestone_gain_2",
|
||||
name: "Eternal Legacy",
|
||||
description: "Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.",
|
||||
category: "runestones",
|
||||
runestonesCost: 500,
|
||||
multiplier: 1.5,
|
||||
},
|
||||
];
|
||||
|
||||
export const PRESTIGE_UPGRADE_CATEGORY_LABELS: Record<string, string> = {
|
||||
income: "🪙 Global Income",
|
||||
click: "👆 Click Power",
|
||||
essence: "✨ Essence Production",
|
||||
crystals: "💎 Crystal Rewards",
|
||||
runestones: "🔮 Runestone Gain",
|
||||
utility: "⚙️ Utility",
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
||||
import { computeSetBonuses } from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
* Checks all achievements against the current game state and returns an updated
|
||||
* achievements array, marking newly-met conditions with the current timestamp.
|
||||
*/
|
||||
const checkAchievements = (state: GameState): Achievement[] => {
|
||||
const now = Date.now();
|
||||
return (state.achievements ?? []).map((achievement) => {
|
||||
if (achievement.unlockedAt !== null) return achievement;
|
||||
|
||||
const { condition } = achievement;
|
||||
let met = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
met = state.player.totalGoldEarned >= condition.amount;
|
||||
break;
|
||||
case "totalClicks":
|
||||
met = state.player.totalClicks >= condition.amount;
|
||||
break;
|
||||
case "bossesDefeated":
|
||||
met = state.bosses.filter((b) => b.status === "defeated").length >= condition.amount;
|
||||
break;
|
||||
case "questsCompleted":
|
||||
met = state.quests.filter((q) => q.status === "completed").length >= condition.amount;
|
||||
break;
|
||||
case "adventurerTotal":
|
||||
met = state.adventurers.reduce((sum, a) => sum + a.count, 0) >= condition.amount;
|
||||
break;
|
||||
case "prestigeCount":
|
||||
met = state.prestige.count >= condition.amount;
|
||||
break;
|
||||
case "equipmentOwned":
|
||||
met = (state.equipment ?? []).filter((e) => e.owned).length >= condition.amount;
|
||||
break;
|
||||
}
|
||||
|
||||
return met ? { ...achievement, unlockedAt: now } : achievement;
|
||||
});
|
||||
};
|
||||
|
||||
/** Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */
|
||||
export const RESOURCE_CAP = 1e300;
|
||||
|
||||
const capResource = (value: number): number => Math.min(value, RESOURCE_CAP);
|
||||
|
||||
/**
|
||||
* Pure function — applies one game tick to the state.
|
||||
* deltaSeconds: time elapsed since last tick.
|
||||
* Returns a new GameState (does not mutate the original).
|
||||
*/
|
||||
export const applyTick = (state: GameState, deltaSeconds: number): GameState => {
|
||||
const equippedItems: Equipment[] = (state.equipment ?? []).filter((e) => e.equipped);
|
||||
const equipmentGoldMultiplier = equippedItems.reduce(
|
||||
(mult, e) => mult * (e.bonus.goldMultiplier ?? 1),
|
||||
1,
|
||||
);
|
||||
const setGoldMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).goldMultiplier;
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgradeMultiplier = state.upgrades
|
||||
.filter(
|
||||
(u) =>
|
||||
u.purchased &&
|
||||
(u.target === "global" ||
|
||||
(u.target === "adventurer" && u.adventurerId === adventurer.id)),
|
||||
)
|
||||
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
|
||||
|
||||
const prestige = state.prestige.productionMultiplier;
|
||||
|
||||
goldGained +=
|
||||
adventurer.goldPerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesIncome *
|
||||
equipmentGoldMultiplier *
|
||||
setGoldMultiplier *
|
||||
deltaSeconds;
|
||||
|
||||
essenceGained +=
|
||||
adventurer.essencePerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
runestonesEssence *
|
||||
deltaSeconds;
|
||||
}
|
||||
|
||||
// Complete active quests and apply their rewards
|
||||
const now = Date.now();
|
||||
let questGold = 0;
|
||||
let questEssence = 0;
|
||||
let questCrystals = 0;
|
||||
|
||||
let updatedUpgrades = state.upgrades;
|
||||
let updatedAdventurers = state.adventurers;
|
||||
let updatedEquipment = state.equipment ?? [];
|
||||
|
||||
const updatedQuests = state.quests.map((quest) => {
|
||||
if (
|
||||
quest.status !== "active" ||
|
||||
quest.startedAt == null ||
|
||||
now < quest.startedAt + quest.durationSeconds * 1000
|
||||
) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "gold" && reward.amount != null) {
|
||||
questGold += reward.amount;
|
||||
} else if (reward.type === "essence" && reward.amount != null) {
|
||||
questEssence += reward.amount;
|
||||
} else if (reward.type === "crystals" && reward.amount != null) {
|
||||
questCrystals += reward.amount * runestonesCrystal;
|
||||
} else if (reward.type === "upgrade" && reward.targetId != null) {
|
||||
updatedUpgrades = updatedUpgrades.map((u) =>
|
||||
u.id === reward.targetId ? { ...u, unlocked: true } : u,
|
||||
);
|
||||
} else if (reward.type === "adventurer" && reward.targetId != null) {
|
||||
updatedAdventurers = updatedAdventurers.map((a) =>
|
||||
a.id === reward.targetId ? { ...a, unlocked: true } : a,
|
||||
);
|
||||
} else if (reward.type === "equipment" && reward.targetId != null) {
|
||||
const targetId = reward.targetId;
|
||||
updatedEquipment = updatedEquipment.map((e) => {
|
||||
if (e.id !== targetId) return e;
|
||||
const slotEmpty = !updatedEquipment.some(
|
||||
(other) => other.type === e.type && other.equipped,
|
||||
);
|
||||
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...quest, status: "completed" as const };
|
||||
});
|
||||
|
||||
// Unlock quests whose prerequisites are now all completed and whose zone is unlocked
|
||||
const completedIds = new Set(
|
||||
updatedQuests.filter((q) => q.status === "completed").map((q) => q.id),
|
||||
);
|
||||
const fullyUpdatedQuests = updatedQuests.map((quest) => {
|
||||
if (quest.status !== "locked") return quest;
|
||||
const zone = state.zones.find((z) => z.id === quest.zoneId);
|
||||
if (zone?.status === "locked") return quest;
|
||||
if (quest.prerequisiteIds.every((id) => completedIds.has(id))) {
|
||||
return { ...quest, status: "available" as const };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
|
||||
// Unlock zones whose both conditions are now satisfied after quest completion:
|
||||
// (1) the gate boss has been defeated, (2) the gate quest is now completed
|
||||
const updatedZones = state.zones.map((zone) => {
|
||||
if (zone.status === "unlocked") return zone;
|
||||
const bossOk =
|
||||
zone.unlockBossId == null ||
|
||||
state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated");
|
||||
const questOk =
|
||||
zone.unlockQuestId == null || completedIds.has(zone.unlockQuestId);
|
||||
if (bossOk && questOk) {
|
||||
return { ...zone, status: "unlocked" as const };
|
||||
}
|
||||
return zone;
|
||||
});
|
||||
|
||||
// Activate the first boss in any zone that just became unlocked this tick
|
||||
const newlyUnlockedZoneIds = new Set(
|
||||
updatedZones
|
||||
.filter((z) => {
|
||||
const wasLocked = state.zones.find((oz) => oz.id === z.id)?.status === "locked";
|
||||
return z.status === "unlocked" && wasLocked;
|
||||
})
|
||||
.map((z) => z.id),
|
||||
);
|
||||
let updatedBosses = state.bosses;
|
||||
if (newlyUnlockedZoneIds.size > 0) {
|
||||
updatedBosses = state.bosses.map((boss) => {
|
||||
if (!newlyUnlockedZoneIds.has(boss.zoneId ?? "")) return boss;
|
||||
const zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId);
|
||||
const firstBoss = zoneBosses[0];
|
||||
if (firstBoss?.id === boss.id && boss.status === "locked") {
|
||||
return { ...boss, status: "available" as const };
|
||||
}
|
||||
return boss;
|
||||
});
|
||||
}
|
||||
|
||||
// Count quests newly completed this tick and update daily challenge progress
|
||||
const newlyCompletedQuestCount = updatedQuests.filter(
|
||||
(q, i) => q.status === "completed" && state.quests[i]?.status !== "completed",
|
||||
).length;
|
||||
|
||||
let updatedDailyChallenges = state.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges && newlyCompletedQuestCount > 0) {
|
||||
const result = updateChallengeProgress(
|
||||
updatedDailyChallenges,
|
||||
"questsCompleted",
|
||||
newlyCompletedQuestCount,
|
||||
);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
const newGold = capResource(state.resources.gold + goldGained + questGold);
|
||||
const newEssence = capResource(state.resources.essence + essenceGained + questEssence);
|
||||
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
|
||||
|
||||
const partialState: GameState = {
|
||||
...state,
|
||||
resources: {
|
||||
...state.resources,
|
||||
gold: newGold,
|
||||
essence: newEssence,
|
||||
crystals: capResource(state.resources.crystals + questCrystals + challengeCrystals),
|
||||
},
|
||||
...(updatedDailyChallenges !== undefined ? { dailyChallenges: updatedDailyChallenges } : {}),
|
||||
player: {
|
||||
...state.player,
|
||||
totalGoldEarned: newTotalGoldEarned,
|
||||
},
|
||||
quests: fullyUpdatedQuests,
|
||||
upgrades: updatedUpgrades,
|
||||
adventurers: updatedAdventurers,
|
||||
equipment: updatedEquipment,
|
||||
bosses: updatedBosses,
|
||||
zones: updatedZones,
|
||||
lastTickAt: now,
|
||||
};
|
||||
|
||||
// Check achievements and apply crystal rewards for newly unlocked ones
|
||||
const updatedAchievements = checkAchievements(partialState);
|
||||
const crystalsFromAchievements = updatedAchievements.reduce((sum, a, i) => {
|
||||
const wasLocked = (state.achievements ?? [])[i]?.unlockedAt === null;
|
||||
const isNowUnlocked = a.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
return sum + (a.reward?.crystals ?? 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...partialState,
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: capResource(partialState.resources.crystals + crystalsFromAchievements),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the effective click power, including upgrades and equipped trinkets.
|
||||
*/
|
||||
export const calculateClickPower = (state: GameState): number => {
|
||||
const clickMultiplier = state.upgrades
|
||||
.filter((u) => u.purchased && u.target === "click")
|
||||
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
|
||||
|
||||
const equippedItems = (state.equipment ?? []).filter((e) => e.equipped);
|
||||
const equipmentClickMultiplier = equippedItems
|
||||
.filter((e) => e.bonus.clickMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
|
||||
const setClickMultiplier = computeSetBonuses(equippedItems.map((e) => e.id), EQUIPMENT_SETS).clickMultiplier;
|
||||
|
||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||
|
||||
return (
|
||||
state.baseClickPower *
|
||||
clickMultiplier *
|
||||
state.prestige.productionMultiplier *
|
||||
runestonesClick *
|
||||
equipmentClickMultiplier *
|
||||
setClickMultiplier
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import type { DailyChallengeState, DailyChallengeType } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Increments progress for daily challenges matching the given type.
|
||||
* Returns the updated challenge state and any crystals awarded for newly-completed challenges.
|
||||
*
|
||||
* Note: challenge generation and daily resets are handled server-side only.
|
||||
* This utility is purely for client-side progress tracking.
|
||||
*/
|
||||
export const updateChallengeProgress = (
|
||||
challengeState: DailyChallengeState,
|
||||
type: DailyChallengeType,
|
||||
amount: number,
|
||||
): { updatedChallenges: DailyChallengeState; crystalsAwarded: number } => {
|
||||
let crystalsAwarded = 0;
|
||||
|
||||
const updatedChallenges: DailyChallengeState = {
|
||||
...challengeState,
|
||||
challenges: challengeState.challenges.map((challenge) => {
|
||||
if (challenge.type !== type || challenge.completed) return challenge;
|
||||
|
||||
const newProgress = Math.min(challenge.progress + amount, challenge.target);
|
||||
const nowCompleted = newProgress >= challenge.target;
|
||||
|
||||
if (nowCompleted) crystalsAwarded += challenge.rewardCrystals;
|
||||
|
||||
return { ...challenge, progress: newProgress, completed: nowCompleted };
|
||||
}),
|
||||
};
|
||||
|
||||
return { updatedChallenges, crystalsAwarded };
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { NumberFormat } from "@elysium/types";
|
||||
|
||||
// Named suffixes up to 1e33 (Decillion). Letter-based suffixes take over from 1e36 onwards.
|
||||
const NAMED_SUFFIXES: { threshold: number; suffix: string }[] = [
|
||||
{ threshold: 1e33, suffix: "Dc" }, // Decillion
|
||||
{ threshold: 1e30, suffix: "No" }, // Nonillion
|
||||
{ threshold: 1e27, suffix: "Oc" }, // Octillion
|
||||
{ threshold: 1e24, suffix: "Sp" }, // Septillion
|
||||
{ threshold: 1e21, suffix: "Sx" }, // Sextillion
|
||||
{ threshold: 1e18, suffix: "Qi" }, // Quintillion
|
||||
{ threshold: 1e15, suffix: "Qa" }, // Quadrillion
|
||||
{ threshold: 1e12, suffix: "T" }, // Trillion
|
||||
{ threshold: 1e9, suffix: "B" }, // Billion
|
||||
{ threshold: 1e6, suffix: "M" }, // Million
|
||||
{ threshold: 1e3, suffix: "K" }, // Thousand
|
||||
];
|
||||
|
||||
// Letter suffixes start at 1e36 ("a"), stepping by 1000 each time (i.e. +3 exponent per letter).
|
||||
const LETTER_BASE_EXP = 36;
|
||||
|
||||
/**
|
||||
* Generates an alphabetic suffix for a given index:
|
||||
* 0 → "a", 1 → "b", ..., 25 → "z",
|
||||
* 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ...
|
||||
*/
|
||||
const getLetterSuffix = (index: number): string => {
|
||||
let result = "";
|
||||
let n = index;
|
||||
do {
|
||||
result = String.fromCharCode(97 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatSuffix = (value: number): string => {
|
||||
if (value >= Math.pow(10, LETTER_BASE_EXP)) {
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const stepsAboveBase = Math.floor((exp - LETTER_BASE_EXP) / 3);
|
||||
const divisorExp = LETTER_BASE_EXP + stepsAboveBase * 3;
|
||||
const divisor = Math.pow(10, divisorExp);
|
||||
return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`;
|
||||
}
|
||||
for (const { threshold, suffix } of NAMED_SUFFIXES) {
|
||||
if (value >= threshold) {
|
||||
return `${(value / threshold).toFixed(2)}${suffix}`;
|
||||
}
|
||||
}
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number in scientific notation: e.g. 1.23e15.
|
||||
* Falls back to K/M/B/T style below 1 million.
|
||||
*/
|
||||
const formatScientific = (value: number): string => {
|
||||
if (value < 1e6) return formatSuffix(value);
|
||||
// toExponential handles all magnitudes JS can represent (up to ~1.8e308)
|
||||
return value.toExponential(2).replace("e+", "e");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number in engineering notation (exponent always a multiple of 3):
|
||||
* e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million.
|
||||
*/
|
||||
const formatEngineering = (value: number): string => {
|
||||
if (value < 1e6) return formatSuffix(value);
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const engExp = Math.floor(exp / 3) * 3;
|
||||
const mantissa = value / Math.pow(10, engExp);
|
||||
return `${mantissa.toFixed(2)}E${engExp}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number for display using the player's chosen notation style.
|
||||
* Negative values are formatted with a leading minus sign.
|
||||
*/
|
||||
export const formatNumber = (value: number, format: NumberFormat = "suffix"): string => {
|
||||
if (!isFinite(value) || isNaN(value)) return "0";
|
||||
if (value < 0) return `-${formatNumber(-value, format)}`;
|
||||
|
||||
switch (format) {
|
||||
case "scientific":
|
||||
return formatScientific(value);
|
||||
case "engineering":
|
||||
return formatEngineering(value);
|
||||
default:
|
||||
return formatSuffix(value);
|
||||
}
|
||||
};
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __WEB_VERSION__: string;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"target": "ES2022"
|
||||
},
|
||||
"exclude": ["test/**/*.ts", "test/**/*.tsx", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const { version: WEB_VERSION } = JSON.parse(
|
||||
readFileSync("./package.json", "utf-8"),
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__WEB_VERSION__: JSON.stringify(WEB_VERSION),
|
||||
},
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: ["src/types/**/*.ts", "src/main.tsx"],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
include: ["test/**/*.spec.ts", "test/**/*.spec.tsx"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "elysium",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "pnpm -r lint",
|
||||
"build": "pnpm -r build",
|
||||
"test": "pnpm -r test",
|
||||
"dev": "pnpm -r --parallel dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nhcarrigan } from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...(await nhcarrigan())];
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@elysium/types",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./prod/src/index.js",
|
||||
"types": "./prod/src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint --max-warnings 0 src",
|
||||
"test": "echo \"No tests for types package\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"eslint": "9.22.0",
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export type {
|
||||
Achievement,
|
||||
AchievementCondition,
|
||||
AchievementConditionType,
|
||||
AchievementReward,
|
||||
} from "./interfaces/Achievement.js";
|
||||
export type { Adventurer, AdventurerClass } from "./interfaces/Adventurer.js";
|
||||
export type {
|
||||
AboutResponse,
|
||||
ApiError,
|
||||
AuthResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
GiteaRelease,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
} from "./interfaces/Api.js";
|
||||
export type { Boss, BossStatus } from "./interfaces/Boss.js";
|
||||
export type {
|
||||
DailyChallenge,
|
||||
DailyChallengeState,
|
||||
DailyChallengeType,
|
||||
} from "./interfaces/DailyChallenge.js";
|
||||
export type {
|
||||
Equipment,
|
||||
EquipmentBonus,
|
||||
EquipmentRarity,
|
||||
EquipmentType,
|
||||
} from "./interfaces/Equipment.js";
|
||||
export type { EquipmentSet, EquipmentSetBonus } from "./interfaces/EquipmentSet.js";
|
||||
export { computeSetBonuses } from "./interfaces/EquipmentSet.js";
|
||||
export type { GameState } from "./interfaces/GameState.js";
|
||||
export type { Player } from "./interfaces/Player.js";
|
||||
export type { PrestigeData } from "./interfaces/Prestige.js";
|
||||
export type {
|
||||
PrestigeUpgrade,
|
||||
PrestigeUpgradeCategory,
|
||||
} from "./interfaces/PrestigeUpgrade.js";
|
||||
export type {
|
||||
Quest,
|
||||
QuestReward,
|
||||
QuestRewardType,
|
||||
QuestStatus,
|
||||
} from "./interfaces/Quest.js";
|
||||
export type { Resource } from "./interfaces/Resource.js";
|
||||
export type {
|
||||
Upgrade,
|
||||
UpgradeTarget,
|
||||
} from "./interfaces/Upgrade.js";
|
||||
export type { Zone, ZoneStatus } from "./interfaces/Zone.js";
|
||||
export type { NumberFormat, ProfileSettings } from "./interfaces/ProfileSettings.js";
|
||||
export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/ProfileSettings.js";
|
||||
@@ -0,0 +1,28 @@
|
||||
export type AchievementConditionType =
|
||||
| "totalGoldEarned"
|
||||
| "totalClicks"
|
||||
| "bossesDefeated"
|
||||
| "questsCompleted"
|
||||
| "adventurerTotal"
|
||||
| "prestigeCount"
|
||||
| "equipmentOwned";
|
||||
|
||||
export interface AchievementCondition {
|
||||
type: AchievementConditionType;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface AchievementReward {
|
||||
crystals?: number;
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
condition: AchievementCondition;
|
||||
reward?: AchievementReward;
|
||||
/** Unix timestamp when unlocked, null if not yet unlocked */
|
||||
unlockedAt: number | null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type AdventurerClass =
|
||||
| "warrior"
|
||||
| "mage"
|
||||
| "rogue"
|
||||
| "cleric"
|
||||
| "ranger"
|
||||
| "paladin";
|
||||
|
||||
export interface Adventurer {
|
||||
id: string;
|
||||
name: string;
|
||||
class: AdventurerClass;
|
||||
level: number;
|
||||
/** Base gold generated per second */
|
||||
goldPerSecond: number;
|
||||
/** Base essence generated per second */
|
||||
essencePerSecond: number;
|
||||
/** Combat power per unit — used in boss battle simulation */
|
||||
combatPower: number;
|
||||
count: number;
|
||||
unlocked: boolean;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { GameState } from "./GameState.js";
|
||||
import type { Player } from "./Player.js";
|
||||
import type { ProfileSettings } from "./ProfileSettings.js";
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
player: Player;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export interface SaveRequest {
|
||||
state: GameState;
|
||||
/** HMAC-SHA256 signature of the previous save's state, for anti-cheat chain verification */
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface SaveResponse {
|
||||
savedAt: number;
|
||||
/** HMAC-SHA256 signature of the saved state — store and include in next save request */
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface LoadResponse {
|
||||
state: GameState;
|
||||
/** Offline gold earned since last save (server-calculated) */
|
||||
offlineGold: number;
|
||||
/** Offline essence earned since last save (server-calculated) */
|
||||
offlineEssence: number;
|
||||
/** Seconds the player was offline (capped at 8 hours) */
|
||||
offlineSeconds: number;
|
||||
/** HMAC-SHA256 signature of the loaded state — store and include in next save request */
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface BossChallengeRequest {
|
||||
bossId: string;
|
||||
}
|
||||
|
||||
export interface BossChallengeResponse {
|
||||
won: boolean;
|
||||
partyDPS: number;
|
||||
bossDPS: number;
|
||||
/** Boss HP immediately before the battle */
|
||||
bossHpBefore: number;
|
||||
/** Boss maximum HP */
|
||||
bossMaxHp: number;
|
||||
/** Boss HP at end of battle before any state reset (0 on win) */
|
||||
bossHpAtBattleEnd: number;
|
||||
/** Boss HP stored in game after the result (0 on win, maxHp on loss) */
|
||||
bossNewHp: number;
|
||||
/** Total party HP at start of battle */
|
||||
partyMaxHp: number;
|
||||
/** Party HP remaining after battle (0 on loss) */
|
||||
partyHpRemaining: number;
|
||||
rewards?: {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
upgradeIds: string[];
|
||||
equipmentIds: string[];
|
||||
/** Runestone bounty awarded for defeating this boss for the very first time */
|
||||
bountyRunestones: number;
|
||||
};
|
||||
casualties?: Array<{
|
||||
adventurerId: string;
|
||||
killed: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PrestigeRequest {
|
||||
characterName: string;
|
||||
}
|
||||
|
||||
export interface PrestigeResponse {
|
||||
runestones: number;
|
||||
newPrestigeCount: number;
|
||||
/** Bonus runestones awarded for reaching a milestone prestige (every 5th), 0 if not a milestone */
|
||||
milestoneRunestones: number;
|
||||
}
|
||||
|
||||
export interface BuyPrestigeUpgradeRequest {
|
||||
upgradeId: string;
|
||||
}
|
||||
|
||||
export interface BuyPrestigeUpgradeResponse {
|
||||
runestonesRemaining: number;
|
||||
purchasedUpgradeIds: string[];
|
||||
runestonesIncomeMultiplier: number;
|
||||
runestonesClickMultiplier: number;
|
||||
runestonesEssenceMultiplier: number;
|
||||
runestonesCrystalMultiplier: number;
|
||||
}
|
||||
|
||||
export interface PublicProfileResponse {
|
||||
characterName: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
prestigeCount: number;
|
||||
totalGoldEarned: number;
|
||||
totalClicks: number;
|
||||
bossesDefeated: number;
|
||||
questsCompleted: number;
|
||||
adventurersRecruited: number;
|
||||
achievementsUnlocked: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
characterName: string;
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
}
|
||||
|
||||
export interface UpdateProfileResponse {
|
||||
characterName: string;
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface GiteaRelease {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface AboutResponse {
|
||||
apiVersion: string;
|
||||
releases: GiteaRelease[];
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { ProfileSettings };
|
||||
@@ -0,0 +1,28 @@
|
||||
export type BossStatus = "locked" | "available" | "in_progress" | "defeated";
|
||||
|
||||
export interface Boss {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: BossStatus;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
/** Damage dealt to adventurers per second whilst the fight is active */
|
||||
damagePerSecond: number;
|
||||
/** Gold reward on defeat */
|
||||
goldReward: number;
|
||||
/** Essence reward on defeat */
|
||||
essenceReward: number;
|
||||
/** Crystal reward on defeat */
|
||||
crystalReward: number;
|
||||
/** IDs of upgrades unlocked on defeat */
|
||||
upgradeRewards: string[];
|
||||
/** IDs of equipment items granted on defeat */
|
||||
equipmentRewards: string[];
|
||||
/** Minimum prestige level required to access this boss */
|
||||
prestigeRequirement: number;
|
||||
/** Zone this boss belongs to */
|
||||
zoneId: string;
|
||||
/** One-time runestone bounty awarded on first-ever defeat */
|
||||
bountyRunestones: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type DailyChallengeType = "clicks" | "bossesDefeated" | "questsCompleted" | "prestige";
|
||||
|
||||
export interface DailyChallenge {
|
||||
id: string;
|
||||
type: DailyChallengeType;
|
||||
label: string;
|
||||
target: number;
|
||||
progress: number;
|
||||
completed: boolean;
|
||||
rewardCrystals: number;
|
||||
}
|
||||
|
||||
export interface DailyChallengeState {
|
||||
/** ISO date string (e.g. "2026-03-06") used to detect when to reset */
|
||||
date: string;
|
||||
challenges: DailyChallenge[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type EquipmentType = "weapon" | "armour" | "trinket";
|
||||
|
||||
export type EquipmentRarity = "common" | "rare" | "epic" | "legendary";
|
||||
|
||||
export interface EquipmentBonus {
|
||||
/** Multiplier applied to all gold/s income (e.g. 1.1 = +10%) */
|
||||
goldMultiplier?: number;
|
||||
/** Multiplier applied to all combat power (e.g. 1.25 = +25%) */
|
||||
combatMultiplier?: number;
|
||||
/** Multiplier applied to click power (e.g. 1.5 = +50%) */
|
||||
clickMultiplier?: number;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: EquipmentType;
|
||||
rarity: EquipmentRarity;
|
||||
bonus: EquipmentBonus;
|
||||
/** Whether the player has acquired this item */
|
||||
owned: boolean;
|
||||
/** Whether this item is currently equipped (only one per type can be equipped) */
|
||||
equipped: boolean;
|
||||
/** If set, this item can be purchased directly rather than obtained via boss drops */
|
||||
cost?: { gold: number; essence: number; crystals: number };
|
||||
/** Equipment set this item belongs to, if any */
|
||||
setId?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface EquipmentSetBonus {
|
||||
goldMultiplier?: number;
|
||||
combatMultiplier?: number;
|
||||
clickMultiplier?: number;
|
||||
}
|
||||
|
||||
export interface EquipmentSet {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Equipment IDs that make up this set */
|
||||
pieces: string[];
|
||||
bonuses: {
|
||||
2: EquipmentSetBonus;
|
||||
3: EquipmentSetBonus;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of equipped item IDs and a set catalogue, returns the combined
|
||||
* multiplicative bonuses granted by all active set bonuses.
|
||||
*/
|
||||
export const computeSetBonuses = (
|
||||
equippedItemIds: string[],
|
||||
sets: EquipmentSet[],
|
||||
): { goldMultiplier: number; combatMultiplier: number; clickMultiplier: number } => {
|
||||
let goldMultiplier = 1;
|
||||
let combatMultiplier = 1;
|
||||
let clickMultiplier = 1;
|
||||
|
||||
for (const set of sets) {
|
||||
const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length;
|
||||
for (const threshold of [2, 3] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = set.bonuses[threshold];
|
||||
goldMultiplier *= bonus.goldMultiplier ?? 1;
|
||||
combatMultiplier *= bonus.combatMultiplier ?? 1;
|
||||
clickMultiplier *= bonus.clickMultiplier ?? 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { goldMultiplier, combatMultiplier, clickMultiplier };
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Achievement } from "./Achievement.js";
|
||||
import type { Adventurer } from "./Adventurer.js";
|
||||
import type { Boss } from "./Boss.js";
|
||||
import type { DailyChallengeState } from "./DailyChallenge.js";
|
||||
import type { Equipment } from "./Equipment.js";
|
||||
import type { Player } from "./Player.js";
|
||||
import type { PrestigeData } from "./Prestige.js";
|
||||
import type { Quest } from "./Quest.js";
|
||||
import type { Resource } from "./Resource.js";
|
||||
import type { Upgrade } from "./Upgrade.js";
|
||||
import type { Zone } from "./Zone.js";
|
||||
|
||||
export interface GameState {
|
||||
player: Player;
|
||||
resources: Resource;
|
||||
adventurers: Adventurer[];
|
||||
upgrades: Upgrade[];
|
||||
quests: Quest[];
|
||||
bosses: Boss[];
|
||||
equipment: Equipment[];
|
||||
achievements: Achievement[];
|
||||
prestige: PrestigeData;
|
||||
zones: Zone[];
|
||||
/** Click power (gold per click, before upgrades) */
|
||||
baseClickPower: number;
|
||||
/** Unix timestamp of the last client-side tick */
|
||||
lastTickAt: number;
|
||||
/** Daily challenge progress — optional for backwards compatibility with old saves */
|
||||
dailyChallenges?: DailyChallengeState;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Player {
|
||||
discordId: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar: string | null;
|
||||
/** Player's chosen in-game character name */
|
||||
characterName: string;
|
||||
/** Unix timestamp when the account was created */
|
||||
createdAt: number;
|
||||
/** Unix timestamp of the last server-side save */
|
||||
lastSavedAt: number;
|
||||
/** Total lifetime gold ever collected (for prestige eligibility) */
|
||||
totalGoldEarned: number;
|
||||
/** Total lifetime clicks */
|
||||
totalClicks: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface PrestigeData {
|
||||
/** Number of times the player has prestiged */
|
||||
count: number;
|
||||
/** Runestones carried over between prestiges */
|
||||
runestones: number;
|
||||
/** Multiplier applied to all production (based on prestige count) */
|
||||
productionMultiplier: number;
|
||||
/** IDs of prestige upgrades purchased with runestones */
|
||||
purchasedUpgradeIds: string[];
|
||||
/** Unix timestamp of last prestige */
|
||||
lastPrestigedAt?: number;
|
||||
/** Pre-computed multiplier from "income" runestone upgrades */
|
||||
runestonesIncomeMultiplier?: number;
|
||||
/** Pre-computed multiplier from "click" runestone upgrades */
|
||||
runestonesClickMultiplier?: number;
|
||||
/** Pre-computed multiplier from "essence" runestone upgrades */
|
||||
runestonesEssenceMultiplier?: number;
|
||||
/** Pre-computed multiplier from "crystals" runestone upgrades */
|
||||
runestonesCrystalMultiplier?: number;
|
||||
/** Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade) */
|
||||
autoPrestigeEnabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type PrestigeUpgradeCategory =
|
||||
| "income"
|
||||
| "click"
|
||||
| "essence"
|
||||
| "crystals"
|
||||
| "runestones"
|
||||
| "utility";
|
||||
|
||||
export interface PrestigeUpgrade {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: PrestigeUpgradeCategory;
|
||||
runestonesCost: number;
|
||||
/** Multiplier applied when this upgrade is purchased */
|
||||
multiplier: number;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type NumberFormat = "suffix" | "scientific" | "engineering";
|
||||
|
||||
export interface ProfileSettings {
|
||||
showTotalGold: boolean;
|
||||
showTotalClicks: boolean;
|
||||
showPrestige: boolean;
|
||||
showGuildFounded: boolean;
|
||||
showBossesDefeated: boolean;
|
||||
showQuestsCompleted: boolean;
|
||||
showAdventurersRecruited: boolean;
|
||||
showAchievementsUnlocked: boolean;
|
||||
numberFormat: NumberFormat;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||
showTotalGold: true,
|
||||
showTotalClicks: true,
|
||||
showPrestige: true,
|
||||
showGuildFounded: true,
|
||||
showBossesDefeated: true,
|
||||
showQuestsCompleted: true,
|
||||
showAdventurersRecruited: true,
|
||||
showAchievementsUnlocked: true,
|
||||
numberFormat: "suffix",
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
export type QuestStatus = "locked" | "available" | "active" | "completed";
|
||||
|
||||
export type QuestRewardType = "gold" | "essence" | "crystals" | "upgrade" | "adventurer" | "equipment";
|
||||
|
||||
export interface QuestReward {
|
||||
type: QuestRewardType;
|
||||
amount?: number;
|
||||
/** ID of the upgrade or adventurer to unlock (if applicable) */
|
||||
targetId?: string;
|
||||
}
|
||||
|
||||
export interface Quest {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: QuestStatus;
|
||||
/** Unix timestamp when quest was started (if active) */
|
||||
startedAt?: number;
|
||||
/** Duration in seconds */
|
||||
durationSeconds: number;
|
||||
rewards: QuestReward[];
|
||||
/** IDs of quests that must be completed before this one unlocks */
|
||||
prerequisiteIds: string[];
|
||||
/** Zone this quest belongs to */
|
||||
zoneId: string;
|
||||
/** Minimum party combat power required to start this quest */
|
||||
combatPowerRequired?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Resource {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
runestones: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type UpgradeTarget =
|
||||
| "click"
|
||||
| "adventurer"
|
||||
| "global"
|
||||
| "prestige"
|
||||
| "boss";
|
||||
|
||||
export interface Upgrade {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target: UpgradeTarget;
|
||||
/** ID of the adventurer this applies to (if target is "adventurer") */
|
||||
adventurerId?: string;
|
||||
/** Multiplier applied to the target's output */
|
||||
multiplier: number;
|
||||
costGold: number;
|
||||
costEssence: number;
|
||||
costCrystals: number;
|
||||
purchased: boolean;
|
||||
unlocked: boolean;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type ZoneStatus = "locked" | "unlocked";
|
||||
|
||||
export interface Zone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: ZoneStatus;
|
||||
/** Boss ID whose defeat is required to unlock this zone (null for the starter zone) */
|
||||
unlockBossId: string | null;
|
||||
/** Quest ID that must be completed to unlock this zone (null for the starter zone) */
|
||||
unlockQuestId: string | null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": ".",
|
||||
"declaration": true
|
||||
},
|
||||
"exclude": ["test/**/*.ts", "prod/**"]
|
||||
}
|
||||
Generated
+5682
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": "."
|
||||
},
|
||||
"exclude": ["test/**/*.ts", "apps/**", "packages/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user