generated from nhcarrigan/template
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e341db56af
|
|||
| 2bc47b79aa | |||
| 3afe64e48a | |||
| e7164257c5 | |||
| 1195b657a0 | |||
| b0227c1709 | |||
|
de5570b5fc
|
|||
|
133c81fefe
|
|||
|
1408e067b7
|
|||
| 666a5b2d6d | |||
|
9926e7f639
|
|||
| 6bf1ac5e7d | |||
| b48beef474 | |||
| 6e573bea14 | |||
|
790d35420f
|
|||
|
9f9edae45e
|
|||
|
a7a255dab6
|
|||
|
e92cf3c9a1
|
|||
|
26d30c271d
|
|||
| 34d07bec95 | |||
| 3ac1d566cb | |||
|
7bd6b2d3e3
|
|||
| 354b7e372e | |||
| dc1782bec9 | |||
| 635c630e49 | |||
| bb60ae3390 | |||
| ee47c1e8c9 | |||
| 2236d1dc9f | |||
|
621f594018
|
|||
| 1e845b14ce | |||
| 81ae1f18e1 | |||
| 0057cfeaaa | |||
| 161127dc21 | |||
| a8a465f293 | |||
| 79c4b99e8a | |||
| 3d114f63d7 | |||
| 911e089a9e | |||
| 14de87d765 | |||
| c4b4fba4c9 | |||
| d723656743 | |||
| 7e10757e68 | |||
| ca2edb090e | |||
| cfcf763ce3 | |||
| aede55a13d | |||
| 744cbf121f | |||
| 03b6c847b3 | |||
| 219d299e9f | |||
| 9e5b8ed972 | |||
|
a20cf3ef87
|
|||
| 9860a2cb1f | |||
| 404b31bd13 | |||
| d0790890ee | |||
| 4d7e624358 | |||
| ac94f67797 | |||
| a36c8e72a5 | |||
| 11e97325cb | |||
| 7a1c57be9a | |||
|
b604a4aa5c
|
|||
|
e10eabc8b5
|
|||
|
c3d79e0c11
|
|||
|
6e2cb45553
|
|||
|
5a065998b6
|
|||
|
f9c925b9fc
|
|||
|
290c06de83
|
@@ -7,6 +7,41 @@
|
|||||||
2. `pnpm build` — all packages build cleanly
|
2. `pnpm build` — all packages build cleanly
|
||||||
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
||||||
|
|
||||||
|
## Art Assets
|
||||||
|
|
||||||
|
Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/image at 1K resolution) and hosted on the CDN at `https://cdn.nhcarrigan.com/elysium/`.
|
||||||
|
|
||||||
|
### Process
|
||||||
|
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
|
||||||
|
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
|
||||||
|
3. Upload to R2 with the AWS CLI — credentials are in the global `~/.claude/CLAUDE.md` (never commit them here)
|
||||||
|
4. Delete the local `img/` directory before committing (images live on CDN only)
|
||||||
|
|
||||||
|
### CDN URL Helper
|
||||||
|
`apps/web/src/utils/cdn.ts` exports `cdnImage(folder, id)` → `https://cdn.nhcarrigan.com/elysium/<folder>/<id>.jpg`
|
||||||
|
|
||||||
|
### Directory → Category Mapping
|
||||||
|
| Game entity | CDN folder |
|
||||||
|
|---|---|
|
||||||
|
| Zones | `zones` |
|
||||||
|
| Bosses | `bosses` |
|
||||||
|
| Quests | `quests` |
|
||||||
|
| Adventurers | `adventurers` |
|
||||||
|
| Companions | `companions` |
|
||||||
|
| Equipment | `equipment` |
|
||||||
|
| Upgrades | `upgrades` |
|
||||||
|
| Prestige upgrades | `prestige-upgrades` |
|
||||||
|
| Transcendence upgrades | `transcendence-upgrades` |
|
||||||
|
| Achievements | `achievements` |
|
||||||
|
| Explorations | `explorations` |
|
||||||
|
| Materials | `materials` |
|
||||||
|
| Recipes | `recipes` |
|
||||||
|
| Story chapter banners | `story-chapters` |
|
||||||
|
|
||||||
|
### API Rate Limits
|
||||||
|
- 250 images/day per API key — use a second key if quota is hit
|
||||||
|
- Free-tier keys cannot use `gemini-3-pro-image-preview`; key must be on a billing-linked project
|
||||||
|
|
||||||
## About Page
|
## About Page
|
||||||
|
|
||||||
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
@@ -14,18 +14,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"@hono/node-server": "1.13.7",
|
"@hono/node-server": "1.19.12",
|
||||||
|
"@nhcarrigan/logger": "1.1.1",
|
||||||
"@prisma/client": "6.5.0",
|
"@prisma/client": "6.5.0",
|
||||||
"hono": "4.7.4",
|
"hono": "4.12.11",
|
||||||
"prisma": "6.5.0"
|
"prisma": "6.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhcarrigan/eslint-config": "5.2.0",
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
"@nhcarrigan/typescript-config": "4.0.0",
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.5.2",
|
||||||
"@vitest/coverage-v8": "3.0.8",
|
"@vitest/coverage-v8": "3.0.8",
|
||||||
"eslint": "9.22.0",
|
"eslint": "9.22.0",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"vitest": "3.0.8"
|
"vitest": "3.0.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ model Player {
|
|||||||
lifetimeAchievementsUnlocked Float @default(0)
|
lifetimeAchievementsUnlocked Float @default(0)
|
||||||
lastLoginDate String?
|
lastLoginDate String?
|
||||||
loginStreak Int @default(1)
|
loginStreak Int @default(1)
|
||||||
|
inGuild Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model GameState {
|
model GameState {
|
||||||
|
|||||||
+1
-4
@@ -1,6 +1,4 @@
|
|||||||
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
|
|
||||||
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
||||||
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
|
|
||||||
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
||||||
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
||||||
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
||||||
@@ -8,5 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
|
|||||||
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
||||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||||
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
|
||||||
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 18, type: "bossesDefeated" },
|
condition: { amount: 18, type: "bossesDefeated" },
|
||||||
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
|
description: "Defeat the 18 bosses of the mortal realms.",
|
||||||
icon: "🌟",
|
icon: "🌟",
|
||||||
id: "devourer_slayer",
|
id: "devourer_slayer",
|
||||||
name: "World Saver",
|
name: "World Saver",
|
||||||
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 40, type: "equipmentOwned" },
|
condition: { amount: 78, type: "equipmentOwned" },
|
||||||
description: "Own 40 pieces of equipment.",
|
description: "Own all 78 pieces of equipment.",
|
||||||
icon: "🛡️",
|
icon: "🛡️",
|
||||||
id: "fully_equipped",
|
id: "fully_equipped",
|
||||||
name: "Fully Equipped",
|
name: "Fully Equipped",
|
||||||
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
icon: "☄️",
|
icon: "☄️",
|
||||||
id: "click_deity",
|
id: "click_deity",
|
||||||
name: "Click Deity",
|
name: "Click Deity",
|
||||||
reward: { crystals: 5000 },
|
reward: { crystals: 15_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
// Endgame gold milestones
|
// Endgame gold milestones
|
||||||
@@ -269,6 +269,33 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
reward: { crystals: 50_000 },
|
reward: { crystals: 50_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 1e30, type: "totalGoldEarned" },
|
||||||
|
description: "Earn 1 nonillion gold in total.",
|
||||||
|
icon: "🌌",
|
||||||
|
id: "cosmic_wealthy",
|
||||||
|
name: "Cosmic Wealthy",
|
||||||
|
reward: { crystals: 100_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 1e60, type: "totalGoldEarned" },
|
||||||
|
description: "Earn a vigintillion gold in total.",
|
||||||
|
icon: "♾️",
|
||||||
|
id: "infinite_hoarder",
|
||||||
|
name: "Infinite Hoarder",
|
||||||
|
reward: { crystals: 250_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 1e90, type: "totalGoldEarned" },
|
||||||
|
description: "Earn a trigintillion gold in total.",
|
||||||
|
icon: "🔮",
|
||||||
|
id: "omniversal_tycoon",
|
||||||
|
name: "Omniversal Tycoon",
|
||||||
|
reward: { crystals: 1_000_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
// Higher quest milestones
|
// Higher quest milestones
|
||||||
{
|
{
|
||||||
condition: { amount: 30, type: "questsCompleted" },
|
condition: { amount: 30, type: "questsCompleted" },
|
||||||
@@ -289,8 +316,26 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 72, type: "questsCompleted" },
|
condition: { amount: 75, type: "questsCompleted" },
|
||||||
description: "Complete all 72 quests across the known multiverse.",
|
description: "Complete 75 quests.",
|
||||||
|
icon: "🌠",
|
||||||
|
id: "quest_hero",
|
||||||
|
name: "Quest Hero",
|
||||||
|
reward: { crystals: 10_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 100, type: "questsCompleted" },
|
||||||
|
description: "Complete 100 quests.",
|
||||||
|
icon: "💫",
|
||||||
|
id: "quest_legend",
|
||||||
|
name: "Quest Legend",
|
||||||
|
reward: { crystals: 15_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 122, type: "questsCompleted" },
|
||||||
|
description: "Complete all 122 quests across the known multiverse.",
|
||||||
icon: "🌌",
|
icon: "🌌",
|
||||||
id: "quest_eternal",
|
id: "quest_eternal",
|
||||||
name: "Quest Eternal",
|
name: "Quest Eternal",
|
||||||
@@ -317,8 +362,17 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
condition: { amount: 60, type: "bossesDefeated" },
|
condition: { amount: 50, type: "bossesDefeated" },
|
||||||
description: "Defeat all 60 bosses across every plane of existence.",
|
description: "Defeat 50 bosses.",
|
||||||
|
icon: "⚡",
|
||||||
|
id: "boss_legend",
|
||||||
|
name: "Legendary Vanquisher",
|
||||||
|
reward: { crystals: 15_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 72, type: "bossesDefeated" },
|
||||||
|
description: "Defeat all 72 bosses across every plane of existence.",
|
||||||
icon: "💀",
|
icon: "💀",
|
||||||
id: "boss_eternal",
|
id: "boss_eternal",
|
||||||
name: "Eternal Vanquisher",
|
name: "Eternal Vanquisher",
|
||||||
@@ -351,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
icon: "💫",
|
icon: "💫",
|
||||||
id: "prestige_master",
|
id: "prestige_master",
|
||||||
name: "Master of Cycles",
|
name: "Master of Cycles",
|
||||||
reward: { crystals: 5000 },
|
reward: { crystals: 15_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -360,7 +414,43 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
icon: "🌠",
|
icon: "🌠",
|
||||||
id: "prestige_legend",
|
id: "prestige_legend",
|
||||||
name: "Legend of Eternity",
|
name: "Legend of Eternity",
|
||||||
reward: { crystals: 25_000 },
|
reward: { crystals: 75_000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 50, type: "prestigeCount" },
|
||||||
|
description: "Prestige 50 times.",
|
||||||
|
icon: "✨",
|
||||||
|
id: "prestige_transcendent",
|
||||||
|
name: "Transcendent",
|
||||||
|
reward: { runestones: 100 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 100, type: "prestigeCount" },
|
||||||
|
description: "Prestige 100 times.",
|
||||||
|
icon: "💎",
|
||||||
|
id: "prestige_eternal",
|
||||||
|
name: "Eternal Looper",
|
||||||
|
reward: { runestones: 500 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 150, type: "prestigeCount" },
|
||||||
|
description: "Prestige 150 times.",
|
||||||
|
icon: "🌟",
|
||||||
|
id: "prestige_immortal",
|
||||||
|
name: "Immortal Cycler",
|
||||||
|
reward: { runestones: 2000 },
|
||||||
|
unlockedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: { amount: 200, type: "prestigeCount" },
|
||||||
|
description: "Prestige 200 times.",
|
||||||
|
icon: "👑",
|
||||||
|
id: "prestige_absolute",
|
||||||
|
name: "Absolute Champion",
|
||||||
|
reward: { runestones: 10_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
unlocked: true,
|
unlocked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 100,
|
baseCost: 65,
|
||||||
class: "warrior",
|
class: "warrior",
|
||||||
combatPower: 3,
|
combatPower: 3,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 0,
|
essencePerSecond: 0,
|
||||||
goldPerSecond: 0.5,
|
goldPerSecond: 0.7,
|
||||||
id: "militia",
|
id: "militia",
|
||||||
level: 2,
|
level: 2,
|
||||||
name: "Militia",
|
name: "Militia",
|
||||||
@@ -129,50 +129,62 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 4_000_000_000,
|
baseCost: 2_850_000_000,
|
||||||
class: "rogue",
|
class: "mage",
|
||||||
combatPower: 18_000,
|
combatPower: 13_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 6,
|
essencePerSecond: 6,
|
||||||
goldPerSecond: 5000,
|
goldPerSecond: 4500,
|
||||||
id: "shadow_assassin",
|
|
||||||
level: 11,
|
|
||||||
name: "Shadow Assassin",
|
|
||||||
unlocked: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
baseCost: 28_000_000_000,
|
|
||||||
class: "mage",
|
|
||||||
combatPower: 45_000,
|
|
||||||
count: 0,
|
|
||||||
essencePerSecond: 15,
|
|
||||||
goldPerSecond: 14_000,
|
|
||||||
id: "arcane_scholar",
|
id: "arcane_scholar",
|
||||||
level: 12,
|
level: 11,
|
||||||
name: "Arcane Scholar",
|
name: "Arcane Scholar",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 200_000_000_000,
|
baseCost: 13_500_000_000,
|
||||||
|
class: "rogue",
|
||||||
|
combatPower: 28_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 11,
|
||||||
|
goldPerSecond: 9500,
|
||||||
|
id: "shadow_assassin",
|
||||||
|
level: 12,
|
||||||
|
name: "Shadow Assassin",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseCost: 64_000_000_000,
|
||||||
|
class: "paladin",
|
||||||
|
combatPower: 60_000,
|
||||||
|
count: 0,
|
||||||
|
essencePerSecond: 20,
|
||||||
|
goldPerSecond: 20_000,
|
||||||
|
id: "dark_templar",
|
||||||
|
level: 13,
|
||||||
|
name: "Dark Templar",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseCost: 300_000_000_000,
|
||||||
class: "rogue",
|
class: "rogue",
|
||||||
combatPower: 130_000,
|
combatPower: 130_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 35,
|
essencePerSecond: 35,
|
||||||
goldPerSecond: 40_000,
|
goldPerSecond: 40_000,
|
||||||
id: "void_walker",
|
id: "void_walker",
|
||||||
level: 13,
|
level: 14,
|
||||||
name: "Void Walker",
|
name: "Void Walker",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 1_400_000_000_000,
|
baseCost: 1_800_000_000_000,
|
||||||
class: "paladin",
|
class: "paladin",
|
||||||
combatPower: 400_000,
|
combatPower: 400_000,
|
||||||
count: 0,
|
count: 0,
|
||||||
essencePerSecond: 100,
|
essencePerSecond: 100,
|
||||||
goldPerSecond: 120_000,
|
goldPerSecond: 120_000,
|
||||||
id: "celestial_guard",
|
id: "celestial_guard",
|
||||||
level: 14,
|
level: 15,
|
||||||
name: "Celestial Guard",
|
name: "Celestial Guard",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300,
|
essencePerSecond: 300,
|
||||||
goldPerSecond: 400_000,
|
goldPerSecond: 400_000,
|
||||||
id: "divine_champion",
|
id: "divine_champion",
|
||||||
level: 15,
|
level: 16,
|
||||||
name: "Divine Champion",
|
name: "Divine Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 800,
|
essencePerSecond: 800,
|
||||||
goldPerSecond: 1_200_000,
|
goldPerSecond: 1_200_000,
|
||||||
id: "seraph_knight",
|
id: "seraph_knight",
|
||||||
level: 16,
|
level: 17,
|
||||||
name: "Seraph Knight",
|
name: "Seraph Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2000,
|
essencePerSecond: 2000,
|
||||||
goldPerSecond: 3_500_000,
|
goldPerSecond: 3_500_000,
|
||||||
id: "abyss_diver",
|
id: "abyss_diver",
|
||||||
level: 17,
|
level: 18,
|
||||||
name: "Abyss Diver",
|
name: "Abyss Diver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 5000,
|
essencePerSecond: 5000,
|
||||||
goldPerSecond: 10_000_000,
|
goldPerSecond: 10_000_000,
|
||||||
id: "infernal_warden",
|
id: "infernal_warden",
|
||||||
level: 18,
|
level: 19,
|
||||||
name: "Infernal Warden",
|
name: "Infernal Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000,
|
essencePerSecond: 12_000,
|
||||||
goldPerSecond: 30_000_000,
|
goldPerSecond: 30_000_000,
|
||||||
id: "crystal_sage",
|
id: "crystal_sage",
|
||||||
level: 19,
|
level: 20,
|
||||||
name: "Crystal Sage",
|
name: "Crystal Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 30_000,
|
essencePerSecond: 30_000,
|
||||||
goldPerSecond: 90_000_000,
|
goldPerSecond: 90_000_000,
|
||||||
id: "void_sentinel",
|
id: "void_sentinel",
|
||||||
level: 20,
|
level: 21,
|
||||||
name: "Void Sentinel",
|
name: "Void Sentinel",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 80_000,
|
essencePerSecond: 80_000,
|
||||||
goldPerSecond: 270_000_000,
|
goldPerSecond: 270_000_000,
|
||||||
id: "eternal_champion",
|
id: "eternal_champion",
|
||||||
level: 21,
|
level: 22,
|
||||||
name: "Eternal Champion",
|
name: "Eternal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 220_000,
|
essencePerSecond: 220_000,
|
||||||
goldPerSecond: 800_000_000,
|
goldPerSecond: 800_000_000,
|
||||||
id: "aether_weaver",
|
id: "aether_weaver",
|
||||||
level: 22,
|
level: 23,
|
||||||
name: "Aether Weaver",
|
name: "Aether Weaver",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 600_000,
|
essencePerSecond: 600_000,
|
||||||
goldPerSecond: 2_500_000_000,
|
goldPerSecond: 2_500_000_000,
|
||||||
id: "titan_warrior",
|
id: "titan_warrior",
|
||||||
level: 23,
|
level: 24,
|
||||||
name: "Titan Warrior",
|
name: "Titan Warrior",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 1_600_000,
|
essencePerSecond: 1_600_000,
|
||||||
goldPerSecond: 7_500_000_000,
|
goldPerSecond: 7_500_000_000,
|
||||||
id: "nexus_sage",
|
id: "nexus_sage",
|
||||||
level: 24,
|
level: 25,
|
||||||
name: "Nexus Sage",
|
name: "Nexus Sage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 4_500_000,
|
essencePerSecond: 4_500_000,
|
||||||
goldPerSecond: 22_000_000_000,
|
goldPerSecond: 22_000_000_000,
|
||||||
id: "cosmos_knight",
|
id: "cosmos_knight",
|
||||||
level: 25,
|
level: 26,
|
||||||
name: "Cosmos Knight",
|
name: "Cosmos Knight",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 12_000_000,
|
essencePerSecond: 12_000_000,
|
||||||
goldPerSecond: 65_000_000_000,
|
goldPerSecond: 65_000_000_000,
|
||||||
id: "astral_sovereign",
|
id: "astral_sovereign",
|
||||||
level: 26,
|
level: 27,
|
||||||
name: "Astral Sovereign",
|
name: "Astral Sovereign",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 35_000_000,
|
essencePerSecond: 35_000_000,
|
||||||
goldPerSecond: 200_000_000_000,
|
goldPerSecond: 200_000_000_000,
|
||||||
id: "primordial_mage",
|
id: "primordial_mage",
|
||||||
level: 27,
|
level: 28,
|
||||||
name: "Primordial Mage",
|
name: "Primordial Mage",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 100_000_000,
|
essencePerSecond: 100_000_000,
|
||||||
goldPerSecond: 600_000_000_000,
|
goldPerSecond: 600_000_000_000,
|
||||||
id: "reality_warden",
|
id: "reality_warden",
|
||||||
level: 28,
|
level: 29,
|
||||||
name: "Reality Warden",
|
name: "Reality Warden",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 300_000_000,
|
essencePerSecond: 300_000_000,
|
||||||
goldPerSecond: 1_800_000_000_000,
|
goldPerSecond: 1_800_000_000_000,
|
||||||
id: "infinity_ranger",
|
id: "infinity_ranger",
|
||||||
level: 29,
|
level: 30,
|
||||||
name: "Infinity Ranger",
|
name: "Infinity Ranger",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 850_000_000,
|
essencePerSecond: 850_000_000,
|
||||||
goldPerSecond: 5_500_000_000_000,
|
goldPerSecond: 5_500_000_000_000,
|
||||||
id: "oblivion_paladin",
|
id: "oblivion_paladin",
|
||||||
level: 30,
|
level: 31,
|
||||||
name: "Oblivion Paladin",
|
name: "Oblivion Paladin",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 2_500_000_000,
|
essencePerSecond: 2_500_000_000,
|
||||||
goldPerSecond: 16_000_000_000_000,
|
goldPerSecond: 16_000_000_000_000,
|
||||||
id: "transcendent_rogue",
|
id: "transcendent_rogue",
|
||||||
level: 31,
|
level: 32,
|
||||||
name: "Transcendent Rogue",
|
name: "Transcendent Rogue",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
essencePerSecond: 7_000_000_000,
|
essencePerSecond: 7_000_000_000,
|
||||||
goldPerSecond: 50_000_000_000_000,
|
goldPerSecond: 50_000_000_000_000,
|
||||||
id: "omniversal_champion",
|
id: "omniversal_champion",
|
||||||
level: 32,
|
level: 33,
|
||||||
name: "Omniversal Champion",
|
name: "Omniversal Champion",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+172
-172
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
|
|||||||
target: 5000,
|
target: 5000,
|
||||||
type: "clicks",
|
type: "clicks",
|
||||||
},
|
},
|
||||||
|
// Crafting — requires materials but no zone/boss progression
|
||||||
|
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
|
||||||
|
{
|
||||||
|
label: "Craft 2 recipes",
|
||||||
|
rewardCrystals: 175,
|
||||||
|
target: 2,
|
||||||
|
type: "crafting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Craft 3 recipes",
|
||||||
|
rewardCrystals: 350,
|
||||||
|
target: 3,
|
||||||
|
type: "crafting",
|
||||||
|
},
|
||||||
// Boss defeats — requires active combat
|
// Boss defeats — requires active combat
|
||||||
{
|
{
|
||||||
label: "Defeat 1 boss",
|
label: "Defeat 1 boss",
|
||||||
|
|||||||
+174
-12
@@ -101,7 +101,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "weapon",
|
type: "weapon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 2.75 },
|
bonus: { combatMultiplier: 3.25 },
|
||||||
cost: { crystals: 500, essence: 2000, gold: 0 },
|
cost: { crystals: 500, essence: 2000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
|
||||||
@@ -204,7 +204,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 2.25 },
|
bonus: { goldMultiplier: 2.75 },
|
||||||
description:
|
description:
|
||||||
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 },
|
bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
|
||||||
description:
|
description:
|
||||||
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -305,9 +305,9 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2, goldMultiplier: 1.25 },
|
bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 },
|
||||||
description:
|
description:
|
||||||
"The legendary stone that grants mastery over gold and combat alike.",
|
"The legendary stone that transmutes effort into wealth — every action fills the coffers.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
id: "philosophers_stone",
|
id: "philosophers_stone",
|
||||||
name: "Philosopher's Stone",
|
name: "Philosopher's Stone",
|
||||||
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 },
|
bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
|
||||||
description:
|
description:
|
||||||
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
@@ -695,9 +695,171 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
setId: "eternal_throne",
|
setId: "eternal_throne",
|
||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
|
// ── Primordial Chaos ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { goldMultiplier: 9 },
|
||||||
|
description:
|
||||||
|
"The Primordial Titan's carapace — formed before the concept of armour existed. It simply is what armour aspires to be.",
|
||||||
|
equipped: false,
|
||||||
|
id: "chaos_mantle",
|
||||||
|
name: "The Chaos Mantle",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "primordial_chaos",
|
||||||
|
type: "armour",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { clickMultiplier: 5, combatMultiplier: 2, goldMultiplier: 2.5 },
|
||||||
|
description:
|
||||||
|
"The crystallised core of the Titan itself — the first stable thing to emerge from chaos. It radiates in every direction simultaneously.",
|
||||||
|
equipped: false,
|
||||||
|
id: "titan_core",
|
||||||
|
name: "The Titan Core",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "primordial_chaos",
|
||||||
|
type: "trinket",
|
||||||
|
},
|
||||||
|
// ── Infinite Expanse ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { combatMultiplier: 14 },
|
||||||
|
description:
|
||||||
|
"Forged from the Expanse Sovereign's own reach — a blade that has no beginning and no end, only edge.",
|
||||||
|
equipped: false,
|
||||||
|
id: "expanse_blade",
|
||||||
|
name: "The Expanse Blade",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "infinite_expanse",
|
||||||
|
type: "weapon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { goldMultiplier: 10 },
|
||||||
|
description:
|
||||||
|
"A second iteration of the void's armour — the first was not enough. This one has never been tested to its limit.",
|
||||||
|
equipped: false,
|
||||||
|
id: "void_armour_mk2",
|
||||||
|
name: "Void Armour Mk. II",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "infinite_expanse",
|
||||||
|
type: "armour",
|
||||||
|
},
|
||||||
|
// ── Reality Forge ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { combatMultiplier: 16 },
|
||||||
|
description:
|
||||||
|
"The Reality Architect's primary instrument — a sword that does not cut through things but rewrites what they are.",
|
||||||
|
equipped: false,
|
||||||
|
id: "cosmos_blade",
|
||||||
|
name: "The Cosmos Blade",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "reality_forge",
|
||||||
|
type: "weapon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { goldMultiplier: 12 },
|
||||||
|
description:
|
||||||
|
"Plated from the substance of reality itself — wearing it makes you feel slightly more real than everything around you.",
|
||||||
|
equipped: false,
|
||||||
|
id: "reality_plate",
|
||||||
|
name: "The Reality Plate",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "reality_forge",
|
||||||
|
type: "armour",
|
||||||
|
},
|
||||||
|
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { combatMultiplier: 18 },
|
||||||
|
description:
|
||||||
|
"Torn from the eye of the Cosmic Annihilator — a weapon that carries the force of an ending universe in every swing.",
|
||||||
|
equipped: false,
|
||||||
|
id: "maelstrom_edge",
|
||||||
|
name: "The Maelstrom Edge",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "cosmic_maelstrom",
|
||||||
|
type: "weapon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { goldMultiplier: 14 },
|
||||||
|
description:
|
||||||
|
"Armour that has weathered the destruction of countless realities. It has learned not to flinch.",
|
||||||
|
equipped: false,
|
||||||
|
id: "cosmic_plate",
|
||||||
|
name: "The Cosmic Plate",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "cosmic_maelstrom",
|
||||||
|
type: "armour",
|
||||||
|
},
|
||||||
|
// ── Primeval Sanctum ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { combatMultiplier: 22 },
|
||||||
|
description:
|
||||||
|
"The first weapon — older than the concept of war, older than the concept of a weapon. It remembers what it was made for.",
|
||||||
|
equipped: false,
|
||||||
|
id: "primeval_blade",
|
||||||
|
name: "The Primeval Blade",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "primeval_sanctum",
|
||||||
|
type: "weapon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { goldMultiplier: 17 },
|
||||||
|
description:
|
||||||
|
"The shield-form of the Primeval God — absolute protection from before the concept of harm existed.",
|
||||||
|
equipped: false,
|
||||||
|
id: "ancient_aegis",
|
||||||
|
name: "The Ancient Aegis",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "primeval_sanctum",
|
||||||
|
type: "armour",
|
||||||
|
},
|
||||||
|
// ── The Absolute ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { combatMultiplier: 28 },
|
||||||
|
description:
|
||||||
|
"There is no name for what this was before it became a sword. There is no name for what it is now. It ends things.",
|
||||||
|
equipped: false,
|
||||||
|
id: "absolute_blade",
|
||||||
|
name: "The Absolute Blade",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "the_absolute",
|
||||||
|
type: "weapon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { goldMultiplier: 20 },
|
||||||
|
description:
|
||||||
|
"Eternity given the shape of armour — it has always existed, it will always exist, and it has always protected its wearer.",
|
||||||
|
equipped: false,
|
||||||
|
id: "eternity_plate",
|
||||||
|
name: "The Eternity Plate",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "the_absolute",
|
||||||
|
type: "armour",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { clickMultiplier: 6, combatMultiplier: 3, goldMultiplier: 3 },
|
||||||
|
description:
|
||||||
|
"The heart of everything — a thing so fundamental that its removal from the Absolute One ended all things, briefly. Briefly.",
|
||||||
|
equipped: false,
|
||||||
|
id: "omniversal_core",
|
||||||
|
name: "The Omniversal Core",
|
||||||
|
owned: false,
|
||||||
|
rarity: "legendary",
|
||||||
|
setId: "the_absolute",
|
||||||
|
type: "trinket",
|
||||||
|
},
|
||||||
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
// ── Purchasable endgame sinks ─────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 2.5 },
|
bonus: { clickMultiplier: 4.25 },
|
||||||
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
"A lens of compressed celestial light that sharpens every strike with divine precision.",
|
||||||
@@ -709,7 +871,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 3 },
|
bonus: { goldMultiplier: 3.75 },
|
||||||
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
|
||||||
@@ -721,7 +883,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { combatMultiplier: 4 },
|
bonus: { combatMultiplier: 10.5 },
|
||||||
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
|
||||||
@@ -733,7 +895,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "weapon",
|
type: "weapon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
|
bonus: { clickMultiplier: 4, goldMultiplier: 1.5 },
|
||||||
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
|
||||||
@@ -745,7 +907,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { goldMultiplier: 4 },
|
bonus: { goldMultiplier: 7.5 },
|
||||||
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
|
||||||
@@ -757,7 +919,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "armour",
|
type: "armour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 },
|
bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 },
|
||||||
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
|
||||||
description:
|
description:
|
||||||
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
|
||||||
|
|||||||
+144
-144
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,8 @@ const initialGameState = (
|
|||||||
achievements: structuredClone(defaultAchievements),
|
achievements: structuredClone(defaultAchievements),
|
||||||
adventurers: structuredClone(defaultAdventurers),
|
adventurers: structuredClone(defaultAdventurers),
|
||||||
apotheosis: { ...initialApotheosis },
|
apotheosis: { ...initialApotheosis },
|
||||||
|
autoBoss: false,
|
||||||
|
autoQuest: false,
|
||||||
baseClickPower: 1,
|
baseClickPower: 1,
|
||||||
bosses: structuredClone(defaultBosses),
|
bosses: structuredClone(defaultBosses),
|
||||||
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
||||||
|
|||||||
@@ -92,20 +92,20 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
description:
|
description:
|
||||||
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
|
"The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
|
||||||
id: "income_10",
|
id: "income_10",
|
||||||
multiplier: 500,
|
multiplier: 200,
|
||||||
name: "Eternal Rune I",
|
name: "Eternal Rune I",
|
||||||
runestonesCost: 30_000,
|
runestonesCost: 15_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
description:
|
description:
|
||||||
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
|
"Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
|
||||||
id: "income_11",
|
id: "income_11",
|
||||||
multiplier: 1000,
|
multiplier: 500,
|
||||||
name: "Eternal Rune II",
|
name: "Eternal Rune II",
|
||||||
runestonesCost: 80_000,
|
runestonesCost: 35_000,
|
||||||
},
|
},
|
||||||
// ── Click Power ───────────────────────────────────────────────────────────
|
// ── Click Power ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
@@ -210,6 +210,15 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
runestonesCost: 1200,
|
runestonesCost: 1200,
|
||||||
},
|
},
|
||||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
category: "utility",
|
||||||
|
description:
|
||||||
|
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
|
||||||
|
id: "auto_adventurer",
|
||||||
|
multiplier: 1,
|
||||||
|
name: "Autonomous Recruitment",
|
||||||
|
runestonesCost: 50,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "utility",
|
category: "utility",
|
||||||
description:
|
description:
|
||||||
|
|||||||
+608
-253
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.08 },
|
bonus: { type: "combat_power", value: 1.2 },
|
||||||
description:
|
description:
|
||||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
||||||
id: "elder_bark_shield",
|
id: "elder_bark_shield",
|
||||||
@@ -75,7 +75,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "gold_income", value: 1.1 },
|
bonus: { type: "gold_income", value: 1.15 },
|
||||||
description:
|
description:
|
||||||
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
|
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
|
||||||
id: "void_fragment_amulet",
|
id: "void_fragment_amulet",
|
||||||
@@ -101,7 +101,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.1 },
|
bonus: { type: "combat_power", value: 1.15 },
|
||||||
description:
|
description:
|
||||||
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
||||||
id: "cursed_focus",
|
id: "cursed_focus",
|
||||||
@@ -127,7 +127,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.12 },
|
bonus: { type: "combat_power", value: 1.2 },
|
||||||
description:
|
description:
|
||||||
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
||||||
id: "elemental_ore_ingot",
|
id: "elemental_ore_ingot",
|
||||||
@@ -193,7 +193,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 8: abyssal_trench
|
// Zone 8: abyssal_trench
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.15 },
|
bonus: { type: "combat_power", value: 1.25 },
|
||||||
description:
|
description:
|
||||||
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
||||||
id: "pressure_forged_core",
|
id: "pressure_forged_core",
|
||||||
@@ -231,7 +231,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "essence_income", value: 1.15 },
|
bonus: { type: "essence_income", value: 1.2 },
|
||||||
description:
|
description:
|
||||||
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
|
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
|
||||||
id: "soul_bound_catalyst",
|
id: "soul_bound_catalyst",
|
||||||
@@ -271,7 +271,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 11: void_sanctum
|
// Zone 11: void_sanctum
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.18 },
|
bonus: { type: "combat_power", value: 1.28 },
|
||||||
description:
|
description:
|
||||||
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
||||||
id: "null_field_generator",
|
id: "null_field_generator",
|
||||||
@@ -309,7 +309,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.2 },
|
bonus: { type: "combat_power", value: 1.3 },
|
||||||
description:
|
description:
|
||||||
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
||||||
id: "eternity_bound_ring",
|
id: "eternity_bound_ring",
|
||||||
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 13: primordial_chaos
|
// Zone 13: primordial_chaos
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.2 },
|
bonus: { type: "click_power", value: 1.22 },
|
||||||
description:
|
description:
|
||||||
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
||||||
id: "chaos_lens",
|
id: "chaos_lens",
|
||||||
@@ -375,7 +375,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 15: reality_forge
|
// Zone 15: reality_forge
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.22 },
|
bonus: { type: "combat_power", value: 1.35 },
|
||||||
description:
|
description:
|
||||||
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
||||||
id: "reality_ingot",
|
id: "reality_ingot",
|
||||||
@@ -387,7 +387,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.22 },
|
bonus: { type: "click_power", value: 1.25 },
|
||||||
description:
|
description:
|
||||||
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
||||||
id: "universe_seed",
|
id: "universe_seed",
|
||||||
@@ -427,7 +427,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 17: primeval_sanctum
|
// Zone 17: primeval_sanctum
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.25 },
|
bonus: { type: "combat_power", value: 1.4 },
|
||||||
description:
|
description:
|
||||||
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
||||||
id: "ancient_memory_array",
|
id: "ancient_memory_array",
|
||||||
@@ -439,7 +439,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.25 },
|
bonus: { type: "click_power", value: 1.28 },
|
||||||
description:
|
description:
|
||||||
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
||||||
id: "first_artefact",
|
id: "first_artefact",
|
||||||
@@ -451,7 +451,88 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Cross-zone recipes ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
bonus: { type: "gold_income", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
|
||||||
|
id: "verdant_pyre_seal",
|
||||||
|
name: "Verdant Pyre Seal",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "verdant_sap", quantity: 8 },
|
||||||
|
{ materialId: "ember_crystal", quantity: 6 },
|
||||||
|
{ materialId: "legendary_ore", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "volcanic_depths",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "click_power", value: 1.22 },
|
||||||
|
description:
|
||||||
|
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
|
||||||
|
id: "voidfrost_weave",
|
||||||
|
name: "Voidfrost Weave",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "glacial_ice", quantity: 8 },
|
||||||
|
{ materialId: "void_shard", quantity: 3 },
|
||||||
|
{ materialId: "shadow_essence", quantity: 5 },
|
||||||
|
],
|
||||||
|
zoneId: "shadow_marshes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "essence_income", value: 1.28 },
|
||||||
|
description:
|
||||||
|
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
|
||||||
|
id: "choir_of_the_deep",
|
||||||
|
name: "Choir of the Deep",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "celestial_dust", quantity: 8 },
|
||||||
|
{ materialId: "choir_shard", quantity: 2 },
|
||||||
|
{ materialId: "ancient_tooth", quantity: 2 },
|
||||||
|
{ materialId: "pressure_gem", quantity: 5 },
|
||||||
|
],
|
||||||
|
zoneId: "abyssal_trench",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "click_power", value: 1.38 },
|
||||||
|
description:
|
||||||
|
"A primeval relic submerged at the absolute boundary of existence alongside omega crystals and boundary shards — the first and last thing, unified. Every action your guild takes through it is simultaneously the most ancient and most final thing that has ever happened. It does not miss.",
|
||||||
|
id: "primal_omega_lens",
|
||||||
|
name: "Primal Omega Lens",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "primeval_relic", quantity: 2 },
|
||||||
|
{ materialId: "boundary_shard", quantity: 4 },
|
||||||
|
{ materialId: "omega_crystal", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "the_absolute",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bonus: { type: "combat_power", value: 1.65 },
|
||||||
|
description:
|
||||||
|
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
|
||||||
|
id: "eternal_omega",
|
||||||
|
name: "Eternal Omega",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "crown_fragment", quantity: 6 },
|
||||||
|
{ materialId: "eternity_splinter", quantity: 2 },
|
||||||
|
{ materialId: "boundary_shard", quantity: 4 },
|
||||||
|
{ materialId: "omega_crystal", quantity: 2 },
|
||||||
|
],
|
||||||
|
zoneId: "the_absolute",
|
||||||
|
},
|
||||||
|
|
||||||
// Zone 18: the_absolute
|
// Zone 18: the_absolute
|
||||||
|
{
|
||||||
|
bonus: { type: "click_power", value: 1.3 },
|
||||||
|
description:
|
||||||
|
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
|
||||||
|
id: "absolute_focus",
|
||||||
|
name: "Absolute Focus",
|
||||||
|
requiredMaterials: [
|
||||||
|
{ materialId: "absolute_fragment", quantity: 8 },
|
||||||
|
{ materialId: "omega_crystal", quantity: 3 },
|
||||||
|
],
|
||||||
|
zoneId: "the_absolute",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "gold_income", value: 1.3 },
|
bonus: { type: "gold_income", value: 1.3 },
|
||||||
description:
|
description:
|
||||||
@@ -465,7 +546,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "combat_power", value: 1.3 },
|
bonus: { type: "combat_power", value: 1.55 },
|
||||||
description:
|
description:
|
||||||
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
||||||
id: "omega_convergence",
|
id: "omega_convergence",
|
||||||
|
|||||||
@@ -8,4 +8,4 @@
|
|||||||
/**
|
/**
|
||||||
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
|
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
|
||||||
*/
|
*/
|
||||||
export const currentSchemaVersion = 1;
|
export const currentSchemaVersion = 2;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Income multipliers ──────────────────────────────────────────────────────
|
// ── Income multipliers ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 5,
|
cost: 2,
|
||||||
description:
|
description:
|
||||||
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
||||||
id: "echo_income_1",
|
id: "echo_income_1",
|
||||||
@@ -20,7 +20,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 10,
|
cost: 4,
|
||||||
description:
|
description:
|
||||||
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
||||||
id: "echo_income_2",
|
id: "echo_income_2",
|
||||||
@@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 20,
|
cost: 8,
|
||||||
description:
|
description:
|
||||||
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
||||||
id: "echo_income_3",
|
id: "echo_income_3",
|
||||||
@@ -38,7 +38,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 40,
|
cost: 16,
|
||||||
description:
|
description:
|
||||||
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
||||||
id: "echo_income_4",
|
id: "echo_income_4",
|
||||||
@@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
cost: 80,
|
cost: 32,
|
||||||
description:
|
description:
|
||||||
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
||||||
id: "echo_income_5",
|
id: "echo_income_5",
|
||||||
@@ -58,7 +58,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Combat multipliers ──────────────────────────────────────────────────────
|
// ── Combat multipliers ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "combat",
|
category: "combat",
|
||||||
cost: 5,
|
cost: 2,
|
||||||
description:
|
description:
|
||||||
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
||||||
id: "echo_combat_1",
|
id: "echo_combat_1",
|
||||||
@@ -67,7 +67,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "combat",
|
category: "combat",
|
||||||
cost: 15,
|
cost: 6,
|
||||||
description:
|
description:
|
||||||
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
||||||
id: "echo_combat_2",
|
id: "echo_combat_2",
|
||||||
@@ -76,7 +76,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "combat",
|
category: "combat",
|
||||||
cost: 35,
|
cost: 12,
|
||||||
description:
|
description:
|
||||||
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
||||||
id: "echo_combat_3",
|
id: "echo_combat_3",
|
||||||
@@ -87,7 +87,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Prestige threshold reductions ──────────────────────────────────────────
|
// ── Prestige threshold reductions ──────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "prestige_threshold",
|
category: "prestige_threshold",
|
||||||
cost: 8,
|
cost: 3,
|
||||||
description:
|
description:
|
||||||
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
||||||
id: "echo_prestige_threshold_1",
|
id: "echo_prestige_threshold_1",
|
||||||
@@ -96,7 +96,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "prestige_threshold",
|
category: "prestige_threshold",
|
||||||
cost: 20,
|
cost: 6,
|
||||||
description:
|
description:
|
||||||
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
||||||
id: "echo_prestige_threshold_2",
|
id: "echo_prestige_threshold_2",
|
||||||
@@ -107,7 +107,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "prestige_runestones",
|
category: "prestige_runestones",
|
||||||
cost: 8,
|
cost: 3,
|
||||||
description:
|
description:
|
||||||
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
||||||
id: "echo_prestige_runestones_1",
|
id: "echo_prestige_runestones_1",
|
||||||
@@ -116,7 +116,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "prestige_runestones",
|
category: "prestige_runestones",
|
||||||
cost: 20,
|
cost: 6,
|
||||||
description:
|
description:
|
||||||
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
||||||
id: "echo_prestige_runestones_2",
|
id: "echo_prestige_runestones_2",
|
||||||
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 10,
|
cost: 15,
|
||||||
description:
|
description:
|
||||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||||
id: "echo_meta_1",
|
id: "echo_meta_1",
|
||||||
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 25,
|
cost: 45,
|
||||||
description:
|
description:
|
||||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||||
id: "echo_meta_2",
|
id: "echo_meta_2",
|
||||||
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "echo_meta",
|
category: "echo_meta",
|
||||||
cost: 50,
|
cost: 100,
|
||||||
description:
|
description:
|
||||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||||
id: "echo_meta_3",
|
id: "echo_meta_3",
|
||||||
|
|||||||
+167
-22
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
costCrystals: 100,
|
costCrystals: 50,
|
||||||
costEssence: 0,
|
costEssence: 0,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
@@ -104,7 +104,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
description:
|
description:
|
||||||
"Forge partnerships with mage guilds across the realm. All income +50%.",
|
"Forge partnerships with mage guilds across the realm. All income +50%.",
|
||||||
id: "essence_guild",
|
id: "essence_guild",
|
||||||
multiplier: 1.5,
|
multiplier: 2,
|
||||||
name: "Essence Guild",
|
name: "Essence Guild",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
target: "global",
|
target: "global",
|
||||||
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "peasant",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 20,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Organised labour guilds and proper scheduling make peasants ten times more productive.",
|
||||||
|
id: "peasant_2",
|
||||||
|
multiplier: 10,
|
||||||
|
name: "Guild Organisation",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "peasant",
|
||||||
|
costCrystals: 50,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Magical augmentation through crystalline resonance supercharges even the humblest worker.",
|
||||||
|
id: "peasant_3",
|
||||||
|
multiplier: 50,
|
||||||
|
name: "Crystal Augmentation",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
adventurerId: "militia",
|
adventurerId: "militia",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
costEssence: 2,
|
costEssence: 2,
|
||||||
costGold: 5000,
|
costGold: 5000,
|
||||||
description: "Ancient books of magic double mage output.",
|
description: "Ancient books of magic double mage output.",
|
||||||
id: "mage_1",
|
id: "apprentice_1",
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
name: "Arcane Tomes",
|
name: "Arcane Tomes",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
costEssence: 3,
|
costEssence: 3,
|
||||||
costGold: 8000,
|
costGold: 8000,
|
||||||
description: "Sacred ceremonies double the output of your clerics.",
|
description: "Sacred ceremonies double the output of your clerics.",
|
||||||
id: "cleric_1",
|
id: "acolyte_1",
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
name: "Holy Rites",
|
name: "Holy Rites",
|
||||||
purchased: false,
|
purchased: false,
|
||||||
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
adventurerId: "shadow_assassin",
|
|
||||||
costCrystals: 0,
|
|
||||||
costEssence: 50,
|
|
||||||
costGold: 0,
|
|
||||||
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
|
||||||
id: "shadow_assassin_1",
|
|
||||||
multiplier: 2,
|
|
||||||
name: "Shadow Arts",
|
|
||||||
purchased: false,
|
|
||||||
target: "adventurer",
|
|
||||||
unlocked: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
adventurerId: "arcane_scholar",
|
adventurerId: "arcane_scholar",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 150,
|
costEssence: 1000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "Access to forbidden libraries doubles scholar output.",
|
description: "Access to forbidden libraries doubles scholar output.",
|
||||||
id: "arcane_scholar_1",
|
id: "arcane_scholar_1",
|
||||||
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "shadow_assassin",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5000,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Mastery of the shadow arts doubles assassin effectiveness.",
|
||||||
|
id: "shadow_assassin_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Shadow Arts",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adventurerId: "dark_templar",
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 25_000,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"A sworn oath to the darkness of the marshes doubles templar output.",
|
||||||
|
id: "dark_templar_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Templar's Oath",
|
||||||
|
purchased: false,
|
||||||
|
target: "adventurer",
|
||||||
|
unlocked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
adventurerId: "void_walker",
|
adventurerId: "void_walker",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 300,
|
costEssence: 100_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
"Walking through the void itself doubles the output of your void walkers.",
|
"Walking through the void itself doubles the output of your void walkers.",
|
||||||
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "celestial_guard",
|
adventurerId: "celestial_guard",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 750,
|
costEssence: 500_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description:
|
description:
|
||||||
"A blessing from the celestials themselves doubles guard output.",
|
"A blessing from the celestials themselves doubles guard output.",
|
||||||
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
{
|
{
|
||||||
adventurerId: "divine_champion",
|
adventurerId: "divine_champion",
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 2000,
|
costEssence: 2_000_000,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "An unbreakable oath to the divine doubles champion output.",
|
description: "An unbreakable oath to the divine doubles champion output.",
|
||||||
id: "divine_champion_1",
|
id: "divine_champion_1",
|
||||||
@@ -417,7 +459,7 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
costCrystals: 10_000_000,
|
costCrystals: 50_000_000,
|
||||||
costEssence: 0,
|
costEssence: 0,
|
||||||
costGold: 0,
|
costGold: 0,
|
||||||
description: "Transcend mortal limits through void energy. All income x3.",
|
description: "Transcend mortal limits through void energy. All income x3.",
|
||||||
@@ -454,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
|
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
|
||||||
|
{
|
||||||
|
costCrystals: 3000,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
|
||||||
|
id: "crystal_pulse",
|
||||||
|
multiplier: 1.5,
|
||||||
|
name: "Crystal Pulse",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 20_000,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
|
||||||
|
id: "crystal_surge",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Crystal Surge",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 150_000,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Your guild's operations are saturated with crystalline power. All income x3.",
|
||||||
|
id: "crystal_tempest",
|
||||||
|
multiplier: 3,
|
||||||
|
name: "Crystal Tempest",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 5_000_000,
|
costEssence: 5_000_000,
|
||||||
@@ -767,4 +846,70 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
target: "adventurer",
|
target: "adventurer",
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
|
// ── Essence Sinks ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 1e12,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Channel a vast reservoir of essence into the guild's core — all production ×2.",
|
||||||
|
id: "essence_sink_1",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion I",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5e12,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"A deeper infusion saturates every operation with raw essence — all production ×2.",
|
||||||
|
id: "essence_sink_2",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion II",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 2.5e13,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Essence floods the ley-lines binding your guild — all production ×2.",
|
||||||
|
id: "essence_sink_3",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Essence Infusion III",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 1e14,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"The guild breathes essence as its very lifeblood — all production ×3.",
|
||||||
|
id: "essence_sink_4",
|
||||||
|
multiplier: 3,
|
||||||
|
name: "Essence Infusion IV",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 0,
|
||||||
|
costEssence: 5e14,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Essence transcends material form and reshapes reality itself — all production ×5.",
|
||||||
|
id: "essence_sink_5",
|
||||||
|
multiplier: 5,
|
||||||
|
name: "Essence Infusion V",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+30
-2
@@ -7,22 +7,27 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { aboutRouter } from "./routes/about.js";
|
import { aboutRouter } from "./routes/about.js";
|
||||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||||
import { authRouter } from "./routes/auth.js";
|
import { authRouter } from "./routes/auth.js";
|
||||||
import { bossRouter } from "./routes/boss.js";
|
import { bossRouter } from "./routes/boss.js";
|
||||||
import { craftRouter } from "./routes/craft.js";
|
import { craftRouter } from "./routes/craft.js";
|
||||||
|
import { debugRouter } from "./routes/debug.js";
|
||||||
import { exploreRouter } from "./routes/explore.js";
|
import { exploreRouter } from "./routes/explore.js";
|
||||||
|
import { frontendRouter } from "./routes/frontend.js";
|
||||||
import { gameRouter } from "./routes/game.js";
|
import { gameRouter } from "./routes/game.js";
|
||||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||||
import { prestigeRouter } from "./routes/prestige.js";
|
import { prestigeRouter } from "./routes/prestige.js";
|
||||||
import { profileRouter } from "./routes/profile.js";
|
import { profileRouter } from "./routes/profile.js";
|
||||||
|
import { timersRouter } from "./routes/timers.js";
|
||||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||||
|
import { connectGateway } from "./services/gateway.js";
|
||||||
|
import { logger } from "./services/logger.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", honoLogger());
|
||||||
app.use(
|
app.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
cors({
|
||||||
@@ -33,6 +38,8 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.route("/about", aboutRouter);
|
app.route("/about", aboutRouter);
|
||||||
|
app.route("/debug", debugRouter);
|
||||||
|
app.route("/fe", frontendRouter);
|
||||||
app.route("/auth", authRouter);
|
app.route("/auth", authRouter);
|
||||||
app.route("/game", gameRouter);
|
app.route("/game", gameRouter);
|
||||||
app.route("/boss", bossRouter);
|
app.route("/boss", bossRouter);
|
||||||
@@ -43,13 +50,34 @@ app.route("/transcendence", transcendenceRouter);
|
|||||||
app.route("/apotheosis", apotheosisRouter);
|
app.route("/apotheosis", apotheosisRouter);
|
||||||
app.route("/leaderboards", leaderboardRouter);
|
app.route("/leaderboards", leaderboardRouter);
|
||||||
app.route("/profile", profileRouter);
|
app.route("/profile", profileRouter);
|
||||||
|
app.route("/timers", timersRouter);
|
||||||
|
|
||||||
app.get("/health", (context) => {
|
app.get("/health", (context) => {
|
||||||
return context.json({ status: "ok" });
|
return context.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.onError((error, context) => {
|
||||||
|
void logger.error(
|
||||||
|
"hono_unhandled_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
|
|
||||||
|
try {
|
||||||
serve({ fetch: app.fetch, port: port }, () => {
|
serve({ fetch: app.fetch, port: port }, () => {
|
||||||
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
|
||||||
|
connectGateway();
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"server_startup",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyToken } from "../services/jwt.js";
|
import { verifyToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
@@ -33,7 +34,17 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
try {
|
try {
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
const isExpiredToken
|
||||||
|
= error instanceof Error && error.message === "Token has expired";
|
||||||
|
if (!isExpiredToken) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_middleware",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
|||||||
const aboutRouter = new Hono();
|
const aboutRouter = new Hono();
|
||||||
|
|
||||||
aboutRouter.get("/", async(context) => {
|
aboutRouter.get("/", async(context) => {
|
||||||
|
try {
|
||||||
const releases = await fetchReleases();
|
const releases = await fetchReleases();
|
||||||
const body: AboutResponse = {
|
const body: AboutResponse = {
|
||||||
apiVersion,
|
apiVersion,
|
||||||
releases,
|
releases,
|
||||||
};
|
};
|
||||||
return context.json(body);
|
return context.json(body);
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 9 -- @preserve */
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"about",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { aboutRouter };
|
export { aboutRouter };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
|
||||||
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
buildPostApotheosisState,
|
buildPostApotheosisState,
|
||||||
isEligibleForApotheosis,
|
isEligibleForApotheosis,
|
||||||
} from "../services/apotheosis.js";
|
} from "../services/apotheosis.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
grantApotheosisRole,
|
grantApotheosisRole,
|
||||||
postMilestoneWebhook,
|
postMilestoneWebhook,
|
||||||
@@ -25,6 +28,7 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
|
|||||||
apotheosisRouter.use("*", authMiddleware);
|
apotheosisRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
apotheosisRouter.post("/", async(context) => {
|
apotheosisRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
@@ -103,6 +107,8 @@ apotheosisRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const apotheosisCount = updatedApotheosisData.count;
|
||||||
|
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
|
||||||
void grantApotheosisRole(discordId);
|
void grantApotheosisRole(discordId);
|
||||||
void postMilestoneWebhook(discordId, "apotheosis", {
|
void postMilestoneWebhook(discordId, "apotheosis", {
|
||||||
apotheosis: updatedApotheosisData.count,
|
apotheosis: updatedApotheosisData.count,
|
||||||
@@ -113,6 +119,15 @@ apotheosisRouter.post("/", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"apotheosis",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { apotheosisRouter };
|
export { apotheosisRouter };
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
fetchDiscordUser,
|
fetchDiscordUser,
|
||||||
} from "../services/discord.js";
|
} from "../services/discord.js";
|
||||||
import { signToken } from "../services/jwt.js";
|
import { signToken } from "../services/jwt.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
import { grantElysianRole } from "../services/webhook.js";
|
||||||
import type { Player } from "@elysium/types";
|
import type { Player } from "@elysium/types";
|
||||||
|
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
@@ -91,7 +93,15 @@ authRouter.get("/callback", async(context) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inGuild = await grantElysianRole(player.discordId);
|
||||||
|
await prisma.player.update({
|
||||||
|
data: { inGuild },
|
||||||
|
where: { discordId: player.discordId },
|
||||||
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(player.discordId);
|
const jwtToken = signToken(player.discordId);
|
||||||
|
void logger.log("info", `New player registered: ${player.discordId}`);
|
||||||
|
void logger.metric("user_registered", 1, { discordId: player.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -101,16 +111,20 @@ authRouter.get("/callback", async(context) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inGuild = await grantElysianRole(discordUser.id);
|
||||||
const updated = await prisma.player.update({
|
const updated = await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
avatar: discordUser.avatar,
|
avatar: discordUser.avatar,
|
||||||
discriminator: discordUser.discriminator,
|
discriminator: discordUser.discriminator,
|
||||||
|
inGuild: inGuild,
|
||||||
username: discordUser.username,
|
username: discordUser.username,
|
||||||
},
|
},
|
||||||
where: { discordId: discordUser.id },
|
where: { discordId: discordUser.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const jwtToken = signToken(updated.discordId);
|
const jwtToken = signToken(updated.discordId);
|
||||||
|
void logger.log("info", `Player logged in: ${updated.discordId}`);
|
||||||
|
void logger.metric("user_login", 1, { discordId: updated.discordId });
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -118,7 +132,13 @@ authRouter.get("/callback", async(context) => {
|
|||||||
return context.redirect(
|
return context.redirect(
|
||||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"auth_callback",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||||
|
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
computeSetBonuses,
|
computeSetBonuses,
|
||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
@@ -17,11 +19,31 @@ import {
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultBosses } from "../data/bosses.js";
|
import { defaultBosses } from "../data/bosses.js";
|
||||||
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
||||||
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the HMAC-SHA256 of data using the given secret.
|
||||||
|
* @param data - The data string to sign.
|
||||||
|
* @param secret - The HMAC secret key.
|
||||||
|
* @returns The hex-encoded HMAC digest.
|
||||||
|
*/
|
||||||
|
const computeHmac = (data: string, secret: string): string => {
|
||||||
|
return createHmac("sha256", secret).update(data).
|
||||||
|
digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||||
|
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||||
|
* Must be kept in sync with prestigeCombatBase in apps/web/src/engine/tick.ts.
|
||||||
|
*/
|
||||||
|
const prestigeCombatBase = 4;
|
||||||
|
|
||||||
const bossRouter = new Hono<HonoEnvironment>();
|
const bossRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
bossRouter.use("*", authMiddleware);
|
bossRouter.use("*", authMiddleware);
|
||||||
@@ -36,8 +58,7 @@ const calculatePartyStats = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
|
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count);
|
||||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
|
||||||
|
|
||||||
// Apply equipped weapon's combat bonus
|
// Apply equipped weapon's combat bonus
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
@@ -121,6 +142,7 @@ const calculatePartyStats = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
bossRouter.post("/challenge", async(context) => {
|
bossRouter.post("/challenge", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<{ bossId: string }>();
|
const body = await context.req.json<{ bossId: string }>();
|
||||||
|
|
||||||
@@ -196,9 +218,11 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
boss.status = "defeated";
|
boss.status = "defeated";
|
||||||
boss.currentHp = 0;
|
boss.currentHp = 0;
|
||||||
|
|
||||||
|
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
const crystalAward = boss.crystalReward * crystalMult;
|
||||||
|
state.resources.crystals = state.resources.crystals + crystalAward;
|
||||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||||
|
|
||||||
for (const upgradeId of boss.upgradeRewards) {
|
for (const upgradeId of boss.upgradeRewards) {
|
||||||
@@ -273,6 +297,19 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
zone.status = "unlocked";
|
zone.status = "unlocked";
|
||||||
|
|
||||||
|
// Unlock exploration areas for the newly unlocked zone
|
||||||
|
for (const area of state.exploration?.areas ?? []) {
|
||||||
|
const areaDefinition = defaultExplorations.find((explorationArea) => {
|
||||||
|
return explorationArea.id === area.id;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||||
|
area.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||||
return b.zoneId === zone.id;
|
return b.zoneId === zone.id;
|
||||||
});
|
});
|
||||||
@@ -296,19 +333,25 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-kill bounty — look up authoritative bounty from static data
|
// First-kill bounty — only awarded once across all prestiges
|
||||||
const staticBoss = defaultBosses.find((b) => {
|
const staticBoss = defaultBosses.find((b) => {
|
||||||
return b.id === body.bossId;
|
return b.id === body.bossId;
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 7 -- @preserve */
|
||||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
const bountyRunestones
|
||||||
|
= boss.bountyRunestonesClaimed === true
|
||||||
|
? 0
|
||||||
|
: staticBoss?.bountyRunestones ?? 0;
|
||||||
|
if (bountyRunestones > 0) {
|
||||||
|
boss.bountyRunestonesClaimed = true;
|
||||||
|
}
|
||||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||||
|
|
||||||
rewards = {
|
rewards = {
|
||||||
bountyRunestones: bountyRunestones,
|
bountyRunestones: bountyRunestones,
|
||||||
crystals: boss.crystalReward,
|
crystals: crystalAward,
|
||||||
equipmentIds: boss.equipmentRewards,
|
equipmentIds: boss.equipmentRewards,
|
||||||
essence: boss.essenceReward,
|
essence: boss.essenceReward,
|
||||||
gold: boss.goldReward,
|
gold: boss.goldReward,
|
||||||
@@ -348,6 +391,14 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const updatedSignature = secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
|
const { bossId } = body;
|
||||||
|
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||||
|
|
||||||
const bossMaxHp = boss.maxHp;
|
const bossMaxHp = boss.maxHp;
|
||||||
const bossNewHp = bossUpdatedHp;
|
const bossNewHp = bossUpdatedHp;
|
||||||
const response: BossChallengeResponse = {
|
const response: BossChallengeResponse = {
|
||||||
@@ -367,8 +418,20 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
if (casualties !== undefined) {
|
if (casualties !== undefined) {
|
||||||
response.casualties = casualties;
|
response.casualties = casualties;
|
||||||
}
|
}
|
||||||
|
if (updatedSignature !== undefined) {
|
||||||
|
response.signature = updatedSignature;
|
||||||
|
}
|
||||||
|
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"boss_challenge",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { bossRouter };
|
export { bossRouter };
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Hono } from "hono";
|
|||||||
import { defaultRecipes } from "../data/recipes.js";
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
@@ -63,6 +65,7 @@ const recomputeCraftedMultipliers = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
craftRouter.post("/", async(context) => {
|
craftRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<CraftRecipeRequest>();
|
const body = await context.req.json<CraftRecipeRequest>();
|
||||||
|
|
||||||
@@ -136,21 +139,53 @@ craftRouter.post("/", async(context) => {
|
|||||||
state.exploration.craftedCombatMultiplier
|
state.exploration.craftedCombatMultiplier
|
||||||
= updatedMultipliers.craftedCombatMultiplier;
|
= updatedMultipliers.craftedCombatMultiplier;
|
||||||
|
|
||||||
|
if (state.dailyChallenges !== undefined) {
|
||||||
|
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
|
||||||
|
state.dailyChallenges,
|
||||||
|
"crafting",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
state.dailyChallenges = updatedChallenges;
|
||||||
|
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: state as object, updatedAt: Date.now() },
|
data: { state: state as object, updatedAt: Date.now() },
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
|
||||||
|
|
||||||
const bonusType = recipe.bonus.type;
|
const bonusType = recipe.bonus.type;
|
||||||
const bonusValue = recipe.bonus.value;
|
const bonusValue = recipe.bonus.value;
|
||||||
|
const { materials } = state.exploration;
|
||||||
|
const {
|
||||||
|
craftedGoldMultiplier,
|
||||||
|
craftedEssenceMultiplier,
|
||||||
|
craftedClickMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
} = updatedMultipliers;
|
||||||
const response: CraftRecipeResponse = {
|
const response: CraftRecipeResponse = {
|
||||||
bonusType,
|
bonusType,
|
||||||
bonusValue,
|
bonusValue,
|
||||||
|
craftedClickMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
craftedEssenceMultiplier,
|
||||||
|
craftedGoldMultiplier,
|
||||||
|
materials,
|
||||||
recipeId,
|
recipeId,
|
||||||
...updatedMultipliers,
|
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"craft",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { craftRouter };
|
export { craftRouter };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,16 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
|
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultExplorations } from "../data/explorations.js";
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
import { initialExploration } from "../data/initialState.js";
|
import { initialExploration } from "../data/initialState.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectEventResult,
|
ExploreCollectEventResult,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
@@ -48,7 +51,66 @@ const pickNothingMessage = (): string => {
|
|||||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exploreRouter.get("/claimable", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
const areaId = context.req.query("areaId");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||||
|
if (!areaId) {
|
||||||
|
return context.json({ error: "areaId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const explorationArea = defaultExplorations.find((a) => {
|
||||||
|
return a.id === areaId;
|
||||||
|
});
|
||||||
|
if (!explorationArea) {
|
||||||
|
return context.json({ error: "Unknown exploration area" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawState: unknown = record.state;
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
|
const state = rawState as GameState;
|
||||||
|
|
||||||
|
if (!state.exploration) {
|
||||||
|
const response: ExploreClaimableResponse = { claimable: false };
|
||||||
|
return context.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = state.exploration.areas.find((a) => {
|
||||||
|
return a.id === areaId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!area || area.status !== "in_progress") {
|
||||||
|
const response: ExploreClaimableResponse = { claimable: false };
|
||||||
|
return context.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const startedAt = area.startedAt ?? 0;
|
||||||
|
const durationMs = explorationArea.durationSeconds * 1000;
|
||||||
|
const expiresAt = startedAt + durationMs;
|
||||||
|
const claimable = Date.now() >= expiresAt;
|
||||||
|
const response: ExploreClaimableResponse = { claimable };
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_claimable",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
exploreRouter.post("/start", async(context) => {
|
exploreRouter.post("/start", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<ExploreStartRequest>();
|
const body = await context.req.json<ExploreStartRequest>();
|
||||||
|
|
||||||
@@ -108,7 +170,10 @@ exploreRouter.post("/start", async(context) => {
|
|||||||
return a.id === areaId;
|
return a.id === areaId;
|
||||||
});
|
});
|
||||||
if (!area) {
|
if (!area) {
|
||||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
return context.json(
|
||||||
|
{ error: "Exploration area not found in state" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyInProgress = state.exploration.areas.some((a) => {
|
const anyInProgress = state.exploration.areas.some((a) => {
|
||||||
@@ -126,25 +191,35 @@ exploreRouter.post("/start", async(context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||||
|
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||||
area.status = "in_progress";
|
area.status = "in_progress";
|
||||||
area.startedAt = now;
|
area.startedAt = now;
|
||||||
|
area.endsAt = endsAt;
|
||||||
|
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: state as object, updatedAt: now },
|
data: { state: state as object, updatedAt: now },
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
||||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
|
||||||
const response: ExploreStartResponse = {
|
const response: ExploreStartResponse = {
|
||||||
areaId,
|
areaId,
|
||||||
endsAt,
|
endsAt,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_start",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
exploreRouter.post("/collect", async(context) => {
|
exploreRouter.post("/collect", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<ExploreCollectRequest>();
|
const body = await context.req.json<ExploreCollectRequest>();
|
||||||
|
|
||||||
@@ -218,7 +293,9 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pick a random event
|
// Pick a random event
|
||||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
const eventIndex = Math.floor(
|
||||||
|
Math.random() * explorationArea.events.length,
|
||||||
|
);
|
||||||
const event = explorationArea.events[eventIndex];
|
const event = explorationArea.events[eventIndex];
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next 3 -- @preserve */
|
/* v8 ignore next 3 -- @preserve */
|
||||||
@@ -350,6 +427,15 @@ exploreRouter.post("/collect", async(context) => {
|
|||||||
materialsFound: materialsFound,
|
materialsFound: materialsFound,
|
||||||
};
|
};
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"explore_collect",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { exploreRouter };
|
export { exploreRouter };
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @file Frontend logging routes that pipe client-side logs to the telemetry service.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
|
const validLevels = new Set([ "debug", "info", "warn" ]);
|
||||||
|
|
||||||
|
const frontendRouter = new Hono();
|
||||||
|
|
||||||
|
frontendRouter.post("/log", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ level: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.level || !body.message || !validLevels.has(body.level)) {
|
||||||
|
return context.json({ error: "level and message are required" }, 400);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */
|
||||||
|
void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`);
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_log",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frontendRouter.post("/error", async(context) => {
|
||||||
|
try {
|
||||||
|
const body = await context.req.json<{ context: string; message: string }>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.context || !body.message) {
|
||||||
|
return context.json({ error: "context and message are required" }, 400);
|
||||||
|
}
|
||||||
|
void logger.error(`[FE] ${body.context}`, new Error(body.message));
|
||||||
|
return context.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"frontend_error",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { frontendRouter };
|
||||||
+110
-7
@@ -27,6 +27,8 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { fetchDiscordUserById } from "../services/discord.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
checkAndUnlockTitles,
|
checkAndUnlockTitles,
|
||||||
@@ -544,6 +546,17 @@ const validateAndSanitize = (
|
|||||||
? previous.prestige
|
? previous.prestige
|
||||||
: incoming.prestige;
|
: incoming.prestige;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the DB prestige count is higher than the client's, the client is sending a
|
||||||
|
* stale pre-prestige save. Discard its upgrades (which have purchased: true) in
|
||||||
|
* favour of the DB's post-prestige upgrades (purchased: false) so that upgrade
|
||||||
|
* multipliers cannot persist across prestige via a race-condition auto-save.
|
||||||
|
*/
|
||||||
|
const upgrades
|
||||||
|
= incoming.prestige.count < previous.prestige.count
|
||||||
|
? previous.upgrades
|
||||||
|
: incoming.upgrades;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Echoes are only granted server-side via transcendence and can only decrease between
|
* Echoes are only granted server-side via transcendence and can only decrease between
|
||||||
* saves (spent on echo upgrades). Cap at the previous value to block inflation.
|
* saves (spent on echo upgrades). Cap at the previous value to block inflation.
|
||||||
@@ -609,11 +622,17 @@ const validateAndSanitize = (
|
|||||||
= Math.min(material.quantity, previousQuantity);
|
= Math.min(material.quantity, previousQuantity);
|
||||||
return { ...material, quantity: cappedQuantity };
|
return { ...material, quantity: cappedQuantity };
|
||||||
});
|
});
|
||||||
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter(
|
|
||||||
(recipeId) => {
|
/*
|
||||||
return previousExploration.craftedRecipeIds.includes(recipeId);
|
* Merge crafted recipe IDs from both states so the list can only ever grow.
|
||||||
},
|
* A stale auto-save arriving after a craft must not silently un-craft items.
|
||||||
);
|
*/
|
||||||
|
const craftedRecipeIds = [
|
||||||
|
...new Set([
|
||||||
|
...previousExploration.craftedRecipeIds,
|
||||||
|
...incoming.exploration.craftedRecipeIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
explorationSpread = {
|
explorationSpread = {
|
||||||
exploration: {
|
exploration: {
|
||||||
...incoming.exploration,
|
...incoming.exploration,
|
||||||
@@ -669,6 +688,7 @@ const validateAndSanitize = (
|
|||||||
prestige,
|
prestige,
|
||||||
quests,
|
quests,
|
||||||
resources,
|
resources,
|
||||||
|
upgrades,
|
||||||
...transcendenceSpread,
|
...transcendenceSpread,
|
||||||
...apotheosisSpread,
|
...apotheosisSpread,
|
||||||
...explorationSpread,
|
...explorationSpread,
|
||||||
@@ -681,13 +701,37 @@ const gameRouter = new Hono<HonoEnvironment>();
|
|||||||
gameRouter.use("*", authMiddleware);
|
gameRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
gameRouter.get("/load", async(context) => {
|
gameRouter.get("/load", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
|
||||||
|
Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
prisma.player.findUnique({ where: { discordId } }),
|
||||||
|
]),
|
||||||
|
fetchDiscordUserById(discordId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Refresh avatar in DB when Discord returns an updated hash
|
||||||
|
if (
|
||||||
|
freshDiscordUser !== null
|
||||||
|
&& playerRecord !== null
|
||||||
|
&& freshDiscordUser.avatar !== playerRecord.avatar
|
||||||
|
) {
|
||||||
|
playerRecord.avatar = freshDiscordUser.avatar;
|
||||||
|
void prisma.player.update({
|
||||||
|
data: { avatar: freshDiscordUser.avatar },
|
||||||
|
where: { discordId },
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
void logger.error(
|
||||||
|
"avatar_refresh",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
@@ -701,7 +745,9 @@ gameRouter.get("/load", async(context) => {
|
|||||||
discordId: playerRecord.discordId,
|
discordId: playerRecord.discordId,
|
||||||
discriminator: playerRecord.discriminator,
|
discriminator: playerRecord.discriminator,
|
||||||
lastSavedAt: Date.now(),
|
lastSavedAt: Date.now(),
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||||
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
|
||||||
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
|
||||||
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
|
||||||
lifetimeClicks: playerRecord.lifetimeClicks,
|
lifetimeClicks: playerRecord.lifetimeClicks,
|
||||||
@@ -732,6 +778,7 @@ gameRouter.get("/load", async(context) => {
|
|||||||
: computeHmac(JSON.stringify(freshState), secret);
|
: computeHmac(JSON.stringify(freshState), secret);
|
||||||
return context.json({
|
return context.json({
|
||||||
currentSchemaVersion: currentSchemaVersion,
|
currentSchemaVersion: currentSchemaVersion,
|
||||||
|
inGuild: playerRecord.inGuild,
|
||||||
loginBonus: null,
|
loginBonus: null,
|
||||||
loginStreak: playerRecord.loginStreak,
|
loginStreak: playerRecord.loginStreak,
|
||||||
offlineEssence: 0,
|
offlineEssence: 0,
|
||||||
@@ -747,6 +794,15 @@ gameRouter.get("/load", async(context) => {
|
|||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||||
const state = rawState as GameState;
|
const state = rawState as GameState;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Always sync character name from the Player record — the profile update route
|
||||||
|
* writes to Player.characterName directly, bypassing the game state blob.
|
||||||
|
*/
|
||||||
|
if (playerRecord !== null) {
|
||||||
|
state.player.characterName = playerRecord.characterName;
|
||||||
|
state.player.avatar = playerRecord.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const { offlineGold, offlineEssence, offlineSeconds }
|
const { offlineGold, offlineEssence, offlineSeconds }
|
||||||
@@ -861,8 +917,10 @@ gameRouter.get("/load", async(context) => {
|
|||||||
const signature = secret === undefined
|
const signature = secret === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: computeHmac(JSON.stringify(state), secret);
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
const inGuild = playerRecord?.inGuild ?? false;
|
||||||
return context.json({
|
return context.json({
|
||||||
currentSchemaVersion,
|
currentSchemaVersion,
|
||||||
|
inGuild,
|
||||||
loginBonus,
|
loginBonus,
|
||||||
loginStreak,
|
loginStreak,
|
||||||
offlineEssence,
|
offlineEssence,
|
||||||
@@ -872,9 +930,19 @@ gameRouter.get("/load", async(context) => {
|
|||||||
signature,
|
signature,
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"game_load",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
gameRouter.post("/save", async(context) => {
|
gameRouter.post("/save", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<SaveRequest>();
|
const body = await context.req.json<SaveRequest>();
|
||||||
|
|
||||||
@@ -888,6 +956,7 @@ gameRouter.post("/save", async(context) => {
|
|||||||
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
error: "Save rejected: outdated save. Reset your progress to continue.",
|
error: "Save rejected: outdated save. Reset your progress to continue.",
|
||||||
},
|
},
|
||||||
409,
|
409,
|
||||||
@@ -933,6 +1002,19 @@ gameRouter.post("/save", async(context) => {
|
|||||||
player: { ...stateToSave.player, lastSavedAt: now },
|
player: { ...stateToSave.player, lastSavedAt: now },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve the Player record's character name so that profile updates are not
|
||||||
|
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
|
||||||
|
*/
|
||||||
|
stateToSave = {
|
||||||
|
...stateToSave,
|
||||||
|
player: {
|
||||||
|
...stateToSave.player,
|
||||||
|
characterName:
|
||||||
|
playerRecord?.characterName ?? stateToSave.player.characterName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
|
||||||
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
* This prevents clients from claiming companions they haven't legitimately unlocked.
|
||||||
@@ -1005,12 +1087,24 @@ gameRouter.post("/save", async(context) => {
|
|||||||
? undefined
|
? undefined
|
||||||
: computeHmac(JSON.stringify(stateToSave), secret);
|
: computeHmac(JSON.stringify(stateToSave), secret);
|
||||||
return context.json({ savedAt: now, signature: signature });
|
return context.json({ savedAt: now, signature: signature });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"game_save",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
gameRouter.post("/reset", async(context) => {
|
gameRouter.post("/reset", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const playerRecord = await prisma.player.findUnique({ where: { discordId } });
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
return context.json({ error: "No player found" }, 404);
|
return context.json({ error: "No player found" }, 404);
|
||||||
}
|
}
|
||||||
@@ -1065,6 +1159,15 @@ gameRouter.post("/reset", async(context) => {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
state: freshState,
|
state: freshState,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"game_reset",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { gameRouter };
|
export { gameRouter };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ const resolveTitleName = (titleId: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
leaderboardRouter.get("/", async(context) => {
|
leaderboardRouter.get("/", async(context) => {
|
||||||
|
try {
|
||||||
const category = context.req.query("category") ?? "totalGold";
|
const category = context.req.query("category") ?? "totalGold";
|
||||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||||
@@ -122,6 +124,15 @@ leaderboardRouter.get("/", async(context) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return context.json({ category, entries });
|
return context.json({ category, entries });
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"leaderboards",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { leaderboardRouter };
|
export { leaderboardRouter };
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostPrestigeState,
|
buildPostPrestigeState,
|
||||||
|
calculatePrestigeThreshold,
|
||||||
computeRunestoneMultipliers,
|
computeRunestoneMultipliers,
|
||||||
isEligibleForPrestige,
|
isEligibleForPrestige,
|
||||||
} from "../services/prestige.js";
|
} from "../services/prestige.js";
|
||||||
@@ -25,6 +28,7 @@ const prestigeRouter = new Hono<HonoEnvironment>();
|
|||||||
prestigeRouter.use("*", authMiddleware);
|
prestigeRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
prestigeRouter.post("/", async(context) => {
|
prestigeRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
@@ -37,9 +41,15 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
const state = record.state as unknown as GameState;
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
if (!isEligibleForPrestige(state)) {
|
if (!isEligibleForPrestige(state)) {
|
||||||
|
const thresholdMultiplier
|
||||||
|
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||||
|
const required = calculatePrestigeThreshold(
|
||||||
|
state.prestige.count,
|
||||||
|
thresholdMultiplier,
|
||||||
|
);
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
error: `Not eligible for prestige — collect ${required.toLocaleString()} total gold first`,
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
@@ -98,12 +108,23 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await prisma.gameState.update({
|
const { updatedAt } = record;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use the record's current updatedAt as an optimistic lock — if another
|
||||||
|
* concurrent prestige request already committed, this update will match
|
||||||
|
* 0 rows and we can safely reject the duplicate without a double webhook.
|
||||||
|
*/
|
||||||
|
const updateResult = await prisma.gameState.updateMany({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: finalState as object, updatedAt: now },
|
data: { state: finalState as object, updatedAt: now },
|
||||||
where: { discordId },
|
where: { discordId, updatedAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
return context.json({ error: "Prestige already in progress" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.player.update({
|
await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
characterName: state.player.characterName,
|
characterName: state.player.characterName,
|
||||||
@@ -130,6 +151,20 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prestigeCount = prestigeData.count;
|
||||||
|
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||||
|
|
||||||
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
select: { profileSettings: true },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
|
||||||
|
const playerSettings = playerRecord?.profileSettings as
|
||||||
|
Record<string, unknown> | null | undefined;
|
||||||
|
const announcementsEnabled
|
||||||
|
= playerSettings?.enablePrestigeAnnouncements !== false;
|
||||||
|
|
||||||
|
if (announcementsEnabled) {
|
||||||
void postMilestoneWebhook(discordId, "prestige", {
|
void postMilestoneWebhook(discordId, "prestige", {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -141,15 +176,26 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
/* v8 ignore next 2 -- @preserve */
|
/* v8 ignore next 2 -- @preserve */
|
||||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
milestoneRunestones: milestoneRunestones,
|
milestoneRunestones: milestoneRunestones,
|
||||||
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
runestones: runestonesEarned,
|
runestones: runestonesEarned,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"prestige",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||||
|
|
||||||
@@ -204,11 +250,24 @@ prestigeRouter.post("/buy-upgrade", async(context) => {
|
|||||||
|
|
||||||
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
||||||
|
|
||||||
|
void logger.metric("prestige_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
return context.json({
|
return context.json({
|
||||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||||
runestonesRemaining: updatedRunestones,
|
runestonesRemaining: updatedRunestones,
|
||||||
...multipliers,
|
...multipliers,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"prestige_buy_upgrade",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { prestigeRouter };
|
export { prestigeRouter };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handlers require many steps */
|
||||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||||
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
||||||
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
|
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
|
||||||
@@ -19,6 +20,7 @@ import { Hono } from "hono";
|
|||||||
import { gameTitles } from "../data/titles.js";
|
import { gameTitles } from "../data/titles.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import { parseUnlockedTitles } from "../services/titles.js";
|
import { parseUnlockedTitles } from "../services/titles.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
|||||||
: "suffix";
|
: "suffix";
|
||||||
return {
|
return {
|
||||||
enableNotifications: rawObject.enableNotifications === true,
|
enableNotifications: rawObject.enableNotifications === true,
|
||||||
|
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
|
||||||
enableSounds: rawObject.enableSounds === true,
|
enableSounds: rawObject.enableSounds === true,
|
||||||
numberFormat: numberFormat,
|
numberFormat: numberFormat,
|
||||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||||
@@ -80,6 +83,7 @@ const resolveTitle = (id: string): { id: string; name: string } => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
profileRouter.get("/:discordId", async(context) => {
|
profileRouter.get("/:discordId", async(context) => {
|
||||||
|
try {
|
||||||
const { discordId } = context.req.param();
|
const { discordId } = context.req.param();
|
||||||
|
|
||||||
const [ player, gameStateRecord ] = await Promise.all([
|
const [ player, gameStateRecord ] = await Promise.all([
|
||||||
@@ -142,6 +146,8 @@ profileRouter.get("/:discordId", async(context) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const completedChapters = state?.story?.completedChapters ?? [];
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
achievementsUnlocked: achievementsUnlocked,
|
achievementsUnlocked: achievementsUnlocked,
|
||||||
activeTitle: player.activeTitle,
|
activeTitle: player.activeTitle,
|
||||||
@@ -153,6 +159,7 @@ profileRouter.get("/:discordId", async(context) => {
|
|||||||
characterClass: player.characterClass,
|
characterClass: player.characterClass,
|
||||||
characterName: player.characterName,
|
characterName: player.characterName,
|
||||||
characterRace: player.characterRace ?? "",
|
characterRace: player.characterRace ?? "",
|
||||||
|
completedChapters: completedChapters,
|
||||||
createdAt: player.createdAt,
|
createdAt: player.createdAt,
|
||||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||||
@@ -173,9 +180,19 @@ profileRouter.get("/:discordId", async(context) => {
|
|||||||
unlockedTitles: unlockedTitles,
|
unlockedTitles: unlockedTitles,
|
||||||
username: player.username,
|
username: player.username,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_get",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
profileRouter.put("/", authMiddleware, async(context) => {
|
profileRouter.put("/", authMiddleware, async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<UpdateProfileRequest>();
|
const body = await context.req.json<UpdateProfileRequest>();
|
||||||
|
|
||||||
@@ -206,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
|||||||
: "suffix";
|
: "suffix";
|
||||||
const profileSettings: ProfileSettings = {
|
const profileSettings: ProfileSettings = {
|
||||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||||
|
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
|
||||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||||
numberFormat: numberFormat,
|
numberFormat: numberFormat,
|
||||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||||
@@ -261,6 +279,15 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
|||||||
profileSettings: profileSettings,
|
profileSettings: profileSettings,
|
||||||
pronouns: updated.pronouns,
|
pronouns: updated.pronouns,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"profile_update",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { profileRouter };
|
export { profileRouter };
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* @file Public read-only timer API for external tooling (bots, automations, etc.).
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
|
const timersRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
|
const explorationNameMap = new Map(
|
||||||
|
defaultExplorations.map((area) => {
|
||||||
|
return [ area.id, area.name ];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts active quest timers from a game state.
|
||||||
|
* @param state - The player's game state.
|
||||||
|
* @param now - The current timestamp in milliseconds.
|
||||||
|
* @returns An array of active quest timer objects.
|
||||||
|
*/
|
||||||
|
const getQuestTimers = (
|
||||||
|
state: GameState,
|
||||||
|
now: number,
|
||||||
|
): Array<{
|
||||||
|
endsAt: number;
|
||||||
|
name: string;
|
||||||
|
questId: string;
|
||||||
|
timeLeft: number;
|
||||||
|
}> => {
|
||||||
|
return state.quests.
|
||||||
|
filter((quest) => {
|
||||||
|
return quest.status === "active" && quest.startedAt !== undefined;
|
||||||
|
}).
|
||||||
|
map((quest) => {
|
||||||
|
const durationMs = quest.durationSeconds * 1000;
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const endsAt = (quest.startedAt ?? 0) + durationMs;
|
||||||
|
return {
|
||||||
|
endsAt: endsAt,
|
||||||
|
name: quest.name,
|
||||||
|
questId: quest.id,
|
||||||
|
timeLeft: Math.max(0, endsAt - now),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts active exploration timers from a game state.
|
||||||
|
* @param state - The player's game state.
|
||||||
|
* @param now - The current timestamp in milliseconds.
|
||||||
|
* @returns An array of active exploration timer objects.
|
||||||
|
*/
|
||||||
|
const getExplorationTimers = (
|
||||||
|
state: GameState,
|
||||||
|
now: number,
|
||||||
|
): Array<{
|
||||||
|
areaId: string;
|
||||||
|
endsAt: number;
|
||||||
|
name: string;
|
||||||
|
timeLeft: number;
|
||||||
|
}> => {
|
||||||
|
return (state.exploration?.areas ?? []).
|
||||||
|
filter((area) => {
|
||||||
|
return area.status === "in_progress" && area.endsAt !== undefined;
|
||||||
|
}).
|
||||||
|
map((area) => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
const endsAt = area.endsAt ?? 0;
|
||||||
|
return {
|
||||||
|
areaId: area.id,
|
||||||
|
endsAt: endsAt,
|
||||||
|
name: explorationNameMap.get(area.id) ?? area.id,
|
||||||
|
timeLeft: Math.max(0, endsAt - now),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns active quest and exploration timers for a given player.
|
||||||
|
* This endpoint is public and read-only — no authentication required.
|
||||||
|
* Rate limiting is enforced at the infrastructure level.
|
||||||
|
*/
|
||||||
|
timersRouter.get("/:userId", async(context) => {
|
||||||
|
try {
|
||||||
|
const { userId } = context.req.param();
|
||||||
|
|
||||||
|
if (userId.length === 0 || !/^\d+$/u.test(userId)) {
|
||||||
|
return context.json({ error: "Invalid user ID" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({
|
||||||
|
where: { discordId: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record === null) {
|
||||||
|
return context.json({ error: "Player not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
|
const state = record.state as unknown as GameState;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
explorations: getExplorationTimers(state, now),
|
||||||
|
quests: getQuestTimers(state, now),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"timers",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { timersRouter };
|
||||||
@@ -6,10 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
import {
|
import {
|
||||||
buildPostTranscendenceState,
|
buildPostTranscendenceState,
|
||||||
computeTranscendenceMultipliers,
|
computeTranscendenceMultipliers,
|
||||||
@@ -24,6 +26,7 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
|
|||||||
transcendenceRouter.use("*", authMiddleware);
|
transcendenceRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
transcendenceRouter.post("/", async(context) => {
|
transcendenceRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
@@ -37,6 +40,7 @@ transcendenceRouter.post("/", async(context) => {
|
|||||||
if (!isEligibleForTranscendence(state)) {
|
if (!isEligibleForTranscendence(state)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||||
error: "Not eligible for transcendence — defeat The Absolute One first",
|
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
@@ -102,6 +106,8 @@ transcendenceRouter.post("/", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const transcendenceCount = transcendenceData.count;
|
||||||
|
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
|
||||||
void postMilestoneWebhook(discordId, "transcendence", {
|
void postMilestoneWebhook(discordId, "transcendence", {
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next -- @preserve */
|
||||||
@@ -119,9 +125,19 @@ transcendenceRouter.post("/", async(context) => {
|
|||||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||||
newTranscendenceCount: transcendenceData.count,
|
newTranscendenceCount: transcendenceData.count,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"transcendence",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||||
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||||
|
|
||||||
@@ -131,6 +147,7 @@ transcendenceRouter.post("/buy-upgrade", async(context) => {
|
|||||||
return context.json({ error: "upgradeId is required" }, 400);
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
|
||||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||||
return transcendenceUpgrade.id === upgradeId;
|
return transcendenceUpgrade.id === upgradeId;
|
||||||
});
|
});
|
||||||
@@ -181,11 +198,24 @@ transcendenceRouter.post("/buy-upgrade", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void logger.metric("transcendence_upgrade_purchased", 1, {
|
||||||
|
discordId,
|
||||||
|
upgradeId,
|
||||||
|
});
|
||||||
return context.json({
|
return context.json({
|
||||||
echoesRemaining: updatedEchoes,
|
echoesRemaining: updatedEchoes,
|
||||||
purchasedUpgradeIds: updatedPurchasedIds,
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
...updatedMultipliers,
|
...updatedMultipliers,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"transcendence_buy_upgrade",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { transcendenceRouter };
|
export { transcendenceRouter };
|
||||||
|
|||||||
@@ -71,8 +71,11 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const challengeTypes: Array<DailyChallengeType> = [
|
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
|
||||||
"clicks",
|
"crafting",
|
||||||
|
];
|
||||||
|
|
||||||
|
const progressionChallengeTypes: Array<DailyChallengeType> = [
|
||||||
"bossesDefeated",
|
"bossesDefeated",
|
||||||
"questsCompleted",
|
"questsCompleted",
|
||||||
"prestige",
|
"prestige",
|
||||||
@@ -80,7 +83,10 @@ const challengeTypes: Array<DailyChallengeType> = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates 3 daily challenges for the given date string, deterministically.
|
* Generates 3 daily challenges for the given date string, deterministically.
|
||||||
* Picks one challenge from 3 different randomly-selected types.
|
* Always includes a "clicks" challenge and a "crafting" challenge (both
|
||||||
|
* completable regardless of zone/boss progression), then picks 1 more from
|
||||||
|
* the progression types. This ensures stuck players always have 2 completable
|
||||||
|
* challenges available.
|
||||||
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
||||||
* @returns An array of 3 DailyChallenge objects.
|
* @returns An array of 3 DailyChallenge objects.
|
||||||
*/
|
*/
|
||||||
@@ -88,8 +94,17 @@ const generateDailyChallenges = (
|
|||||||
dateString: string,
|
dateString: string,
|
||||||
): Array<DailyChallenge> => {
|
): Array<DailyChallenge> => {
|
||||||
const seed = dateSeed(dateString);
|
const seed = dateSeed(dateString);
|
||||||
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
|
const selectedTypes: Array<DailyChallengeType> = [
|
||||||
slice(0, 3);
|
"clicks",
|
||||||
|
...shuffleWithSeed(
|
||||||
|
[ ...nonProgressionChallengeTypes ],
|
||||||
|
seed + 500,
|
||||||
|
).slice(0, 1),
|
||||||
|
...shuffleWithSeed(
|
||||||
|
[ ...progressionChallengeTypes ],
|
||||||
|
seed,
|
||||||
|
).slice(0, 1),
|
||||||
|
];
|
||||||
|
|
||||||
return selectedTypes.map((type, index) => {
|
return selectedTypes.map((type, index) => {
|
||||||
const templates = dailyChallengeTemplates.filter((template) => {
|
const templates = dailyChallengeTemplates.filter((template) => {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const discordClientId = "1479551654264049908";
|
||||||
|
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -30,26 +34,21 @@ interface DiscordUser {
|
|||||||
const exchangeCode = async(
|
const exchangeCode = async(
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<DiscordTokenResponse> => {
|
): Promise<DiscordTokenResponse> => {
|
||||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
|
||||||
|
|
||||||
if (
|
if (clientSecret === undefined || clientSecret === "") {
|
||||||
clientId === undefined || clientId === ""
|
|
||||||
|| clientSecret === undefined || clientSecret === ""
|
|
||||||
|| redirectUri === undefined || redirectUri === ""
|
|
||||||
) {
|
|
||||||
throw new Error("Discord OAuth environment variables are required");
|
throw new Error("Discord OAuth environment variables are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = new URLSearchParams({
|
const parameters = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: discordClientId,
|
||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
code: code,
|
code: code,
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: discordRedirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||||
body: parameters.toString(),
|
body: parameters.toString(),
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@@ -62,6 +61,15 @@ const exchangeCode = async(
|
|||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
||||||
return await (response.json() as Promise<DiscordTokenResponse>);
|
return await (response.json() as Promise<DiscordTokenResponse>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_exchange_code",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +81,7 @@ const exchangeCode = async(
|
|||||||
const fetchDiscordUser = async(
|
const fetchDiscordUser = async(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<DiscordUser> => {
|
): Promise<DiscordUser> => {
|
||||||
|
try {
|
||||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
@@ -83,6 +92,49 @@ const fetchDiscordUser = async(
|
|||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
return await (response.json() as Promise<DiscordUser>);
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a Discord user's profile by their Discord ID using the bot token.
|
||||||
|
* Returns null on any failure so callers are never blocked by Discord API issues.
|
||||||
|
* @param discordId - The Discord user ID to look up.
|
||||||
|
* @returns The Discord user object, or null if the fetch fails.
|
||||||
|
*/
|
||||||
|
const fetchDiscordUserById = async(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<DiscordUser | null> => {
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
if (botToken === undefined || botToken === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://discord.com/api/v10/users/${discordId}`,
|
||||||
|
{ headers: { Authorization: `Bot ${botToken}` } },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||||
|
return await (response.json() as Promise<DiscordUser>);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"discord_fetch_user_by_id",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,19 +143,9 @@ const fetchDiscordUser = async(
|
|||||||
* @throws {Error} If OAuth environment variables are missing.
|
* @throws {Error} If OAuth environment variables are missing.
|
||||||
*/
|
*/
|
||||||
const buildOAuthUrl = (): string => {
|
const buildOAuthUrl = (): string => {
|
||||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
|
||||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
|
||||||
|
|
||||||
if (
|
|
||||||
clientId === undefined || clientId === ""
|
|
||||||
|| redirectUri === undefined || redirectUri === ""
|
|
||||||
) {
|
|
||||||
throw new Error("Discord OAuth environment variables are required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parameters = new URLSearchParams({
|
const parameters = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: discordClientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: discordRedirectUri,
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "identify",
|
scope: "identify",
|
||||||
});
|
});
|
||||||
@@ -112,4 +154,4 @@ const buildOAuthUrl = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type { DiscordTokenResponse, DiscordUser };
|
export type { DiscordTokenResponse, DiscordUser };
|
||||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* @file Discord Gateway WebSocket client for listening to guild member events.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */
|
||||||
|
import { prisma } from "../db/client.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const discordGuildId = "1354624415861833870";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord Gateway opcodes used by this client.
|
||||||
|
*/
|
||||||
|
const gatewayOpcodes = {
|
||||||
|
dispatch: 0,
|
||||||
|
heartbeat: 1,
|
||||||
|
heartbeatAck: 11,
|
||||||
|
hello: 10,
|
||||||
|
identify: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUILD_MEMBERS privileged intent bitmask.
|
||||||
|
*/
|
||||||
|
/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */
|
||||||
|
const guildMembersIntent = 1 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the inGuild flag for a player when they join the configured guild.
|
||||||
|
* No-ops silently if the Discord user has no player record.
|
||||||
|
* @param discordId - The Discord user ID of the member who joined.
|
||||||
|
* @param guildId - The ID of the guild they joined.
|
||||||
|
* @returns A promise that resolves when the update attempt completes.
|
||||||
|
*/
|
||||||
|
const handleGuildMemberAdd = async(
|
||||||
|
discordId: string,
|
||||||
|
guildId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (guildId !== discordGuildId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await prisma.player.updateMany({
|
||||||
|
data: { inGuild: true },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"gateway_member_add",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the inGuild flag for a player when they leave the configured guild.
|
||||||
|
* No-ops silently if the Discord user has no player record.
|
||||||
|
* @param discordId - The Discord user ID of the member who left.
|
||||||
|
* @param guildId - The ID of the guild they left.
|
||||||
|
* @returns A promise that resolves when the update attempt completes.
|
||||||
|
*/
|
||||||
|
const handleGuildMemberRemove = async(
|
||||||
|
discordId: string,
|
||||||
|
guildId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (guildId !== discordGuildId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await prisma.player.updateMany({
|
||||||
|
data: { inGuild: false },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"gateway_member_remove",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase
|
||||||
|
/* v8 ignore next 95 -- @preserve */
|
||||||
|
/**
|
||||||
|
* Connects to the Discord Gateway and listens for guild member events.
|
||||||
|
* Reconnects automatically on close or error.
|
||||||
|
* Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal.
|
||||||
|
*/
|
||||||
|
const connectGateway = (): void => {
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
if (botToken === undefined || botToken === "") {
|
||||||
|
void logger.log("info", "Gateway: no bot token configured, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json");
|
||||||
|
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let lastSequence: number | null = null;
|
||||||
|
|
||||||
|
const stopHeartbeat = (): void => {
|
||||||
|
if (heartbeatInterval !== null) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */
|
||||||
|
const payload = JSON.parse(event.data as string) as {
|
||||||
|
op: number;
|
||||||
|
d: unknown;
|
||||||
|
s: number | null;
|
||||||
|
t: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.s !== null) {
|
||||||
|
lastSequence = payload.s;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.op === gatewayOpcodes.hello) {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */
|
||||||
|
const helloData = payload.d as { heartbeat_interval: number };
|
||||||
|
const heartbeatMs = helloData.heartbeat_interval;
|
||||||
|
heartbeatInterval = setInterval(() => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
d: lastSequence,
|
||||||
|
op: gatewayOpcodes.heartbeat,
|
||||||
|
}));
|
||||||
|
}, heartbeatMs);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
d: {
|
||||||
|
intents: guildMembersIntent,
|
||||||
|
properties: { browser: "elysium", device: "elysium", os: "linux" },
|
||||||
|
token: botToken,
|
||||||
|
},
|
||||||
|
op: gatewayOpcodes.identify,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */
|
||||||
|
const data = payload.d as { user?: { id: string }; guild_id?: string };
|
||||||
|
const discordId = data.user?.id;
|
||||||
|
const guildId = data.guild_id;
|
||||||
|
|
||||||
|
if (discordId === undefined || guildId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.t === "GUILD_MEMBER_ADD") {
|
||||||
|
void handleGuildMemberAdd(discordId, guildId);
|
||||||
|
} else if (payload.t === "GUILD_MEMBER_REMOVE") {
|
||||||
|
void handleGuildMemberRemove(discordId, guildId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
stopHeartbeat();
|
||||||
|
void logger.log("info", "Gateway: connection closed, reconnecting in 5s");
|
||||||
|
setTimeout(connectGateway, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("error", (event) => {
|
||||||
|
const message
|
||||||
|
= event instanceof ErrorEvent
|
||||||
|
? event.message
|
||||||
|
: "WebSocket error";
|
||||||
|
void logger.error("gateway_error", new Error(message));
|
||||||
|
stopHeartbeat();
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @file Logger service for handling logging.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@nhcarrigan/logger";
|
||||||
|
|
||||||
|
const logger = new Logger("Elysium", process.env.LOG_TOKEN ?? "");
|
||||||
|
|
||||||
|
export { logger };
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
||||||
|
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
|
||||||
import { initialGameState } from "../data/initialState.js";
|
import { initialGameState } from "../data/initialState.js";
|
||||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -14,14 +15,21 @@ import type {
|
|||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
|
|
||||||
const basePrestigeGoldThreshold = 1_000_000;
|
const basePrestigeGoldThreshold = 1_000_000;
|
||||||
const thresholdScaleFactor = 5;
|
const runestonesPerPrestigeLevel = 20;
|
||||||
const runestonesPerPrestigeLevel = 10;
|
|
||||||
const milestoneInterval = 5;
|
const milestoneInterval = 5;
|
||||||
const milestoneRunestonesPerInterval = 25;
|
const milestoneRunestonesPerInterval = 25;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hard cap on the base runestone yield (before multipliers) to prevent
|
||||||
|
* extreme AFK accumulation from producing game-breaking runestone counts.
|
||||||
|
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige.
|
||||||
|
*/
|
||||||
|
const maxBaseRunestones = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the gold threshold required for the next prestige.
|
* Calculates the gold threshold required for the next prestige.
|
||||||
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
|
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
|
||||||
|
* meaningful even as the production multiplier scales.
|
||||||
* @param prestigeCount - The current number of prestiges completed.
|
* @param prestigeCount - The current number of prestiges completed.
|
||||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||||
* @returns The gold amount required to prestige.
|
* @returns The gold amount required to prestige.
|
||||||
@@ -32,7 +40,7 @@ const calculatePrestigeThreshold = (
|
|||||||
): number => {
|
): number => {
|
||||||
return (
|
return (
|
||||||
basePrestigeGoldThreshold
|
basePrestigeGoldThreshold
|
||||||
* Math.pow(thresholdScaleFactor, prestigeCount)
|
* Math.pow(prestigeCount + 1, 2.5)
|
||||||
* thresholdMultiplier
|
* thresholdMultiplier
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -106,7 +114,9 @@ interface RunestoneParameters {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates how many runestones the player earns from a prestige.
|
* Calculates how many runestones the player earns from a prestige.
|
||||||
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
|
* Formula: min(floor(cbrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL, MAX_BASE) * multipliers.
|
||||||
|
* Uses cube root for stronger diminishing returns than sqrt, and caps the base before multipliers
|
||||||
|
* to prevent extended AFK sessions from producing runestone windfalls.
|
||||||
* @param parameters - The parameters for the runestone calculation.
|
* @param parameters - The parameters for the runestone calculation.
|
||||||
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
||||||
* @param parameters.prestigeCount - The current prestige count.
|
* @param parameters.prestigeCount - The current prestige count.
|
||||||
@@ -122,9 +132,11 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
|
|||||||
echoRunestoneMultiplier = 1,
|
echoRunestoneMultiplier = 1,
|
||||||
} = parameters;
|
} = parameters;
|
||||||
const threshold = calculatePrestigeThreshold(prestigeCount);
|
const threshold = calculatePrestigeThreshold(prestigeCount);
|
||||||
const base
|
const base = Math.min(
|
||||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
|
Math.floor(Math.cbrt(totalGoldEarned / threshold))
|
||||||
* runestonesPerPrestigeLevel;
|
* runestonesPerPrestigeLevel,
|
||||||
|
maxBaseRunestones,
|
||||||
|
);
|
||||||
const runestoneMult = getCategoryMultiplier(
|
const runestoneMult = getCategoryMultiplier(
|
||||||
purchasedUpgradeIds,
|
purchasedUpgradeIds,
|
||||||
"runestones",
|
"runestones",
|
||||||
@@ -134,19 +146,20 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the new prestige production multiplier.
|
* Calculates the new prestige production multiplier.
|
||||||
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
|
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually
|
||||||
|
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
|
||||||
* @param prestigeCount - The new prestige count.
|
* @param prestigeCount - The new prestige count.
|
||||||
* @returns The production multiplier for the new prestige level.
|
* @returns The production multiplier for the new prestige level.
|
||||||
*/
|
*/
|
||||||
const calculateProductionMultiplier = (
|
const calculateProductionMultiplier = (
|
||||||
prestigeCount: number,
|
prestigeCount: number,
|
||||||
): number => {
|
): number => {
|
||||||
return Math.pow(1.15, prestigeCount);
|
return Math.pow(1.3, prestigeCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the milestone runestone bonus for the given prestige count.
|
* Returns the milestone runestone bonus for the given prestige count.
|
||||||
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
|
* Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
|
||||||
* @param prestigeCount - The prestige count after the current prestige.
|
* @param prestigeCount - The prestige count after the current prestige.
|
||||||
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
||||||
*/
|
*/
|
||||||
@@ -155,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const milestoneNumber = prestigeCount / milestoneInterval;
|
const milestoneNumber = prestigeCount / milestoneInterval;
|
||||||
return milestoneNumber * milestoneRunestonesPerInterval;
|
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +189,7 @@ const buildPostPrestigeState = (
|
|||||||
} => {
|
} => {
|
||||||
const {
|
const {
|
||||||
autoPrestigeEnabled,
|
autoPrestigeEnabled,
|
||||||
|
autoPrestigeMaxRunestonesOnly,
|
||||||
count: currentPrestigeCount,
|
count: currentPrestigeCount,
|
||||||
purchasedUpgradeIds,
|
purchasedUpgradeIds,
|
||||||
runestones: currentRunestones,
|
runestones: currentRunestones,
|
||||||
@@ -202,12 +216,87 @@ const buildPostPrestigeState = (
|
|||||||
...autoPrestigeEnabled === undefined
|
...autoPrestigeEnabled === undefined
|
||||||
? {}
|
? {}
|
||||||
: { autoPrestigeEnabled },
|
: { autoPrestigeEnabled },
|
||||||
|
...autoPrestigeMaxRunestonesOnly === undefined
|
||||||
|
? {}
|
||||||
|
: { autoPrestigeMaxRunestonesOnly },
|
||||||
};
|
};
|
||||||
|
|
||||||
const freshState = initialGameState(currentState.player, characterName);
|
const freshState = initialGameState(currentState.player, characterName);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve first-kill (bounty claimed) status across the prestige reset so
|
||||||
|
* the one-time bounty is never re-awarded in subsequent runs.
|
||||||
|
*/
|
||||||
|
const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => {
|
||||||
|
const currentBoss = currentState.bosses.find((candidate) => {
|
||||||
|
return candidate.id === freshBoss.id;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
currentBoss?.bountyRunestonesClaimed === true
|
||||||
|
|| currentBoss?.status === "defeated"
|
||||||
|
) {
|
||||||
|
return { ...freshBoss, bountyRunestonesClaimed: true };
|
||||||
|
}
|
||||||
|
return freshBoss;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute current-run contributions to accumulate into lifetime totals
|
||||||
|
const runBossesDefeated = currentState.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
const runQuestsCompleted = currentState.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
let runAdventurersRecruited = 0;
|
||||||
|
for (const adventurer of currentState.adventurers) {
|
||||||
|
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||||
|
}
|
||||||
|
const runAchievementsUnlocked = currentState.achievements.filter(
|
||||||
|
(achievement) => {
|
||||||
|
return achievement.unlockedAt !== null;
|
||||||
|
},
|
||||||
|
).length;
|
||||||
|
|
||||||
const prestigeState: GameState = {
|
const prestigeState: GameState = {
|
||||||
...freshState,
|
...freshState,
|
||||||
|
|
||||||
|
// Achievements are permanent — earned achievements survive all prestiges
|
||||||
|
achievements: currentState.achievements,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve automation preferences across prestige — the player explicitly
|
||||||
|
* opted into these settings and would not expect them to silently reset.
|
||||||
|
*/
|
||||||
|
autoAdventurer: currentState.autoAdventurer ?? false,
|
||||||
|
autoBoss: currentState.autoBoss ?? false,
|
||||||
|
|
||||||
|
autoQuest: currentState.autoQuest ?? false,
|
||||||
|
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
|
||||||
|
bosses: bossesWithBountyClaimed,
|
||||||
lastTickAt: Date.now(),
|
lastTickAt: Date.now(),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fold current-run totals into lifetime stats so the GameState reflects
|
||||||
|
* the true all-time values immediately after prestige.
|
||||||
|
*/
|
||||||
|
player: {
|
||||||
|
...freshState.player,
|
||||||
|
lifetimeAchievementsUnlocked:
|
||||||
|
freshState.player.lifetimeAchievementsUnlocked
|
||||||
|
+ runAchievementsUnlocked,
|
||||||
|
lifetimeAdventurersRecruited:
|
||||||
|
freshState.player.lifetimeAdventurersRecruited
|
||||||
|
+ runAdventurersRecruited,
|
||||||
|
lifetimeBossesDefeated:
|
||||||
|
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
|
||||||
|
lifetimeClicks:
|
||||||
|
freshState.player.lifetimeClicks + currentState.player.totalClicks,
|
||||||
|
lifetimeGoldEarned:
|
||||||
|
freshState.player.lifetimeGoldEarned
|
||||||
|
+ currentState.player.totalGoldEarned,
|
||||||
|
lifetimeQuestsCompleted:
|
||||||
|
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
|
||||||
|
},
|
||||||
prestige: prestigeData,
|
prestige: prestigeData,
|
||||||
// Codex lore persists across prestiges — players keep their discovered entries
|
// Codex lore persists across prestiges — players keep their discovered entries
|
||||||
...currentState.codex === undefined
|
...currentState.codex === undefined
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one";
|
|||||||
/**
|
/**
|
||||||
* Base constant used in the echo yield formula.
|
* Base constant used in the echo yield formula.
|
||||||
*/
|
*/
|
||||||
const echoFormulaConstant = 853;
|
const echoFormulaConstant = 224;
|
||||||
|
|
||||||
const getCategoryMultiplier = (
|
const getCategoryMultiplier = (
|
||||||
purchasedIds: Array<string>,
|
purchasedIds: Array<string>,
|
||||||
|
|||||||
@@ -5,8 +5,59 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordApi = "https://discord.com/api/v10";
|
const discordApi = "https://discord.com/api/v10";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
|
||||||
|
* triggering desktop or mobile push notifications.
|
||||||
|
*/
|
||||||
|
const suppressNotifications = 4096;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Discord role ID for the Elysian role granted to all Elysium players.
|
||||||
|
*/
|
||||||
|
const discordGuildId = "1354624415861833870";
|
||||||
|
const elysianRoleId = "1486144823684628490";
|
||||||
|
const apotheosisRoleId = "1479966598210129991";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
|
||||||
|
* Fails silently so role grant errors do not affect the auth flow.
|
||||||
|
* @param discordId - The Discord user ID to grant the role to.
|
||||||
|
* @returns True if the player is in the guild and the role was granted, false otherwise.
|
||||||
|
*/
|
||||||
|
const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
|
||||||
|
if (botToken === undefined || botToken === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bot ${botToken}`,
|
||||||
|
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.ok || response.status === 204;
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_elysian_role",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grants the apotheosis Discord role to the given player if configured.
|
* Grants the apotheosis Discord role to the given player if configured.
|
||||||
* Fails silently so role grant errors do not affect the game action.
|
* Fails silently so role grant errors do not affect the game action.
|
||||||
@@ -15,26 +66,29 @@ const discordApi = "https://discord.com/api/v10";
|
|||||||
*/
|
*/
|
||||||
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
const guildId = process.env.DISCORD_GUILD_ID;
|
|
||||||
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
|
|
||||||
|
|
||||||
if (
|
if (botToken === undefined || botToken === "") {
|
||||||
botToken === undefined || botToken === ""
|
|
||||||
|| guildId === undefined || guildId === ""
|
|
||||||
|| roleId === undefined || roleId === ""
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(
|
await fetch(
|
||||||
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
|
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bot ${botToken}` },
|
headers: {
|
||||||
|
"Authorization": `Bot ${botToken}`,
|
||||||
|
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
|
||||||
|
},
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_apotheosis_role",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — role grant failure must not affect the apotheosis
|
// Graceful degradation — role grant failure must not affect the apotheosis
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,13 +131,22 @@ const postMilestoneWebhook = async(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(webhookUrl, {
|
await fetch(webhookUrl, {
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
flags: suppressNotifications,
|
||||||
|
}),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"webhook_milestone",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
// Graceful degradation — webhook failure must not affect the game action
|
// Graceful degradation — webhook failure must not affect the game action
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { grantApotheosisRole, postMilestoneWebhook };
|
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
|
||||||
|
|||||||
@@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({
|
|||||||
verifyToken: vi.fn(),
|
verifyToken: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe("authMiddleware", () => {
|
describe("authMiddleware", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeApp = async () => {
|
const makeApp = async () => {
|
||||||
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
||||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
const app = new Hono<{ Variables: { discordId: string } }>();
|
const app = new Hono<{ Variables: { discordId: string } }>();
|
||||||
app.use("*", authMiddleware);
|
app.use("*", authMiddleware);
|
||||||
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
||||||
return { app, verifyToken };
|
return { app, logger, verifyToken };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("returns 401 when Authorization header is missing", async () => {
|
it("returns 401 when Authorization header is missing", async () => {
|
||||||
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
|
|||||||
expect(body.discordId).toBe("user_123");
|
expect(body.discordId).toBe("user_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when verifyToken throws", async () => {
|
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
|
||||||
const { app, verifyToken } = await makeApp();
|
const { app, logger, verifyToken } = await makeApp();
|
||||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
throw new Error("Invalid token");
|
throw new Error("Invalid token");
|
||||||
});
|
});
|
||||||
@@ -54,5 +62,39 @@ describe("authMiddleware", () => {
|
|||||||
headers: { Authorization: "Bearer bad_token" },
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||||
|
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
|
"auth_middleware",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
|
||||||
|
const { app, logger, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw "raw string error";
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||||
|
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
|
"auth_middleware",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 without logging when token has expired", async () => {
|
||||||
|
const { app, logger, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Token has expired");
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer expired_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||||
|
expect((logger.error as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns apotheosis count on success", async () => {
|
it("returns apotheosis count on success", async () => {
|
||||||
// Need all 15 transcendence upgrades purchased for eligibility
|
// Need all 15 transcendence upgrades purchased for eligibility
|
||||||
const allUpgradeIds = [
|
const allUpgradeIds = [
|
||||||
|
|||||||
@@ -113,5 +113,14 @@ describe("auth route", () => {
|
|||||||
const location = res.headers.get("Location") ?? "";
|
const location = res.headers.get("Location") ?? "";
|
||||||
expect(location).toContain("error=auth_failed");
|
expect(location).toContain("error=auth_failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects with error when callback throws a non-Error value", async () => {
|
||||||
|
const { app, exchangeCode } = await makeApp();
|
||||||
|
exchangeCode.mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const location = res.headers.get("Location") ?? "";
|
||||||
|
expect(location).toContain("error=auth_failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,4 +293,114 @@ describe("boss route", () => {
|
|||||||
const body = await res.json() as { won: boolean };
|
const body = await res.json() as { won: boolean };
|
||||||
expect(body.won).toBe(true);
|
expect(body.won).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles zone unlock gracefully when exploration state is undefined", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||||
|
quests: [],
|
||||||
|
exploration: undefined,
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { won: boolean };
|
||||||
|
expect(body.won).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks exploration areas when a zone is unlocked on boss defeat", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||||
|
quests: [],
|
||||||
|
exploration: {
|
||||||
|
areas: [{ id: "test_area", status: "locked" as const }],
|
||||||
|
materials: [],
|
||||||
|
craftedRecipeIds: [],
|
||||||
|
craftedGoldMultiplier: 1,
|
||||||
|
craftedEssenceMultiplier: 1,
|
||||||
|
craftedClickMultiplier: 1,
|
||||||
|
craftedCombatMultiplier: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
let savedState: GameState | undefined;
|
||||||
|
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
|
||||||
|
savedState = (args as { data: { state: GameState } }).data.state;
|
||||||
|
return {} as never;
|
||||||
|
});
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Exploration area should remain locked — no matching defaultExploration for "test_area"
|
||||||
|
const area = savedState?.exploration?.areas.find((a) => a.id === "test_area");
|
||||||
|
expect(area?.status).toBe("locked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss()] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeDefined();
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits signature in response when ANTI_CHEAT_SECRET is not set", async () => {
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss()] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss({
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
currentHp: 100,
|
||||||
|
damagePerSecond: 1,
|
||||||
|
maxHp: 100,
|
||||||
|
})] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
|
||||||
|
expect(body.won).toBe(true);
|
||||||
|
expect(body.rewards.bountyRunestones).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -143,4 +143,44 @@ describe("craft route", () => {
|
|||||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||||
expect(body.bonusType).toBe("gold_income");
|
expect(body.bonusType).toBe("gold_income");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
dailyChallenges: {
|
||||||
|
date: "2024-01-15",
|
||||||
|
challenges: [
|
||||||
|
{
|
||||||
|
completed: false,
|
||||||
|
id: "2024-01-15_crafting",
|
||||||
|
label: "Craft 1 recipe",
|
||||||
|
progress: 0,
|
||||||
|
rewardCrystals: 75,
|
||||||
|
target: 1,
|
||||||
|
type: "crafting",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||||
|
data: { state: GameState };
|
||||||
|
};
|
||||||
|
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
|
||||||
|
expect(updateArg.data.state.resources.crystals).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,99 @@ describe("explore route", () => {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const getClaimable = (areaId?: string) => {
|
||||||
|
const url = areaId === undefined
|
||||||
|
? "http://localhost/explore/claimable"
|
||||||
|
: `http://localhost/explore/claimable?areaId=${areaId}`;
|
||||||
|
return app.fetch(new Request(url));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GET /claimable", () => {
|
||||||
|
it("returns 400 when areaId is missing", async () => {
|
||||||
|
const res = await getClaimable();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for unknown area", async () => {
|
||||||
|
const res = await getClaimable("nonexistent_area");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when no save is found", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=false when no exploration state exists", async () => {
|
||||||
|
const state = makeState({ exploration: undefined });
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=false when area is not in_progress", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=false when exploration is still in progress", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: {
|
||||||
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
|
||||||
|
materials: [],
|
||||||
|
craftedRecipeIds: [],
|
||||||
|
craftedGoldMultiplier: 1,
|
||||||
|
craftedEssenceMultiplier: 1,
|
||||||
|
craftedClickMultiplier: 1,
|
||||||
|
craftedCombatMultiplier: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns claimable=true when exploration is complete", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: {
|
||||||
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
||||||
|
materials: [],
|
||||||
|
craftedRecipeIds: [],
|
||||||
|
craftedGoldMultiplier: 1,
|
||||||
|
craftedEssenceMultiplier: 1,
|
||||||
|
craftedClickMultiplier: 1,
|
||||||
|
craftedCombatMultiplier: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { claimable: boolean };
|
||||||
|
expect(body.claimable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await getClaimable(TEST_AREA_ID);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /start", () => {
|
describe("POST /start", () => {
|
||||||
it("returns 400 when areaId is missing", async () => {
|
it("returns 400 when areaId is missing", async () => {
|
||||||
const res = await postStart({});
|
const res = await postStart({});
|
||||||
@@ -153,6 +246,22 @@ describe("explore route", () => {
|
|||||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists endsAt to the DB state on exploration start", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { areaId: string; endsAt: number };
|
||||||
|
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]?.[0];
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test accesses nested mock data */
|
||||||
|
const savedState = (updateCall?.data as { state?: { exploration?: { areas?: Array<{ id: string; endsAt?: number }> } } }).state;
|
||||||
|
const savedArea = savedState?.exploration?.areas?.find((a) => {
|
||||||
|
return a.id === TEST_AREA_ID;
|
||||||
|
});
|
||||||
|
expect(savedArea?.endsAt).toBe(body.endsAt);
|
||||||
|
});
|
||||||
|
|
||||||
it("backfills exploration state for old saves without exploration", async () => {
|
it("backfills exploration state for old saves without exploration", async () => {
|
||||||
const state = makeState({ exploration: undefined });
|
const state = makeState({ exploration: undefined });
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
@@ -406,5 +515,31 @@ describe("explore route", () => {
|
|||||||
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
||||||
mockRandom.mockRestore();
|
mockRandom.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on collect", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /start error path", () => {
|
||||||
|
it("returns 500 when the database throws on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value on start", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("frontend route", () => {
|
||||||
|
let loggerMock: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
loggerMock = logger as typeof loggerMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeApp = async () => {
|
||||||
|
const { frontendRouter } = await import("../../src/routes/frontend.js");
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/frontend", frontendRouter);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postLog = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const postError = async (body: unknown, contentType = "application/json") => {
|
||||||
|
const app = await makeApp();
|
||||||
|
return app.fetch(new Request("http://localhost/frontend/error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POST /log", () => {
|
||||||
|
it("returns 200 when level is debug and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "debug", message: "test debug" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is info and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "info", message: "test info" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 when level is warn and message is present", async () => {
|
||||||
|
const res = await postLog({ level: "warn", message: "test warn" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is invalid", async () => {
|
||||||
|
const res = await postLog({ level: "error", message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("level and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when level is missing", async () => {
|
||||||
|
const res = await postLog({ message: "test" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message is missing", async () => {
|
||||||
|
const res = await postLog({ level: "info" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postLog("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postLog({ level: "info", message: "test" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /error", () => {
|
||||||
|
it("returns 200 with valid context and message", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when context field is missing", async () => {
|
||||||
|
const res = await postError({ message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("context and message are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when message field is missing", async () => {
|
||||||
|
const res = await postError({ context: "SomeComponent" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when request body is invalid JSON", async () => {
|
||||||
|
const res = await postError("not valid json at all", "application/json");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
|
||||||
|
loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; });
|
||||||
|
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,8 +19,12 @@ vi.mock("../../src/middleware/auth.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/discord.js", () => ({
|
||||||
|
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
const DISCORD_ID = "test_discord_id";
|
const DISCORD_ID = "test_discord_id";
|
||||||
const CURRENT_SCHEMA_VERSION = 1;
|
const CURRENT_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||||
@@ -200,6 +204,75 @@ describe("game route", () => {
|
|||||||
expect(body.offlineGold).toBeGreaterThan(0);
|
expect(body.offlineGold).toBeGreaterThan(0);
|
||||||
expect(body.offlineEssence).toBeGreaterThan(0);
|
expect(body.offlineEssence).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("syncs updated avatar from Discord into the returned state", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { state: GameState };
|
||||||
|
expect(body.state.player.avatar).toBe("new_hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading when the avatar DB update fails", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
||||||
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stored avatar when Discord returns null", async () => {
|
||||||
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
||||||
|
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { state: GameState };
|
||||||
|
expect(body.state.player.avatar).toBe("stored_hash");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /save", () => {
|
describe("POST /save", () => {
|
||||||
@@ -233,6 +306,16 @@ describe("game route", () => {
|
|||||||
expect(body.savedAt).toBeGreaterThan(0);
|
expect(body.savedAt).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to state characterName when playerRecord is null", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||||
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||||
|
const state = makeState();
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it("validates and sanitizes state when previous record exists", async () => {
|
it("validates and sanitizes state when previous record exists", async () => {
|
||||||
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
||||||
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
|
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
|
||||||
@@ -394,6 +477,28 @@ describe("game route", () => {
|
|||||||
expect(body.savedAt).toBeGreaterThan(0);
|
expect(body.savedAt).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
|
||||||
|
const prevUpgrades = [
|
||||||
|
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
|
||||||
|
] as GameState["upgrades"];
|
||||||
|
const prevState = makeState({
|
||||||
|
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
|
||||||
|
upgrades: prevUpgrades,
|
||||||
|
});
|
||||||
|
const incomingState = makeState({
|
||||||
|
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||||
|
upgrades: [
|
||||||
|
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
|
||||||
|
] as GameState["upgrades"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
|
||||||
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await save({ state: incomingState });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it("validates companion when active companion is legitimately unlocked", async () => {
|
it("validates companion when active companion is legitimately unlocked", async () => {
|
||||||
const prevState = makeState();
|
const prevState = makeState();
|
||||||
const stateWithCompanion = makeState({
|
const stateWithCompanion = makeState({
|
||||||
@@ -410,6 +515,45 @@ describe("game route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /load error path", () => {
|
||||||
|
it("returns 500 when the database throws during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during load", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /save error path", () => {
|
||||||
|
const save = (body: Record<string, unknown>) =>
|
||||||
|
app.fetch(new Request("http://localhost/game/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during save", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await save({ state });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /reset", () => {
|
describe("POST /reset", () => {
|
||||||
const reset = () =>
|
const reset = () =>
|
||||||
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
||||||
@@ -440,5 +584,17 @@ describe("game route", () => {
|
|||||||
const body = await res.json() as { signature: string | undefined };
|
const body = await res.json() as { signature: string | undefined };
|
||||||
expect(typeof body.signature).toBe("string");
|
expect(typeof body.signature).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during reset", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await reset();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
|
|||||||
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
expect(typeof body.entries[0]?.activeTitle).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value", async () => {
|
||||||
|
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await get();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to 0 for game-state categories when state is missing", async () => {
|
it("defaults to 0 for game-state categories when state is missing", async () => {
|
||||||
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
|
||||||
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types";
|
|||||||
|
|
||||||
vi.mock("../../src/db/client.js", () => ({
|
vi.mock("../../src/db/client.js", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
player: { update: vi.fn() },
|
player: { findUnique: vi.fn(), update: vi.fn() },
|
||||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
|||||||
describe("prestige route", () => {
|
describe("prestige route", () => {
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
player: { update: ReturnType<typeof vi.fn> };
|
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -81,10 +81,20 @@ describe("prestige route", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
|
||||||
|
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns runestones on successful prestige", async () => {
|
it("returns runestones on successful prestige", async () => {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -93,6 +103,26 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 409 when a concurrent prestige already committed", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 0 } as never);
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during prestige", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
it("updates daily challenge progress when dailyChallenges are set", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
dailyChallenges: {
|
dailyChallenges: {
|
||||||
@@ -100,14 +130,26 @@ describe("prestige route", () => {
|
|||||||
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
||||||
} as GameState["dailyChallenges"],
|
} as GameState["dailyChallenges"],
|
||||||
});
|
});
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
||||||
expect(body.newPrestigeCount).toBe(1);
|
expect(body.newPrestigeCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips webhook when enablePrestigeAnnouncements is false", async () => {
|
||||||
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /buy-upgrade", () => {
|
describe("POST /buy-upgrade", () => {
|
||||||
@@ -152,5 +194,17 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
expect(body.runestonesRemaining).toBe(90); // 100 - 10
|
||||||
expect(body.purchasedUpgradeIds).toContain("income_1");
|
expect(body.purchasedUpgradeIds).toContain("income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,6 +181,36 @@ describe("profile route", () => {
|
|||||||
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
|
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
|
||||||
expect(unknown?.name).toBe("unknown_title_id");
|
expect(unknown?.name).toBe("unknown_title_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile get", async () => {
|
||||||
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes completed story chapters in profile response", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
story: {
|
||||||
|
unlockedChapterIds: [ "boss_troll_king" ],
|
||||||
|
completedChapters: [ { chapterId: "boss_troll_king", choiceId: "fight" } ],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as {
|
||||||
|
completedChapters: Array<{ chapterId: string; choiceId: string }>;
|
||||||
|
};
|
||||||
|
expect(body.completedChapters).toHaveLength(1);
|
||||||
|
expect(body.completedChapters[0]).toMatchObject({ chapterId: "boss_troll_king", choiceId: "fight" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PUT /", () => {
|
describe("PUT /", () => {
|
||||||
@@ -238,5 +268,23 @@ describe("profile route", () => {
|
|||||||
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
||||||
expect(body.profileSettings.numberFormat).toBe("suffix");
|
expect(body.profileSettings.numberFormat).toBe("suffix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during profile update", async () => {
|
||||||
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await put({
|
||||||
|
characterName: "NewName",
|
||||||
|
profileSettings: { numberFormat: "suffix" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
vi.mock("../../src/db/client.js", () => ({
|
||||||
|
prisma: {
|
||||||
|
gameState: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeState = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
quests: [],
|
||||||
|
exploration: { areas: [] },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("timers route", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { timersRouter } = await import("../../src/routes/timers.js");
|
||||||
|
const { prisma: p } = await import("../../src/db/client.js");
|
||||||
|
prisma = p as typeof prisma;
|
||||||
|
app = new Hono();
|
||||||
|
app.route("/timers", timersRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = (userId: string) =>
|
||||||
|
app.fetch(new Request(`http://localhost/timers/${userId}`));
|
||||||
|
|
||||||
|
it("returns 400 for a non-numeric user ID", async () => {
|
||||||
|
const res = await get("not-a-number");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Invalid user ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when player is not found", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||||
|
const res = await get("123456789");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Player not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty arrays when no active quests or explorations", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({
|
||||||
|
state: makeState(),
|
||||||
|
});
|
||||||
|
const res = await get("123456789");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { quests: unknown[]; explorations: unknown[] };
|
||||||
|
expect(body.quests).toEqual([]);
|
||||||
|
expect(body.explorations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns active quest timers with endsAt computed from startedAt + duration", async () => {
|
||||||
|
const startedAt = Date.now() - 30_000;
|
||||||
|
const state = makeState({
|
||||||
|
quests: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
name: "Forest Patrol",
|
||||||
|
status: "active",
|
||||||
|
startedAt: startedAt,
|
||||||
|
durationSeconds: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||||
|
const res = await get("123456789");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as {
|
||||||
|
quests: Array<{ questId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||||
|
};
|
||||||
|
expect(body.quests).toHaveLength(1);
|
||||||
|
expect(body.quests[0]?.questId).toBe("q1");
|
||||||
|
expect(body.quests[0]?.name).toBe("Forest Patrol");
|
||||||
|
expect(body.quests[0]?.endsAt).toBe(startedAt + 600_000);
|
||||||
|
expect(body.quests[0]?.timeLeft).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out quests that are not in_progress", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [
|
||||||
|
{ id: "q1", name: "Done Quest", status: "completed", startedAt: 0, durationSeconds: 60 },
|
||||||
|
{ id: "q2", name: "Idle Quest", status: "available", durationSeconds: 60 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||||
|
const res = await get("123456789");
|
||||||
|
const body = await res.json() as { quests: unknown[] };
|
||||||
|
expect(body.quests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns timeLeft of 0 for already-completed quests still marked in_progress", async () => {
|
||||||
|
const startedAt = Date.now() - 700_000;
|
||||||
|
const state = makeState({
|
||||||
|
quests: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
name: "Old Quest",
|
||||||
|
status: "active",
|
||||||
|
startedAt: startedAt,
|
||||||
|
durationSeconds: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||||
|
const res = await get("123456789");
|
||||||
|
const body = await res.json() as {
|
||||||
|
quests: Array<{ timeLeft: number }>;
|
||||||
|
};
|
||||||
|
expect(body.quests[0]?.timeLeft).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns active exploration timers", async () => {
|
||||||
|
const endsAt = Date.now() + 120_000;
|
||||||
|
const state = makeState({
|
||||||
|
exploration: {
|
||||||
|
areas: [
|
||||||
|
{ id: "verdant_meadows", status: "in_progress", endsAt },
|
||||||
|
{ id: "unknown_area_xyz", status: "in_progress", endsAt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||||
|
const res = await get("123456789");
|
||||||
|
const body = await res.json() as {
|
||||||
|
explorations: Array<{ areaId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||||
|
};
|
||||||
|
expect(body.explorations).toHaveLength(2);
|
||||||
|
expect(body.explorations[0]?.areaId).toBe("verdant_meadows");
|
||||||
|
expect(body.explorations[0]?.endsAt).toBe(endsAt);
|
||||||
|
expect(body.explorations[0]?.timeLeft).toBeGreaterThan(0);
|
||||||
|
// Unknown area falls back to ID as name
|
||||||
|
expect(body.explorations[1]?.name).toBe("unknown_area_xyz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out explorations not in_progress", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
exploration: {
|
||||||
|
areas: [
|
||||||
|
{ id: "area1", status: "available" },
|
||||||
|
{ id: "area2", status: "completed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||||
|
const res = await get("123456789");
|
||||||
|
const body = await res.json() as { explorations: unknown[] };
|
||||||
|
expect(body.explorations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing exploration state gracefully", async () => {
|
||||||
|
const state = { quests: [] };
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||||
|
const res = await get("123456789");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { explorations: unknown[] };
|
||||||
|
expect(body.explorations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 on database error", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(
|
||||||
|
new Error("DB failure"),
|
||||||
|
);
|
||||||
|
const res = await get("123456789");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json() as { error: string };
|
||||||
|
expect(body.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 and logs non-Error throws", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||||
|
const res = await get("123456789");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
|
|||||||
expect(body.newTranscendenceCount).toBe(1);
|
expect(body.newTranscendenceCount).toBe(1);
|
||||||
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
expect(body.echoes).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /buy-upgrade", () => {
|
describe("POST /buy-upgrade", () => {
|
||||||
@@ -146,8 +158,20 @@ describe("transcendence route", () => {
|
|||||||
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
|
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
|
||||||
expect(body.echoesRemaining).toBe(95); // 100 - 5
|
expect(body.echoesRemaining).toBe(98); // 100 - 2
|
||||||
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||||
|
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,13 +46,37 @@ describe("generateDailyChallenges", () => {
|
|||||||
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates different challenges for different dates", async () => {
|
it("always includes a clicks challenge regardless of date", async () => {
|
||||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
const day1 = generateDailyChallenges("2024-01-15");
|
const day1 = generateDailyChallenges("2024-01-15");
|
||||||
const day2 = generateDailyChallenges("2024-01-16");
|
const day2 = generateDailyChallenges("2024-01-16");
|
||||||
// They should differ in at least one challenge ID (types vary by seed)
|
expect(day1.some((c) => c.type === "clicks")).toBe(true);
|
||||||
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
|
expect(day2.some((c) => c.type === "clicks")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always includes a crafting challenge regardless of date", async () => {
|
||||||
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
|
const day1 = generateDailyChallenges("2024-01-15");
|
||||||
|
const day2 = generateDailyChallenges("2024-01-16");
|
||||||
|
expect(day1.some((c) => c.type === "crafting")).toBe(true);
|
||||||
|
expect(day2.some((c) => c.type === "crafting")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("progression challenge slot varies across different dates", async () => {
|
||||||
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
|
// 2024-01-01 picks bossesDefeated, 2024-01-02 picks prestige (verified by seed)
|
||||||
|
const day1 = generateDailyChallenges("2024-01-01");
|
||||||
|
const day2 = generateDailyChallenges("2024-01-02");
|
||||||
|
const day1ProgressionType = day1.find((c) => {
|
||||||
|
return c.type !== "clicks" && c.type !== "crafting";
|
||||||
|
})?.type;
|
||||||
|
const day2ProgressionType = day2.find((c) => {
|
||||||
|
return c.type !== "clicks" && c.type !== "crafting";
|
||||||
|
})?.type;
|
||||||
|
expect(day1ProgressionType).not.toBe(day2ProgressionType);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,51 +18,31 @@ describe("discord service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildOAuthUrl", () => {
|
describe("buildOAuthUrl", () => {
|
||||||
it("throws when DISCORD_CLIENT_ID is missing", async () => {
|
|
||||||
delete process.env["DISCORD_CLIENT_ID"];
|
|
||||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
|
||||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
|
||||||
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
|
|
||||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
|
||||||
delete process.env["DISCORD_REDIRECT_URI"];
|
|
||||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
|
||||||
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a URL with correct query params", async () => {
|
it("returns a URL with correct query params", async () => {
|
||||||
process.env["DISCORD_CLIENT_ID"] = "client123";
|
|
||||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
|
||||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||||
const url = buildOAuthUrl();
|
const url = buildOAuthUrl();
|
||||||
expect(url).toContain("client_id=client123");
|
expect(url).toContain("client_id=1479551654264049908");
|
||||||
expect(url).toContain("response_type=code");
|
expect(url).toContain("response_type=code");
|
||||||
expect(url).toContain("scope=identify");
|
expect(url).toContain("scope=identify");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("exchangeCode", () => {
|
describe("exchangeCode", () => {
|
||||||
it("throws when env vars are missing", async () => {
|
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
|
||||||
delete process.env["DISCORD_CLIENT_ID"];
|
delete process.env["DISCORD_CLIENT_SECRET"];
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when response is not ok", async () => {
|
it("throws when response is not ok", async () => {
|
||||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns parsed body on success", async () => {
|
it("returns parsed body on success", async () => {
|
||||||
process.env["DISCORD_CLIENT_ID"] = "cid";
|
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
|
||||||
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
@@ -86,5 +66,69 @@ describe("discord service", () => {
|
|||||||
expect(result.id).toBe("123");
|
expect(result.id).toBe("123");
|
||||||
expect(result.username).toBe("testuser");
|
expect(result.username).toBe("testuser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUser } = await import("../../src/services/discord.js");
|
||||||
|
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exchangeCode non-Error throw", () => {
|
||||||
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchDiscordUserById", () => {
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is missing", async () => {
|
||||||
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when DISCORD_BOT_TOKEN is empty", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "";
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when response is not ok", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when fetch throws a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user on success", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" };
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
|
||||||
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
||||||
|
const result = await fetchDiscordUserById("123456");
|
||||||
|
expect(result).toMatchObject({ id: "123456", avatar: "abc123" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../src/db/client.js", () => ({
|
||||||
|
prisma: {
|
||||||
|
player: { updateMany: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
log: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from "../../src/db/client.js";
|
||||||
|
|
||||||
|
const discordGuildId = "1354624415861833870";
|
||||||
|
|
||||||
|
describe("gateway service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleGuildMemberAdd", () => {
|
||||||
|
it("sets inGuild to true for the matching guild", async () => {
|
||||||
|
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||||
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
|
await handleGuildMemberAdd("user123", discordGuildId);
|
||||||
|
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||||
|
data: { inGuild: true },
|
||||||
|
where: { discordId: "user123" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when guild id does not match the configured guild", async () => {
|
||||||
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
|
await handleGuildMemberAdd("user123", "other_guild");
|
||||||
|
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when prisma throws an Error", async () => {
|
||||||
|
const dbError = new Error("DB failure");
|
||||||
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
||||||
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
await handleGuildMemberAdd("user123", discordGuildId);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when prisma throws a non-Error", async () => {
|
||||||
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
||||||
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
await handleGuildMemberAdd("user123", discordGuildId);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
"gateway_member_add",
|
||||||
|
new Error("raw error"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleGuildMemberRemove", () => {
|
||||||
|
it("sets inGuild to false for the matching guild", async () => {
|
||||||
|
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||||
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
|
await handleGuildMemberRemove("user123", discordGuildId);
|
||||||
|
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||||
|
data: { inGuild: false },
|
||||||
|
where: { discordId: "user123" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when guild id does not match the configured guild", async () => {
|
||||||
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
|
await handleGuildMemberRemove("user123", "other_guild");
|
||||||
|
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when prisma throws an Error", async () => {
|
||||||
|
const dbError = new Error("DB failure");
|
||||||
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
||||||
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
await handleGuildMemberRemove("user123", discordGuildId);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when prisma throws a non-Error", async () => {
|
||||||
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
||||||
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
|
await handleGuildMemberRemove("user123", discordGuildId);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
"gateway_member_remove",
|
||||||
|
new Error("raw error"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,14 +13,24 @@ import {
|
|||||||
} from "../../src/services/prestige.js";
|
} from "../../src/services/prestige.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
const makePlayer = (totalGoldEarned: number) => ({
|
const makePlayer = (
|
||||||
discordId: "test_id",
|
totalGoldEarned: number,
|
||||||
username: "testuser",
|
lifetimeGoldEarned = 0,
|
||||||
discriminator: "0",
|
totalClicks = 0,
|
||||||
|
) => ({
|
||||||
avatar: null,
|
avatar: null,
|
||||||
totalGoldEarned,
|
|
||||||
totalClicks: 0,
|
|
||||||
characterName: "Tester",
|
characterName: "Tester",
|
||||||
|
discordId: "test_id",
|
||||||
|
discriminator: "0",
|
||||||
|
lifetimeAchievementsUnlocked: 0,
|
||||||
|
lifetimeAdventurersRecruited: 0,
|
||||||
|
lifetimeBossesDefeated: 0,
|
||||||
|
lifetimeClicks: 0,
|
||||||
|
lifetimeGoldEarned: lifetimeGoldEarned,
|
||||||
|
lifetimeQuestsCompleted: 0,
|
||||||
|
totalClicks: totalClicks,
|
||||||
|
totalGoldEarned: totalGoldEarned,
|
||||||
|
username: "testuser",
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||||
@@ -45,15 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
|||||||
|
|
||||||
describe("calculatePrestigeThreshold", () => {
|
describe("calculatePrestigeThreshold", () => {
|
||||||
it("returns base threshold at count 0", () => {
|
it("returns base threshold at count 0", () => {
|
||||||
|
// base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
|
||||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 5× at count 1", () => {
|
it("returns base × 2^2.5 at count 1", () => {
|
||||||
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
|
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
|
||||||
|
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 25× at count 2", () => {
|
it("returns base × 3^2.5 at count 2", () => {
|
||||||
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
|
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
|
||||||
|
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies threshold multiplier correctly", () => {
|
it("applies threshold multiplier correctly", () => {
|
||||||
@@ -89,21 +102,27 @@ describe("isEligibleForPrestige", () => {
|
|||||||
|
|
||||||
describe("calculateRunestones", () => {
|
describe("calculateRunestones", () => {
|
||||||
it("calculates basic runestones formula", () => {
|
it("calculates basic runestones formula", () => {
|
||||||
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
|
// floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||||
expect(result).toBe(20);
|
expect(result).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies echo runestone multiplier", () => {
|
it("applies echo runestone multiplier", () => {
|
||||||
// floor(sqrt(4) × 10) = 20; × 2 = 40
|
// floor(cbrt(4)) × 20 = 20; × 2 = 40
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||||||
expect(result).toBe(40);
|
expect(result).toBe(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies purchased runestone upgrade multiplier", () => {
|
it("applies purchased runestone upgrade multiplier", () => {
|
||||||
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
// With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||||||
expect(result).toBeGreaterThan(20);
|
expect(result).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps base runestones before multipliers", () => {
|
||||||
|
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
|
||||||
|
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||||
|
expect(result).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
|
|||||||
expect(calculateProductionMultiplier(0)).toBe(1);
|
expect(calculateProductionMultiplier(0)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 1.15 at count 1", () => {
|
it("returns 1.3 at count 1", () => {
|
||||||
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
|
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scales exponentially", () => {
|
it("scales exponentially", () => {
|
||||||
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
|
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,12 +151,12 @@ describe("calculateMilestoneBonus", () => {
|
|||||||
expect(calculateMilestoneBonus(5)).toBe(25);
|
expect(calculateMilestoneBonus(5)).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 50 at prestige 10", () => {
|
it("returns 100 at prestige 10", () => {
|
||||||
expect(calculateMilestoneBonus(10)).toBe(50);
|
expect(calculateMilestoneBonus(10)).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 75 at prestige 15", () => {
|
it("returns 225 at prestige 15", () => {
|
||||||
expect(calculateMilestoneBonus(15)).toBe(75);
|
expect(calculateMilestoneBonus(15)).toBe(225);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,10 +255,172 @@ describe("buildPostPrestigeState", () => {
|
|||||||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||||
|
});
|
||||||
|
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
|
||||||
|
const state = makeMinimalState();
|
||||||
|
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves apotheosis data across prestige", () => {
|
it("preserves apotheosis data across prestige", () => {
|
||||||
const apotheosis = { count: 2 };
|
const apotheosis = { count: 2 };
|
||||||
const state = makeMinimalState({ apotheosis });
|
const state = makeMinimalState({ apotheosis });
|
||||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accumulates current-run gold into lifetime total", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
player: makePlayer(4_000_000, 1_000_000),
|
||||||
|
});
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates current-run clicks into lifetime total", () => {
|
||||||
|
const state = makeMinimalState({
|
||||||
|
player: makePlayer(4_000_000, 0, 500),
|
||||||
|
});
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeClicks).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates defeated bosses into lifetime total", () => {
|
||||||
|
const defeatedBoss = {
|
||||||
|
bountyRunestones: 0,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "boss_1",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Boss One",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ defeatedBoss ] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeBossesDefeated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => {
|
||||||
|
const claimedBoss = {
|
||||||
|
bountyRunestones: 5,
|
||||||
|
bountyRunestonesClaimed: true,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "troll_king",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Troll King",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "verdant_vale",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||||||
|
return boss.id === "troll_king";
|
||||||
|
});
|
||||||
|
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
|
||||||
|
const legacyDefeatedBoss = {
|
||||||
|
bountyRunestones: 5,
|
||||||
|
crystalReward: 0,
|
||||||
|
currentHp: 0,
|
||||||
|
damagePerSecond: 10,
|
||||||
|
description: "A boss",
|
||||||
|
equipmentRewards: [] as string[],
|
||||||
|
essenceReward: 0,
|
||||||
|
goldReward: 100,
|
||||||
|
id: "troll_king",
|
||||||
|
maxHp: 100,
|
||||||
|
name: "Troll King",
|
||||||
|
prestigeRequirement: 0,
|
||||||
|
status: "defeated" as const,
|
||||||
|
upgradeRewards: [] as string[],
|
||||||
|
zoneId: "verdant_vale",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ bosses: [ legacyDefeatedBoss ] as GameState["bosses"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
const matchingBoss = prestigeState.bosses.find((boss) => {
|
||||||
|
return boss.id === "troll_king";
|
||||||
|
});
|
||||||
|
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates completed quests into lifetime total", () => {
|
||||||
|
const quest = {
|
||||||
|
id: "q_1",
|
||||||
|
name: "A Quest",
|
||||||
|
description: "Do the thing",
|
||||||
|
status: "completed" as const,
|
||||||
|
zoneId: "zone_1",
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates recruited adventurers into lifetime total", () => {
|
||||||
|
const adventurer = {
|
||||||
|
combatPower: 10,
|
||||||
|
count: 5,
|
||||||
|
essencePerSecond: 0,
|
||||||
|
goldPerSecond: 1,
|
||||||
|
id: "adv_1",
|
||||||
|
level: 1,
|
||||||
|
unlocked: true,
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves achievements from current state across prestige", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_persisted",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.achievements).toEqual([ achievement ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates unlocked achievements into lifetime total", () => {
|
||||||
|
const achievement = {
|
||||||
|
description: "Did a thing",
|
||||||
|
id: "ach_1",
|
||||||
|
name: "Achiever",
|
||||||
|
requirement: 1,
|
||||||
|
type: "totalClicks" as const,
|
||||||
|
unlockedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
|
||||||
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
|
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,20 +97,21 @@ describe("isEligibleForTranscendence", () => {
|
|||||||
|
|
||||||
describe("calculateEchoes", () => {
|
describe("calculateEchoes", () => {
|
||||||
it("handles prestige count of 0 by treating it as 1", () => {
|
it("handles prestige count of 0 by treating it as 1", () => {
|
||||||
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
|
// safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224
|
||||||
expect(calculateEchoes(0, 1)).toBe(853);
|
expect(calculateEchoes(0, 1)).toBe(224);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calculates echoes at count 1", () => {
|
it("calculates echoes at count 1", () => {
|
||||||
expect(calculateEchoes(1, 1)).toBe(853);
|
// floor(224 / sqrt(1)) = 224
|
||||||
|
expect(calculateEchoes(1, 1)).toBe(224);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("decreases echoes with higher prestige count", () => {
|
it("decreases echoes with higher prestige count", () => {
|
||||||
const echoesAt1 = calculateEchoes(1, 1);
|
const echoesAt1 = calculateEchoes(1, 1);
|
||||||
const echoesAt4 = calculateEchoes(4, 1);
|
const echoesAt4 = calculateEchoes(4, 1);
|
||||||
expect(echoesAt4).toBeLessThan(echoesAt1);
|
expect(echoesAt4).toBeLessThan(echoesAt1);
|
||||||
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
|
// floor(224 / sqrt(4)) = floor(224 / 2) = 112
|
||||||
expect(echoesAt4).toBe(426);
|
expect(echoesAt4).toBe(112);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies echoMetaMultiplier", () => {
|
it("applies echoMetaMultiplier", () => {
|
||||||
@@ -118,6 +119,11 @@ describe("calculateEchoes", () => {
|
|||||||
const withMult = calculateEchoes(1, 2);
|
const withMult = calculateEchoes(1, 2);
|
||||||
expect(withMult).toBe(base * 2);
|
expect(withMult).toBe(base * 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 50 echoes at the target prestige 20", () => {
|
||||||
|
// floor(224 / sqrt(20)) = floor(224 / 4.472) = floor(50.09) = 50
|
||||||
|
expect(calculateEchoes(20, 1)).toBe(50);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildPostTranscendenceState", () => {
|
describe("buildPostTranscendenceState", () => {
|
||||||
|
|||||||
@@ -20,40 +20,18 @@ describe("webhook service", () => {
|
|||||||
describe("grantApotheosisRole", () => {
|
describe("grantApotheosisRole", () => {
|
||||||
it("does nothing when bot token is missing", async () => {
|
it("does nothing when bot token is missing", async () => {
|
||||||
delete process.env["DISCORD_BOT_TOKEN"];
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
|
||||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await grantApotheosisRole("user123");
|
await grantApotheosisRole("user123");
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing when guild id is missing", async () => {
|
it("calls Discord API with correct URL and auth when bot token is set", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
|
||||||
delete process.env["DISCORD_GUILD_ID"];
|
|
||||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
|
||||||
await grantApotheosisRole("user123");
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when role id is missing", async () => {
|
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "token";
|
|
||||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
|
||||||
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
|
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
|
||||||
await grantApotheosisRole("user123");
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls Discord API with correct URL and auth when env vars are set", async () => {
|
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
process.env["DISCORD_GUILD_ID"] = "guild123";
|
|
||||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await grantApotheosisRole("user789");
|
await grantApotheosisRole("user789");
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
|
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||||
@@ -63,12 +41,74 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("swallows fetch errors gracefully", async () => {
|
it("swallows fetch errors gracefully", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
process.env["DISCORD_GUILD_ID"] = "g";
|
|
||||||
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("grantElysianRole", () => {
|
||||||
|
it("does nothing when bot token is missing", async () => {
|
||||||
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user123");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when Discord API responds with ok", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user789");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "PUT",
|
||||||
|
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when Discord API responds with 204", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when Discord API responds with an error status", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and swallows fetch errors gracefully", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and swallows non-Error fetch rejections", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("postMilestoneWebhook", () => {
|
describe("postMilestoneWebhook", () => {
|
||||||
@@ -88,9 +128,10 @@ describe("webhook service", () => {
|
|||||||
await postMilestoneWebhook("user123", "prestige", counts);
|
await postMilestoneWebhook("user123", "prestige", counts);
|
||||||
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
expect(url).toBe("https://discord.com/webhook/abc");
|
expect(url).toBe("https://discord.com/webhook/abc");
|
||||||
const body = JSON.parse(options.body as string) as { content: string };
|
const body = JSON.parse(options.body as string) as { content: string; flags: number };
|
||||||
expect(body.content).toContain("<@user123>");
|
expect(body.content).toContain("<@user123>");
|
||||||
expect(body.content).toContain("prestiged");
|
expect(body.content).toContain("prestiged");
|
||||||
|
expect(body.flags).toBe(4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("posts transcendence message correctly", async () => {
|
it("posts transcendence message correctly", async () => {
|
||||||
@@ -119,5 +160,12 @@ describe("webhook service", () => {
|
|||||||
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
|
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
|
||||||
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
|
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
|
||||||
|
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Elysium — Idle RPG</title>
|
<title>Elysium — Idle RPG</title>
|
||||||
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
|
||||||
|
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
<meta property="og:site_name" content="Elysium" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Elysium — Idle RPG" />
|
||||||
|
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
|
||||||
|
|
||||||
|
<!-- Plausible Analytics -->
|
||||||
|
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
|
||||||
|
|
||||||
|
<!-- Tree-Nation -->
|
||||||
|
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var interval = setInterval(function () {
|
||||||
|
if (typeof TreeNation !== "undefined") {
|
||||||
|
clearInterval(interval);
|
||||||
|
TreeNation.renderAll();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Google Ads -->
|
||||||
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,20 +12,21 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysium/types": "workspace:*",
|
"@elysium/types": "workspace:*",
|
||||||
"react": "19.0.0",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhcarrigan/eslint-config": "5.2.0",
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
"@nhcarrigan/typescript-config": "4.0.0",
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
"@types/react": "19.0.10",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.0.4",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"@vitest/coverage-v8": "3.0.8",
|
"@vitest/coverage-v8": "3.0.8",
|
||||||
"eslint": "9.22.0",
|
"eslint": "9.22.0",
|
||||||
"jsdom": "26.0.0",
|
"jsdom": "29.0.1",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"vite": "6.2.1",
|
"vite": "8.0.5",
|
||||||
"vitest": "3.0.8"
|
"vitest": "3.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,19 @@ import type {
|
|||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
CraftRecipeResponse,
|
CraftRecipeResponse,
|
||||||
|
ExploreClaimableResponse,
|
||||||
ExploreCollectRequest,
|
ExploreCollectRequest,
|
||||||
ExploreCollectResponse,
|
ExploreCollectResponse,
|
||||||
ExploreStartRequest,
|
ExploreStartRequest,
|
||||||
ExploreStartResponse,
|
ExploreStartResponse,
|
||||||
|
ForceUnlocksResponse,
|
||||||
LoadResponse,
|
LoadResponse,
|
||||||
PrestigeRequest,
|
PrestigeRequest,
|
||||||
PrestigeResponse,
|
PrestigeResponse,
|
||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
@@ -35,6 +38,26 @@ import type {
|
|||||||
|
|
||||||
const baseUrl = "/api";
|
const baseUrl = "/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a 4xx API error so callers can distinguish expected server
|
||||||
|
* rejections from unexpected failures. ValidationErrors are downgraded to
|
||||||
|
* console.warn and are not forwarded to the error-email pipeline.
|
||||||
|
*/
|
||||||
|
class ValidationError extends Error {
|
||||||
|
public readonly statusCode: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new ValidationError.
|
||||||
|
* @param message - The error message from the server response.
|
||||||
|
* @param statusCode - The HTTP status code (4xx) returned by the server.
|
||||||
|
*/
|
||||||
|
public constructor(message: string, statusCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getToken = (): string | null => {
|
const getToken = (): string | null => {
|
||||||
return globalThis.localStorage.getItem("elysium_token");
|
return globalThis.localStorage.getItem("elysium_token");
|
||||||
};
|
};
|
||||||
@@ -69,6 +92,14 @@ const fetchJson = async <T>(
|
|||||||
= typeof errorBody.error === "string"
|
= typeof errorBody.error === "string"
|
||||||
? errorBody.error
|
? errorBody.error
|
||||||
: "Unknown error";
|
: "Unknown error";
|
||||||
|
if (response.status === 401) {
|
||||||
|
globalThis.localStorage.removeItem("elysium_token");
|
||||||
|
globalThis.localStorage.removeItem("elysium_save_signature");
|
||||||
|
globalThis.location.href = "/";
|
||||||
|
}
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
|
throw new ValidationError(message, response.status);
|
||||||
|
}
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +273,19 @@ const collectExploration = async(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a given exploration area is ready to claim on the server.
|
||||||
|
* @param areaId - The area ID to check.
|
||||||
|
* @returns Whether the exploration is claimable.
|
||||||
|
*/
|
||||||
|
const checkExplorationClaimable = async(
|
||||||
|
areaId: string,
|
||||||
|
): Promise<ExploreClaimableResponse> => {
|
||||||
|
return await fetchJson<ExploreClaimableResponse>(
|
||||||
|
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crafts a recipe on the server.
|
* Crafts a recipe on the server.
|
||||||
* @param body - The craft recipe request payload.
|
* @param body - The craft recipe request payload.
|
||||||
@@ -256,6 +300,34 @@ const craftRecipe = async(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a request to fix any missing unlocks in the player's game state.
|
||||||
|
* @returns The corrected game state and counts of what was unlocked.
|
||||||
|
*/
|
||||||
|
const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
|
||||||
|
return await fetchJson<ForceUnlocksResponse>("/debug/force-unlocks", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs any content added after the player's save was created into their save.
|
||||||
|
* @returns The updated game state and counts of what was added per content type.
|
||||||
|
*/
|
||||||
|
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
|
||||||
|
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
||||||
|
* @returns The fresh game state as a LoadResponse.
|
||||||
|
*/
|
||||||
|
const debugHardReset = async(): Promise<LoadResponse> => {
|
||||||
|
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a public player profile by Discord ID.
|
* Fetches a public player profile by Discord ID.
|
||||||
* @param discordId - The Discord ID of the player to look up.
|
* @param discordId - The Discord ID of the player to look up.
|
||||||
@@ -282,12 +354,17 @@ const updateProfile = async(
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
ValidationError,
|
||||||
achieveApotheosis,
|
achieveApotheosis,
|
||||||
buyEchoUpgrade,
|
buyEchoUpgrade,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
challengeBoss,
|
challengeBoss,
|
||||||
|
checkExplorationClaimable,
|
||||||
collectExploration,
|
collectExploration,
|
||||||
craftRecipe,
|
craftRecipe,
|
||||||
|
debugHardReset,
|
||||||
|
forceUnlocks,
|
||||||
|
syncNewContent,
|
||||||
getAbout,
|
getAbout,
|
||||||
getAuthUrl,
|
getAuthUrl,
|
||||||
getPublicProfile,
|
getPublicProfile,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @file React Error Boundary for catching unhandled render-time errors.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { logError } from "../utils/logError.js";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProperties {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches unhandled render-time errors in the React tree, logs them to the
|
||||||
|
* backend telemetry service, and renders a fallback UI.
|
||||||
|
*/
|
||||||
|
class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProperties,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
// eslint-disable-next-line jsdoc/require-jsdoc -- React Error Boundary constructor is standard boilerplate
|
||||||
|
public constructor(properties: ErrorBoundaryProperties) {
|
||||||
|
super(properties);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates state so the next render shows the fallback UI.
|
||||||
|
* @returns The updated error boundary state.
|
||||||
|
*/
|
||||||
|
public static getDerivedStateFromError(): ErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the error to the backend telemetry service.
|
||||||
|
* @param error - The error that was thrown during render.
|
||||||
|
* @param info - React error info containing the component stack trace.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- React lifecycle method cannot be static
|
||||||
|
public override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
logError("react_error_boundary", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the fallback UI when an error is caught, otherwise renders children.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
public override render(): ReactNode {
|
||||||
|
const { hasError } = this.state;
|
||||||
|
const { children } = this.props;
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="error-screen">
|
||||||
|
<p>{"Something went wrong. Please refresh the page."}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
import { getAbout } from "../../api/client.js";
|
import { getAbout } from "../../api/client.js";
|
||||||
import type { AboutResponse } from "@elysium/types";
|
import type { AboutResponse } from "@elysium/types";
|
||||||
|
|
||||||
@@ -30,14 +31,24 @@ const howToPlay = [
|
|||||||
body:
|
body:
|
||||||
"Purchase upgrades to multiply the gold and essence output of specific"
|
"Purchase upgrades to multiply the gold and essence output of specific"
|
||||||
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
||||||
+ " for the current run and compound with each other.",
|
+ " for the current run and stack multiplicatively — two ×2 upgrades"
|
||||||
|
+ " targeting the same adventurer combine to give ×4, not ×3. Global"
|
||||||
|
+ " upgrades multiply on top of adventurer-specific ones, so stacking"
|
||||||
|
+ " both types compounds the effect significantly. Late in a run, look"
|
||||||
|
+ " for the Essence Infusion upgrades — five powerful global multipliers"
|
||||||
|
+ " purchasable purely with essence, giving that resource an ongoing"
|
||||||
|
+ " use when gold upgrades are all bought.",
|
||||||
title: "🔧 Upgrades",
|
title: "🔧 Upgrades",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Send your guild on quests that complete over time and reward gold,"
|
"Send your guild on quests that complete over time and reward gold,"
|
||||||
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
||||||
+ " simultaneously. Completing quests also unlocks new zones.",
|
+ " simultaneously. Completing quests also unlocks new zones."
|
||||||
|
+ " Each quest has a failure chance that increases in later zones"
|
||||||
|
+ " (from 10% in the starting zone up to 40% in the hardest zones)."
|
||||||
|
+ " If a quest fails, no rewards are granted and the quest resets —"
|
||||||
|
+ " your party must be sent again to retry it.",
|
||||||
title: "📜 Quests",
|
title: "📜 Quests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,10 +69,12 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
||||||
+ " bonuses to gold income, click power, or combat. Rarer equipment"
|
+ " bonuses to gold income, click power, or boss combat DPS. Rarer"
|
||||||
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
|
+ " equipment provides stronger bonuses. Note: combat bonuses only"
|
||||||
+ " named set) to unlock escalating set bonuses shown at the top of the"
|
+ " affect boss fights — quest combat power is determined solely by"
|
||||||
+ " Equipment panel.",
|
+ " your adventurers. Equip matching set pieces (2 or 3 of a named set)"
|
||||||
|
+ " to unlock escalating set bonuses shown at the top of the Equipment"
|
||||||
|
+ " panel.",
|
||||||
title: "🗡️ Equipment & Sets",
|
title: "🗡️ Equipment & Sets",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,7 +123,11 @@ const howToPlay = [
|
|||||||
+ " real-time and reward gold, essence, and crafting materials when"
|
+ " real-time and reward gold, essence, and crafting materials when"
|
||||||
+ " collected. Each area has a set duration — short explorations are"
|
+ " collected. Each area has a set duration — short explorations are"
|
||||||
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
||||||
+ " you've collected from at least once, unlocking a Codex entry.",
|
+ " you've collected from at least once, unlocking a Codex entry."
|
||||||
|
+ " Exploration zones are locked until the corresponding main-game"
|
||||||
|
+ " zone is unlocked — which requires defeating that zone's final boss"
|
||||||
|
+ " and completing its final quest. The Exploration tab shows the"
|
||||||
|
+ " specific boss and quest required for each locked zone.",
|
||||||
title: "🗺️ Exploration",
|
title: "🗺️ Exploration",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,10 +170,12 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
||||||
+ " Each item provides bonuses to gold income, combat power, or click"
|
+ " Each item provides bonuses to gold income, boss combat DPS, or click"
|
||||||
+ " power. Only one item per slot can be equipped at a time — visit the"
|
+ " power. Combat bonuses only affect boss fights — quest combat power"
|
||||||
+ " Equipment panel to manage your loadout. Your currently equipped"
|
+ " is determined solely by your adventurers. Only one item per slot"
|
||||||
+ " items are displayed on your character sheet and public profile.",
|
+ " can be equipped at a time — visit the Equipment panel to manage"
|
||||||
|
+ " your loadout. Your currently equipped items are displayed on your"
|
||||||
|
+ " character sheet and public profile.",
|
||||||
title: "🗡️ Equipment",
|
title: "🗡️ Equipment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -180,14 +199,16 @@ const howToPlay = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
|
"Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
|
||||||
+ " automatically sends your party on the highest-zone available quest"
|
+ " panels! Auto-Quest automatically sends your party on the"
|
||||||
+ " as soon as one completes, skipping quests whose combat power"
|
+ " highest-zone available quest as soon as one completes, skipping"
|
||||||
+ " requirement isn't met. Auto-Boss automatically challenges the"
|
+ " quests whose combat power requirement isn't met. Auto-Boss"
|
||||||
+ " highest available boss as soon as one is ready. Both can be toggled"
|
+ " automatically challenges the highest available boss as soon as one"
|
||||||
+ " on or off at any time using the 🤖 Auto button in each panel"
|
+ " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
|
||||||
+ " header.",
|
+ " runestones) automatically purchases the highest-tier adventurer you"
|
||||||
title: "🤖 Auto-Quest & Auto-Boss",
|
+ " can currently afford each tick, keeping your income growing after a"
|
||||||
|
+ " prestige without any manual clicks.",
|
||||||
|
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
@@ -211,7 +232,7 @@ const howToPlay = [
|
|||||||
{
|
{
|
||||||
body:
|
body:
|
||||||
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
||||||
+ " The Absolute One (requires Prestige 90). Transcending performs a"
|
+ " The Absolute One (requires Prestige 20). Transcending performs a"
|
||||||
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
||||||
+ " and equipment — but grants Echoes based on your prestige count"
|
+ " and equipment — but grants Echoes based on your prestige count"
|
||||||
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
||||||
@@ -256,6 +277,15 @@ const howToPlay = [
|
|||||||
+ " when you first enable them.",
|
+ " when you first enable them.",
|
||||||
title: "🔔 Sounds & Notifications",
|
title: "🔔 Sounds & Notifications",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"Have a question, found a bug, or want to suggest a feature? Join the"
|
||||||
|
+ " NHCarrigan community Discord at https://chat.nhcarrigan.com or open"
|
||||||
|
+ " a support ticket at https://support.nhcarrigan.com. You can also"
|
||||||
|
+ " report issues directly on the project repository. We'd love to hear"
|
||||||
|
+ " from you!",
|
||||||
|
title: "💬 Community & Support",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
@@ -331,7 +361,9 @@ const aboutPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expandedRelease === release.tag_name
|
{expandedRelease === release.tag_name
|
||||||
&& <pre className="about-release-body">{release.body}</pre>
|
&& <div className="about-release-body">
|
||||||
|
<Markdown>{release.body}</Markdown>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Achievement } from "@elysium/types";
|
import type { Achievement, GameState } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the plural form of a word based on a count.
|
* Returns the plural form of a word based on a count.
|
||||||
@@ -53,9 +54,50 @@ const conditionDescription = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the player's current progress value toward an achievement's unlock condition,
|
||||||
|
* mirroring the logic used by the tick engine's checkAchievements function.
|
||||||
|
* @param achievement - The achievement to evaluate progress for.
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns The current numeric progress toward the achievement condition.
|
||||||
|
*/
|
||||||
|
const getCurrentProgress = (
|
||||||
|
achievement: Achievement,
|
||||||
|
state: GameState,
|
||||||
|
): number => {
|
||||||
|
const { condition } = achievement;
|
||||||
|
switch (condition.type) {
|
||||||
|
case "totalGoldEarned":
|
||||||
|
return state.player.totalGoldEarned;
|
||||||
|
case "totalClicks":
|
||||||
|
return state.player.totalClicks;
|
||||||
|
case "bossesDefeated":
|
||||||
|
return state.bosses.filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).length;
|
||||||
|
case "questsCompleted":
|
||||||
|
return state.quests.filter((quest) => {
|
||||||
|
return quest.status === "completed";
|
||||||
|
}).length;
|
||||||
|
case "adventurerTotal":
|
||||||
|
return state.adventurers.reduce((sum, adventurer) => {
|
||||||
|
return sum + adventurer.count;
|
||||||
|
}, 0);
|
||||||
|
case "prestigeCount":
|
||||||
|
return state.prestige.count;
|
||||||
|
case "equipmentOwned":
|
||||||
|
return state.equipment.filter((item) => {
|
||||||
|
return item.owned;
|
||||||
|
}).length;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface AchievementCardProperties {
|
interface AchievementCardProperties {
|
||||||
readonly achievement: Achievement;
|
readonly achievement: Achievement;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly progressValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,26 +105,47 @@ interface AchievementCardProperties {
|
|||||||
* @param props - The achievement card properties.
|
* @param props - The achievement card properties.
|
||||||
* @param props.achievement - The achievement to display.
|
* @param props.achievement - The achievement to display.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.progressValue - The player's current progress toward the unlock condition.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
|
||||||
const AchievementCard = ({
|
const AchievementCard = ({
|
||||||
achievement,
|
achievement,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
progressValue,
|
||||||
}: AchievementCardProperties): JSX.Element => {
|
}: AchievementCardProperties): JSX.Element => {
|
||||||
const isUnlocked = achievement.unlockedAt !== null;
|
const isUnlocked = achievement.unlockedAt !== null;
|
||||||
const crystals = achievement.reward?.crystals;
|
const crystals = achievement.reward?.crystals;
|
||||||
|
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`achievement-card ${isUnlocked
|
<div className={`achievement-card ${isUnlocked
|
||||||
? "unlocked"
|
? "unlocked"
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="achievement-icon">{achievement.icon}</div>
|
<img
|
||||||
|
alt={achievement.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("achievements", achievement.id)}
|
||||||
|
/>
|
||||||
<div className="achievement-info">
|
<div className="achievement-info">
|
||||||
<h3>{achievement.name}</h3>
|
<h3>{achievement.name}</h3>
|
||||||
<p>{achievement.description}</p>
|
<p>{achievement.description}</p>
|
||||||
<p className="achievement-condition">
|
<p className="achievement-condition">
|
||||||
{conditionDescription(achievement, formatNumber)}
|
{conditionDescription(achievement, formatNumber)}
|
||||||
</p>
|
</p>
|
||||||
|
{!isUnlocked
|
||||||
|
&& <div className="achievement-progress">
|
||||||
|
<progress
|
||||||
|
max={achievement.condition.amount}
|
||||||
|
value={cappedProgress}
|
||||||
|
/>
|
||||||
|
<span className="achievement-progress-label">
|
||||||
|
{formatNumber(progressValue)}
|
||||||
|
{" / "}
|
||||||
|
{formatNumber(achievement.condition.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
{crystals !== undefined
|
{crystals !== undefined
|
||||||
&& <p className="achievement-reward">
|
&& <p className="achievement-reward">
|
||||||
{"💎 +"}
|
{"💎 +"}
|
||||||
@@ -93,7 +156,18 @@ const AchievementCard = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="achievement-status">
|
<div className="achievement-status">
|
||||||
{isUnlocked
|
{isUnlocked
|
||||||
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
? <>
|
||||||
|
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||||
|
{achievement.unlockedAt !== null
|
||||||
|
&& <span className="achievement-unlocked-at">
|
||||||
|
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</>
|
||||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +232,7 @@ const AchievementPanel = (): JSX.Element => {
|
|||||||
achievement={achievement}
|
achievement={achievement}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
key={achievement.id}
|
key={achievement.id}
|
||||||
|
progressValue={getCurrentProgress(achievement, state)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const ToastItem = ({
|
|||||||
const crystals = achievement.reward?.crystals;
|
const crystals = achievement.reward?.crystals;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="achievement-toast" onClick={handleClick}>
|
<div className="game-toast" onClick={handleClick}>
|
||||||
<span className="toast-icon">{achievement.icon}</span>
|
<span className="toast-icon">{achievement.icon}</span>
|
||||||
<div className="toast-content">
|
<div className="toast-content">
|
||||||
<span className="toast-label">{"Achievement Unlocked!"}</span>
|
<span className="toast-label">{"Achievement Unlocked!"}</span>
|
||||||
@@ -70,7 +70,7 @@ const AchievementToast = (): JSX.Element | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="achievement-toast-container">
|
<>
|
||||||
{pendingAchievements.map((achievement) => {
|
{pendingAchievements.map((achievement) => {
|
||||||
return (
|
return (
|
||||||
<ToastItem
|
<ToastItem
|
||||||
@@ -80,7 +80,7 @@ const AchievementToast = (): JSX.Element | null => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,21 +9,39 @@
|
|||||||
/* eslint-disable complexity -- Complex component with many render paths */
|
/* eslint-disable complexity -- Complex component with many render paths */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Adventurer } from "@elysium/types";
|
import type { Adventurer } from "@elysium/types";
|
||||||
|
|
||||||
const iconByClass: Record<string, string> = {
|
|
||||||
cleric: "✝️",
|
|
||||||
mage: "🔮",
|
|
||||||
paladin: "🛡️",
|
|
||||||
ranger: "🏹",
|
|
||||||
rogue: "🗝️",
|
|
||||||
warrior: "🗡️",
|
|
||||||
};
|
|
||||||
|
|
||||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
|
||||||
|
* @param stored - The raw string from localStorage (or null if absent).
|
||||||
|
* @returns A valid BatchSize value.
|
||||||
|
*/
|
||||||
|
const parseBatchSize = (stored: string | null): BatchSize => {
|
||||||
|
if (stored === "max") {
|
||||||
|
return "max";
|
||||||
|
}
|
||||||
|
const numeric = Number(stored);
|
||||||
|
if (numeric === 5) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
if (numeric === 10) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if (numeric === 25) {
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
if (numeric === 100) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the total cost to buy a batch of adventurers.
|
* Computes the total cost to buy a batch of adventurers.
|
||||||
* @param adventurer - The adventurer to buy.
|
* @param adventurer - The adventurer to buy.
|
||||||
@@ -59,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
|||||||
return quantity;
|
return quantity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EffectiveAdventurerStats {
|
||||||
|
readonly combatPower: number;
|
||||||
|
readonly essencePerSecond: number;
|
||||||
|
readonly goldPerSecond: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AdventurerCardProperties {
|
interface AdventurerCardProperties {
|
||||||
readonly adventurer: Adventurer;
|
readonly adventurer: Adventurer;
|
||||||
readonly currentGold: number;
|
readonly currentGold: number;
|
||||||
readonly batchSize: BatchSize;
|
readonly batchSize: BatchSize;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly effectiveStats: EffectiveAdventurerStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +100,7 @@ interface AdventurerCardProperties {
|
|||||||
* @param props.batchSize - The selected batch size.
|
* @param props.batchSize - The selected batch size.
|
||||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.effectiveStats - The post-multiplier per-unit stats.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const AdventurerCard = ({
|
const AdventurerCard = ({
|
||||||
@@ -83,6 +109,7 @@ const AdventurerCard = ({
|
|||||||
batchSize,
|
batchSize,
|
||||||
unlockHint,
|
unlockHint,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
effectiveStats,
|
||||||
}: AdventurerCardProperties): JSX.Element => {
|
}: AdventurerCardProperties): JSX.Element => {
|
||||||
const { buyAdventurer } = useGame();
|
const { buyAdventurer } = useGame();
|
||||||
|
|
||||||
@@ -105,26 +132,31 @@ const AdventurerCard = ({
|
|||||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||||
: "🔒 Locked";
|
: "🔒 Locked";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
|
||||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`adventurer-card ${adventurer.unlocked
|
<div className={`adventurer-card ${adventurer.unlocked
|
||||||
? ""
|
? ""
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
<img
|
||||||
|
alt={adventurer.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("adventurers", adventurer.id)}
|
||||||
|
/>
|
||||||
<div className="adventurer-info">
|
<div className="adventurer-info">
|
||||||
<h3>{adventurer.name}</h3>
|
<h3>{adventurer.name}</h3>
|
||||||
<p>
|
<p>
|
||||||
{formatNumber(adventurer.goldPerSecond)}
|
{formatNumber(effectiveStats.goldPerSecond)}
|
||||||
{" gold/s each"}
|
{" gold/s each"}
|
||||||
</p>
|
</p>
|
||||||
{adventurer.essencePerSecond > 0
|
{adventurer.essencePerSecond > 0
|
||||||
&& <p>
|
&& <p>
|
||||||
{formatNumber(adventurer.essencePerSecond)}
|
{formatNumber(effectiveStats.essencePerSecond)}
|
||||||
{" essence/s each"}
|
{" essence/s each"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
<p>
|
||||||
|
{formatNumber(effectiveStats.combatPower)}
|
||||||
|
{" combat power each"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="adventurer-count">
|
<div className="adventurer-count">
|
||||||
{"×"}
|
{"×"}
|
||||||
@@ -153,9 +185,11 @@ const AdventurerCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const AdventurerPanel = (): JSX.Element => {
|
const AdventurerPanel = (): JSX.Element => {
|
||||||
const { state, formatNumber } = useGame();
|
const { state, formatNumber, toggleAutoAdventurer } = useGame();
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
|
||||||
|
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
|
||||||
|
});
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -183,6 +217,11 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
|
||||||
|
"auto_adventurer",
|
||||||
|
);
|
||||||
|
const autoAdventurerOn = state.autoAdventurer === true;
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -193,16 +232,40 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
<section className="panel adventurer-panel">
|
<section className="panel adventurer-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>{"Adventurers"}</h2>
|
<h2>{"Adventurers"}</h2>
|
||||||
|
<div className="panel-header-controls">
|
||||||
|
{autoAdventurerUnlocked
|
||||||
|
? <button
|
||||||
|
className={`auto-toggle-btn ${
|
||||||
|
autoAdventurerOn
|
||||||
|
? "auto-toggle-on"
|
||||||
|
: "auto-toggle-off"
|
||||||
|
}`}
|
||||||
|
onClick={toggleAutoAdventurer}
|
||||||
|
title={
|
||||||
|
"Automatically purchase the highest-tier"
|
||||||
|
+ " affordable adventurer"
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"🤖 Auto: "}
|
||||||
|
{autoAdventurerOn
|
||||||
|
? "ON"
|
||||||
|
: "OFF"}
|
||||||
|
</button>
|
||||||
|
: null
|
||||||
|
}
|
||||||
<LockToggle
|
<LockToggle
|
||||||
lockedCount={locked.length}
|
lockedCount={locked.length}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
showLocked={showLocked}
|
showLocked={showLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="batch-selector">
|
<div className="batch-selector">
|
||||||
{batchOptions.map((option) => {
|
{batchOptions.map((option) => {
|
||||||
function handleBatchSelect(): void {
|
function handleBatchSelect(): void {
|
||||||
setBatchSize(option);
|
setBatchSize(option);
|
||||||
|
localStorage.setItem("elysium_batch_size", String(option));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -227,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
|
|||||||
adventurer={adventurer}
|
adventurer={adventurer}
|
||||||
batchSize={batchSize}
|
batchSize={batchSize}
|
||||||
currentGold={state.resources.gold}
|
currentGold={state.resources.gold}
|
||||||
|
effectiveStats={computeEffectiveAdventurerStats(
|
||||||
|
state,
|
||||||
|
adventurer.id,
|
||||||
|
)}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
key={adventurer.id}
|
key={adventurer.id}
|
||||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
/* eslint-disable complexity -- Battle result display requires many conditional paths */
|
/* eslint-disable complexity -- Battle result display requires many conditional paths */
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import { type JSX, useEffect, useState } from "react";
|
||||||
import { type BattleResult, useGame } from "../../context/gameContext.js";
|
import { type BattleResult, useGame } from "../../context/gameContext.js";
|
||||||
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
|
import { playSound } from "../../utils/sound.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts HP values to a percentage for display.
|
* Converts HP values to a percentage for display.
|
||||||
@@ -23,6 +25,22 @@ const toHpPercent = (current: number, maximum: number): number => {
|
|||||||
return scaled / maximum;
|
return scaled / maximum;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a colour hex string based on the HP percentage.
|
||||||
|
* Green above 50%, yellow 25–50%, red below 25%.
|
||||||
|
* @param percent - Current HP as a percentage (0–100).
|
||||||
|
* @returns A hex colour string.
|
||||||
|
*/
|
||||||
|
const getHpColour = (percent: number): string => {
|
||||||
|
if (percent > 50) {
|
||||||
|
return "#27ae60";
|
||||||
|
}
|
||||||
|
if (percent > 25) {
|
||||||
|
return "#f39c12";
|
||||||
|
}
|
||||||
|
return "#e74c3c";
|
||||||
|
};
|
||||||
|
|
||||||
interface BattleModalProperties {
|
interface BattleModalProperties {
|
||||||
readonly battle: BattleResult;
|
readonly battle: BattleResult;
|
||||||
readonly onDismiss: ()=> void;
|
readonly onDismiss: ()=> void;
|
||||||
@@ -40,12 +58,17 @@ const BattleModal = ({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
}: BattleModalProperties): JSX.Element => {
|
}: BattleModalProperties): JSX.Element => {
|
||||||
const { result, bossName } = battle;
|
const { result, bossName } = battle;
|
||||||
const { formatNumber } = useGame();
|
const {
|
||||||
|
enableNotifications,
|
||||||
|
enableSounds,
|
||||||
|
flushBossLoreToasts,
|
||||||
|
formatInteger,
|
||||||
|
formatNumber,
|
||||||
|
} = useGame();
|
||||||
|
|
||||||
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
||||||
|
|
||||||
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
|
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
|
||||||
const partyStartPercent = 100;
|
|
||||||
|
|
||||||
const bossEndPercent = toHpPercent(
|
const bossEndPercent = toHpPercent(
|
||||||
result.bossHpAtBattleEnd,
|
result.bossHpAtBattleEnd,
|
||||||
@@ -57,37 +80,72 @@ const BattleModal = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
|
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
|
||||||
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent);
|
const [ partyHpPercent, setPartyHpPercent ] = useState(100);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startAnimation = setTimeout(() => {
|
const animationDurationMs = 5000;
|
||||||
|
const intervalMs = 50;
|
||||||
|
const totalSteps = animationDurationMs / intervalMs;
|
||||||
|
|
||||||
|
const bossHpRange = bossEndPercent - bossStartPercent;
|
||||||
|
const bossDelta = bossHpRange / totalSteps;
|
||||||
|
|
||||||
|
const partyHpRange = partyEndPercent - 100;
|
||||||
|
const partyDelta = partyHpRange / totalSteps;
|
||||||
|
|
||||||
|
let currentStep = 0;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned inside timeout
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
const tick = (): void => {
|
||||||
|
currentStep = currentStep + 1;
|
||||||
|
if (currentStep >= totalSteps) {
|
||||||
setBossHpPercent(bossEndPercent);
|
setBossHpPercent(bossEndPercent);
|
||||||
setPartyHpPercent(partyEndPercent);
|
setPartyHpPercent(partyEndPercent);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
} else {
|
||||||
|
const bossStep = bossDelta * currentStep;
|
||||||
|
setBossHpPercent(bossStartPercent + bossStep);
|
||||||
|
const partyStep = partyDelta * currentStep;
|
||||||
|
setPartyHpPercent(100 + partyStep);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTimeout = setTimeout(() => {
|
||||||
|
intervalId = setInterval(tick, intervalMs);
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
const revealResult = setTimeout(() => {
|
const revealTimeout = setTimeout(() => {
|
||||||
setPhase("result");
|
setPhase("result");
|
||||||
|
flushBossLoreToasts();
|
||||||
|
if (result.won) {
|
||||||
|
if (enableSounds) {
|
||||||
|
playSound("bossVictory");
|
||||||
|
}
|
||||||
|
if (enableNotifications) {
|
||||||
|
sendNotification("⚔️ Boss Defeated!", `You defeated ${bossName}!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, 5200);
|
}, 5200);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
clearTimeout(startAnimation);
|
clearTimeout(startTimeout);
|
||||||
clearTimeout(revealResult);
|
clearTimeout(revealTimeout);
|
||||||
|
clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [ bossEndPercent, partyEndPercent ]);
|
}, [
|
||||||
|
bossEndPercent,
|
||||||
|
bossName,
|
||||||
|
bossStartPercent,
|
||||||
|
enableNotifications,
|
||||||
|
enableSounds,
|
||||||
|
flushBossLoreToasts,
|
||||||
|
partyEndPercent,
|
||||||
|
result.won,
|
||||||
|
]);
|
||||||
|
|
||||||
let bossHpBarColour = "#c0392b";
|
const bossHpBarColour = getHpColour(bossHpPercent);
|
||||||
if (bossHpPercent > 50) {
|
const partyHpBarColour = getHpColour(partyHpPercent);
|
||||||
bossHpBarColour = "#e74c3c";
|
|
||||||
} else if (bossHpPercent > 25) {
|
|
||||||
bossHpBarColour = "#e67e22";
|
|
||||||
}
|
|
||||||
|
|
||||||
let partyHpBarColour = "#e74c3c";
|
|
||||||
if (partyHpPercent > 50) {
|
|
||||||
partyHpBarColour = "#27ae60";
|
|
||||||
} else if (partyHpPercent > 25) {
|
|
||||||
partyHpBarColour = "#f39c12";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
@@ -120,7 +178,6 @@ const BattleModal = ({
|
|||||||
className="hp-bar-fill"
|
className="hp-bar-fill"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: bossHpBarColour,
|
backgroundColor: bossHpBarColour,
|
||||||
transition: "width 5s ease-in-out",
|
|
||||||
width: `${bossHpPercent.toFixed(1)}%`,
|
width: `${bossHpPercent.toFixed(1)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -141,7 +198,6 @@ const BattleModal = ({
|
|||||||
className="hp-bar-fill party-hp"
|
className="hp-bar-fill party-hp"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: partyHpBarColour,
|
backgroundColor: partyHpBarColour,
|
||||||
transition: "width 5s ease-in-out",
|
|
||||||
width: `${partyHpPercent.toFixed(1)}%`,
|
width: `${partyHpPercent.toFixed(1)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -186,14 +242,14 @@ const BattleModal = ({
|
|||||||
{result.rewards.crystals > 0
|
{result.rewards.crystals > 0
|
||||||
&& <span>
|
&& <span>
|
||||||
{"💎 "}
|
{"💎 "}
|
||||||
{formatNumber(result.rewards.crystals)}
|
{formatInteger(result.rewards.crystals)}
|
||||||
{" crystals"}
|
{" crystals"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{result.rewards.bountyRunestones > 0
|
{result.rewards.bountyRunestones > 0
|
||||||
&& <span className="battle-bounty">
|
&& <span className="battle-bounty">
|
||||||
{"🔮 "}
|
{"🔮 "}
|
||||||
{formatNumber(result.rewards.bountyRunestones)}
|
{formatInteger(result.rewards.bountyRunestones)}
|
||||||
{" runestones (first kill!)"}
|
{" runestones (first kill!)"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { computePartyCombatPower } from "../../engine/tick.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss, GameState } from "@elysium/types";
|
import type { Boss } from "@elysium/types";
|
||||||
|
|
||||||
interface BossCardProperties {
|
interface BossCardProperties {
|
||||||
readonly boss: Boss;
|
readonly boss: Boss;
|
||||||
@@ -21,6 +23,7 @@ interface BossCardProperties {
|
|||||||
readonly onChallenge: (bossId: string)=> void;
|
readonly onChallenge: (bossId: string)=> void;
|
||||||
readonly isChallenging: boolean;
|
readonly isChallenging: boolean;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
|
readonly formatInteger: (n: number)=> string;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ interface BossCardProperties {
|
|||||||
* @param props.onChallenge - Callback to challenge this boss.
|
* @param props.onChallenge - Callback to challenge this boss.
|
||||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||||
|
* @param props.formatInteger - The integer formatting utility function.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
@@ -41,6 +45,7 @@ const BossCard = ({
|
|||||||
onChallenge,
|
onChallenge,
|
||||||
isChallenging,
|
isChallenging,
|
||||||
unlockHint,
|
unlockHint,
|
||||||
|
formatInteger,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
}: BossCardProperties): JSX.Element => {
|
}: BossCardProperties): JSX.Element => {
|
||||||
const scaled = boss.currentHp * 100;
|
const scaled = boss.currentHp * 100;
|
||||||
@@ -56,6 +61,11 @@ const BossCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`boss-card boss-${boss.status}`}>
|
<div className={`boss-card boss-${boss.status}`}>
|
||||||
|
<img
|
||||||
|
alt={boss.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("bosses", boss.id)}
|
||||||
|
/>
|
||||||
<div className="boss-info">
|
<div className="boss-info">
|
||||||
<h3>{boss.name}</h3>
|
<h3>{boss.name}</h3>
|
||||||
<p>{boss.description}</p>
|
<p>{boss.description}</p>
|
||||||
@@ -110,7 +120,7 @@ const BossCard = ({
|
|||||||
{boss.crystalReward > 0
|
{boss.crystalReward > 0
|
||||||
&& <span>
|
&& <span>
|
||||||
{"💎 "}
|
{"💎 "}
|
||||||
{formatNumber(boss.crystalReward)}
|
{formatInteger(boss.crystalReward)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{boss.equipmentRewards.length > 0
|
{boss.equipmentRewards.length > 0
|
||||||
@@ -120,7 +130,9 @@ const BossCard = ({
|
|||||||
{" Equipment"}
|
{" Equipment"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
{boss.status !== "defeated"
|
||||||
|
&& boss.bountyRunestones > 0
|
||||||
|
&& boss.bountyRunestonesClaimed !== true
|
||||||
&& <span className="boss-bounty">
|
&& <span className="boss-bounty">
|
||||||
{"🔮 "}
|
{"🔮 "}
|
||||||
{boss.bountyRunestones}
|
{boss.bountyRunestones}
|
||||||
@@ -149,82 +161,27 @@ const BossCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes party DPS and HP from the current game state.
|
|
||||||
* @param state - The full game state.
|
|
||||||
* @returns The computed party DPS and HP values.
|
|
||||||
*/
|
|
||||||
const computePartyStats = (
|
|
||||||
state: GameState,
|
|
||||||
): {
|
|
||||||
partyDps: number;
|
|
||||||
partyHp: number;
|
|
||||||
} => {
|
|
||||||
const { upgrades, adventurers, equipment, prestige } = state;
|
|
||||||
let globalMultiplier = 1;
|
|
||||||
for (const upgrade of upgrades) {
|
|
||||||
const { purchased, target, multiplier } = upgrade;
|
|
||||||
if (purchased && target === "global") {
|
|
||||||
globalMultiplier = globalMultiplier * multiplier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const prestigeBonus = prestige.count * 0.1;
|
|
||||||
const prestigeMultiplier = 1 + prestigeBonus;
|
|
||||||
const equipmentCombatMultiplier = equipment.
|
|
||||||
filter((item) => {
|
|
||||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
|
||||||
}).
|
|
||||||
reduce((multiplier, item) => {
|
|
||||||
return multiplier * (item.bonus.combatMultiplier ?? 1);
|
|
||||||
}, 1);
|
|
||||||
|
|
||||||
let partyDps = 0;
|
|
||||||
let partyHp = 0;
|
|
||||||
for (const adventurer of adventurers) {
|
|
||||||
const { count, id: adventurerId, combatPower, level } = adventurer;
|
|
||||||
if (count === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let adventurerMultiplier = 1;
|
|
||||||
for (const upgrade of upgrades) {
|
|
||||||
const {
|
|
||||||
purchased,
|
|
||||||
target,
|
|
||||||
multiplier,
|
|
||||||
adventurerId: upgradeAdventurerId,
|
|
||||||
} = upgrade;
|
|
||||||
if (
|
|
||||||
purchased
|
|
||||||
&& target === "adventurer"
|
|
||||||
&& upgradeAdventurerId === adventurerId
|
|
||||||
) {
|
|
||||||
adventurerMultiplier = adventurerMultiplier * multiplier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dps
|
|
||||||
= combatPower
|
|
||||||
* count
|
|
||||||
* adventurerMultiplier
|
|
||||||
* globalMultiplier
|
|
||||||
* prestigeMultiplier;
|
|
||||||
partyDps = partyDps + dps;
|
|
||||||
const hp = level * 50 * count;
|
|
||||||
partyHp = partyHp + hp;
|
|
||||||
}
|
|
||||||
partyDps = partyDps * equipmentCombatMultiplier;
|
|
||||||
return { partyDps, partyHp };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the boss panel with zone selection and boss list.
|
* Renders the boss panel with zone selection and boss list.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const BossPanel = (): JSX.Element => {
|
const BossPanel = (): JSX.Element => {
|
||||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
const {
|
||||||
|
state,
|
||||||
|
challengeBoss,
|
||||||
|
formatInteger,
|
||||||
|
formatNumber,
|
||||||
|
toggleAutoBoss,
|
||||||
|
autoBossLastResult,
|
||||||
|
autoBossError,
|
||||||
|
bossError,
|
||||||
|
} = useGame();
|
||||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -248,7 +205,31 @@ const BossPanel = (): JSX.Element => {
|
|||||||
void handleChallenge(bossId);
|
void handleChallenge(bossId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
const {
|
||||||
|
adventurers,
|
||||||
|
autoBoss,
|
||||||
|
bosses,
|
||||||
|
prestige: playerPrestige,
|
||||||
|
quests,
|
||||||
|
zones,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
const zoneBosses = bosses.filter((boss) => {
|
const zoneBosses = bosses.filter((boss) => {
|
||||||
return boss.zoneId === activeZoneId;
|
return boss.zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -302,6 +283,11 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_boss_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -309,7 +295,12 @@ const BossPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoBossOn = autoBoss === true;
|
const autoBossOn = autoBoss === true;
|
||||||
const { partyDps, partyHp } = computePartyStats(state);
|
const partyDps = computePartyCombatPower(state);
|
||||||
|
let partyHp = 0;
|
||||||
|
for (const { level, count } of adventurers) {
|
||||||
|
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
|
||||||
|
partyHp = partyHp + level * 50 * count;
|
||||||
|
}
|
||||||
const { count: prestigeCount } = playerPrestige;
|
const { count: prestigeCount } = playerPrestige;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -340,12 +331,57 @@ const BossPanel = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ "}
|
||||||
|
{bossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{autoBossError === null
|
||||||
|
? null
|
||||||
|
: <p className="auto-boss-error">
|
||||||
|
{"⚠️ Auto-boss stopped: "}
|
||||||
|
{autoBossError}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{autoBossLastResult !== null && autoBossError === null
|
||||||
|
? <p className="auto-boss-status">
|
||||||
|
{"🤖 Last fight: "}
|
||||||
|
{autoBossLastResult.bossName}
|
||||||
|
{autoBossLastResult.won
|
||||||
|
? " — ✅ Won"
|
||||||
|
: " — ❌ Lost"}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="party-combat-stats">
|
<div className="party-combat-stats">
|
||||||
<div className="combat-stat">
|
<div className="combat-stat">
|
||||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||||
@@ -363,6 +399,7 @@ const BossPanel = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<BossCard
|
<BossCard
|
||||||
boss={boss}
|
boss={boss}
|
||||||
|
formatInteger={formatInteger}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
isChallenging={challengingBossId === bossId}
|
isChallenging={challengingBossId === bossId}
|
||||||
key={bossId}
|
key={bossId}
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
|
/* eslint-disable max-lines -- Story section adds lines beyond the file limit */
|
||||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||||
import { type JSX, useEffect, useState } from "react";
|
import {
|
||||||
import type {
|
STORY_CHAPTERS,
|
||||||
EquipmentBonus,
|
type EquipmentBonus,
|
||||||
EquipmentType,
|
type EquipmentType,
|
||||||
PublicProfileResponse,
|
type PublicProfileResponse,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
|
import { type JSX, useEffect, useState } from "react";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface CharacterPageProperties {
|
interface CharacterPageProperties {
|
||||||
readonly discordId: string;
|
readonly discordId: string;
|
||||||
@@ -76,11 +79,15 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
|
then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="character-page-equipment-item"
|
className="character-page-equipment-item"
|
||||||
key={item.type}
|
key={item.name}
|
||||||
>
|
>
|
||||||
<div className="character-page-equipment-header">
|
<div className="character-page-equipment-header">
|
||||||
<span className="character-page-equipment-slot">
|
<span className="character-page-equipment-slot">
|
||||||
@@ -269,6 +276,43 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{profile.completedChapters.length === 0
|
||||||
|
? null
|
||||||
|
: <div className="character-page-section">
|
||||||
|
<h2 className="character-page-section-title">{"📖 Story"}</h2>
|
||||||
|
{profile.completedChapters.map((completion) => {
|
||||||
|
const chapter = STORY_CHAPTERS.find((candidate) => {
|
||||||
|
return candidate.id === completion.chapterId;
|
||||||
|
});
|
||||||
|
if (chapter === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const choice = chapter.choices.find((candidate) => {
|
||||||
|
return candidate.id === completion.choiceId;
|
||||||
|
});
|
||||||
|
if (choice === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="character-sheet-story-entry"
|
||||||
|
key={completion.chapterId}
|
||||||
|
>
|
||||||
|
<span className="character-sheet-story-chapter">
|
||||||
|
{chapter.title}
|
||||||
|
</span>
|
||||||
|
<span className="character-sheet-story-choice">
|
||||||
|
{choice.label}
|
||||||
|
</span>
|
||||||
|
<p className="character-sheet-story-outcome">
|
||||||
|
{choice.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div className="character-page-divider" />
|
<div className="character-page-divider" />
|
||||||
|
|
||||||
<p className="character-page-player-line">
|
<p className="character-page-player-line">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||||
import { updateProfile } from "../../api/client.js";
|
import { updateProfile } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
|
|
||||||
interface EquippedItem {
|
interface EquippedItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -205,11 +206,15 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
function handleShareClick(): void {
|
function handleShareClick(): void {
|
||||||
const discordId = player?.discordId ?? "";
|
const discordId = player?.discordId ?? "";
|
||||||
const url = `${window.location.origin}/character/${discordId}`;
|
const url = `${window.location.origin}/character/${discordId}`;
|
||||||
void navigator.clipboard.writeText(url).then(() => {
|
void navigator.clipboard.writeText(url).
|
||||||
|
then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,6 +662,15 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
if (choice === undefined) {
|
if (choice === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const characterName
|
||||||
|
= player?.characterName === ""
|
||||||
|
|| player?.characterName === undefined
|
||||||
|
? "the guild leader"
|
||||||
|
: player.characterName;
|
||||||
|
const outcome = choice.outcome.replaceAll(
|
||||||
|
"{characterName}",
|
||||||
|
characterName,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="character-sheet-story-entry"
|
className="character-sheet-story-entry"
|
||||||
@@ -668,6 +682,7 @@ const CharacterSheetPanel = (): JSX.Element => {
|
|||||||
<span className="character-sheet-story-choice">
|
<span className="character-sheet-story-choice">
|
||||||
{choice.label}
|
{choice.label}
|
||||||
</span>
|
</span>
|
||||||
|
<p className="character-sheet-story-outcome">{outcome}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { CodexEntry } from "@elysium/types";
|
import type { CodexEntry } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,52 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
|||||||
zone: "🗺️",
|
zone: "🗺️",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
||||||
|
adventurer: "adventurers",
|
||||||
|
boss: "bosses",
|
||||||
|
equipment: "equipment",
|
||||||
|
exploration: "explorations",
|
||||||
|
prestige: "prestige-upgrades",
|
||||||
|
quest: "quests",
|
||||||
|
recipe: "recipes",
|
||||||
|
upgrade: "upgrades",
|
||||||
|
zone: "zones",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a snake_case ID to a Title Case display name.
|
||||||
|
* @param id - The snake_case identifier to format.
|
||||||
|
* @returns The formatted display name.
|
||||||
|
*/
|
||||||
|
const formatId = (id: string): string => {
|
||||||
|
return id.split("_").
|
||||||
|
map((word) => {
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
|
}).
|
||||||
|
join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a human-readable unlock hint for a locked codex entry.
|
||||||
|
* @param entry - The locked codex entry.
|
||||||
|
* @returns A string describing how to unlock the entry.
|
||||||
|
*/
|
||||||
|
const buildUnlockHint = (entry: CodexEntry): string => {
|
||||||
|
const name = formatId(entry.sourceId);
|
||||||
|
switch (entry.sourceType) {
|
||||||
|
case "boss": return `Defeat ${name}`;
|
||||||
|
case "quest": return `Complete: ${name}`;
|
||||||
|
case "equipment": return `Obtain: ${name}`;
|
||||||
|
case "adventurer": return `Recruit a ${name}`;
|
||||||
|
case "upgrade": return `Purchase: ${name}`;
|
||||||
|
case "prestige": return `Purchase runestone upgrade: ${name}`;
|
||||||
|
case "zone": return `Explore: ${name}`;
|
||||||
|
case "exploration": return `Discover: ${name}`;
|
||||||
|
case "recipe": return `Craft: ${name}`;
|
||||||
|
default: return "Keep playing to unlock";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the codex panel with lore entries grouped by zone.
|
* Renders the codex panel with lore entries grouped by zone.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -123,6 +170,9 @@ const CodexPanel = (): JSX.Element => {
|
|||||||
<span className="codex-lock">{"🔒"}</span>
|
<span className="codex-lock">{"🔒"}</span>
|
||||||
<span className="codex-entry-title">{"???"}</span>
|
<span className="codex-entry-title">{"???"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="codex-unlock-hint">
|
||||||
|
{buildUnlockHint(entry)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,7 +205,17 @@ const CodexPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? <p className="codex-entry-content">{entry.content}</p>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={entry.title}
|
||||||
|
className="codex-entry-image"
|
||||||
|
src={cdnImage(
|
||||||
|
sourceTypeFolder[entry.sourceType],
|
||||||
|
entry.sourceId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="codex-entry-content">{entry.content}</p>
|
||||||
|
</>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const CodexToastItem = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="codex-toast" onClick={handleClick}>
|
<div className="game-toast" onClick={handleClick}>
|
||||||
<span className="toast-icon">{"📖"}</span>
|
<span className="toast-icon">{"📖"}</span>
|
||||||
<div className="toast-content">
|
<div className="toast-content">
|
||||||
<span className="toast-label">{"✨ Lore Unlocked!"}</span>
|
<span className="toast-label">{"✨ Lore Unlocked!"}</span>
|
||||||
@@ -70,13 +70,13 @@ const CodexToast = (): JSX.Element | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="achievement-toast-container">
|
<>
|
||||||
{pendingEntryIds.map((id) => {
|
{pendingEntryIds.map((id) => {
|
||||||
return (
|
return (
|
||||||
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
|
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||||
|
/* eslint-disable complexity -- Companion card has many conditional render paths */
|
||||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
const bonusLabels: Record<string, string> = {
|
const bonusLabels: Record<string, string> = {
|
||||||
@@ -27,41 +29,13 @@ const unlockLabels: Record<string, string> = {
|
|||||||
transcendence: "transcendence(s)",
|
transcendence: "transcendence(s)",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a companion unlock threshold for display.
|
|
||||||
* @param type - The unlock condition type.
|
|
||||||
* @param threshold - The threshold value.
|
|
||||||
* @returns The formatted threshold string.
|
|
||||||
*/
|
|
||||||
const formatThreshold = (type: string, threshold: number): string => {
|
|
||||||
if (type === "lifetimeGold") {
|
|
||||||
if (threshold >= 1e18) {
|
|
||||||
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
|
||||||
}
|
|
||||||
if (threshold >= 1e15) {
|
|
||||||
return `${(threshold / 1e15).toFixed(0)}Q`;
|
|
||||||
}
|
|
||||||
if (threshold >= 1e12) {
|
|
||||||
return `${(threshold / 1e12).toFixed(0)}T`;
|
|
||||||
}
|
|
||||||
if (threshold >= 1e9) {
|
|
||||||
return `${(threshold / 1e9).toFixed(0)}B`;
|
|
||||||
}
|
|
||||||
if (threshold >= 1e6) {
|
|
||||||
return `${(threshold / 1e6).toFixed(0)}M`;
|
|
||||||
}
|
|
||||||
if (threshold >= 1e3) {
|
|
||||||
return `${(threshold / 1e3).toFixed(0)}K`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return threshold.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CompanionCardProperties {
|
interface CompanionCardProperties {
|
||||||
readonly companion: Companion;
|
readonly companion: Companion;
|
||||||
readonly isUnlocked: boolean;
|
readonly isUnlocked: boolean;
|
||||||
readonly isActive: boolean;
|
readonly isActive: boolean;
|
||||||
readonly onSelect: ()=> void;
|
readonly onSelect: ()=> void;
|
||||||
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly currentProgress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +45,8 @@ interface CompanionCardProperties {
|
|||||||
* @param props.isUnlocked - Whether this companion is unlocked.
|
* @param props.isUnlocked - Whether this companion is unlocked.
|
||||||
* @param props.isActive - Whether this companion is currently active.
|
* @param props.isActive - Whether this companion is currently active.
|
||||||
* @param props.onSelect - Callback when the companion is selected/deselected.
|
* @param props.onSelect - Callback when the companion is selected/deselected.
|
||||||
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.currentProgress - The player's current progress toward the unlock threshold.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const CompanionCard = ({
|
const CompanionCard = ({
|
||||||
@@ -78,6 +54,8 @@ const CompanionCard = ({
|
|||||||
isUnlocked,
|
isUnlocked,
|
||||||
isActive,
|
isActive,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
formatNumber,
|
||||||
|
currentProgress,
|
||||||
}: CompanionCardProperties): JSX.Element => {
|
}: CompanionCardProperties): JSX.Element => {
|
||||||
const bonusSign = companion.bonus.type === "questTime"
|
const bonusSign = companion.bonus.type === "questTime"
|
||||||
? "-"
|
? "-"
|
||||||
@@ -96,6 +74,11 @@ const CompanionCard = ({
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
>
|
>
|
||||||
<div className="companion-header">
|
<div className="companion-header">
|
||||||
|
<img
|
||||||
|
alt={companion.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("companions", companion.id)}
|
||||||
|
/>
|
||||||
<div className="companion-name-block">
|
<div className="companion-name-block">
|
||||||
<span className="companion-name">{companion.name}</span>
|
<span className="companion-name">{companion.name}</span>
|
||||||
<span className="companion-title">{companion.title}</span>
|
<span className="companion-title">{companion.title}</span>
|
||||||
@@ -131,12 +114,28 @@ const CompanionCard = ({
|
|||||||
: "Activate"}
|
: "Activate"}
|
||||||
</button>
|
</button>
|
||||||
: <div className="companion-unlock-requirement">
|
: <div className="companion-unlock-requirement">
|
||||||
|
<p>
|
||||||
{"🔒 Unlock: "}
|
{"🔒 Unlock: "}
|
||||||
{formatThreshold(
|
{companion.unlock.type === "lifetimeGold"
|
||||||
companion.unlock.type,
|
? formatNumber(companion.unlock.threshold)
|
||||||
companion.unlock.threshold,
|
: String(companion.unlock.threshold)}{" "}
|
||||||
)}{" "}
|
|
||||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||||
|
</p>
|
||||||
|
<div className="companion-progress">
|
||||||
|
<progress
|
||||||
|
max={companion.unlock.threshold}
|
||||||
|
value={Math.min(currentProgress, companion.unlock.threshold)}
|
||||||
|
/>
|
||||||
|
<span className="companion-progress-label">
|
||||||
|
{companion.unlock.type === "lifetimeGold"
|
||||||
|
? formatNumber(currentProgress)
|
||||||
|
: String(currentProgress)}
|
||||||
|
{" / "}
|
||||||
|
{companion.unlock.type === "lifetimeGold"
|
||||||
|
? formatNumber(companion.unlock.threshold)
|
||||||
|
: String(companion.unlock.threshold)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +147,7 @@ const CompanionCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const CompanionPanel = (): JSX.Element => {
|
const CompanionPanel = (): JSX.Element => {
|
||||||
const { state, setActiveCompanion } = useGame();
|
const { formatNumber, setActiveCompanion, state } = useGame();
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -161,6 +160,15 @@ const CompanionPanel = (): JSX.Element => {
|
|||||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||||
const activeId = state.companions?.activeCompanionId ?? null;
|
const activeId = state.companions?.activeCompanionId ?? null;
|
||||||
|
|
||||||
|
const progressByUnlockType: Record<string, number> = {
|
||||||
|
apotheosis: state.apotheosis?.count ?? 0,
|
||||||
|
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
||||||
|
lifetimeGold: state.player.lifetimeGoldEarned,
|
||||||
|
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
||||||
|
prestige: state.prestige.count,
|
||||||
|
transcendence: state.transcendence?.count ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
function handleSelect(companionId: string): void {
|
function handleSelect(companionId: string): void {
|
||||||
setActiveCompanion(activeId === companionId
|
setActiveCompanion(activeId === companionId
|
||||||
? null
|
? null
|
||||||
@@ -198,6 +206,10 @@ const CompanionPanel = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<CompanionCard
|
<CompanionCard
|
||||||
companion={companion}
|
companion={companion}
|
||||||
|
currentProgress={
|
||||||
|
progressByUnlockType[companion.unlock.type] ?? 0
|
||||||
|
}
|
||||||
|
formatNumber={formatNumber}
|
||||||
isActive={activeId === companion.id}
|
isActive={activeId === companion.id}
|
||||||
isUnlocked={unlockedIds.includes(companion.id)}
|
isUnlocked={unlockedIds.includes(companion.id)}
|
||||||
key={companion.id}
|
key={companion.id}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
|
|||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { MATERIALS } from "../../data/materials.js";
|
import { MATERIALS } from "../../data/materials.js";
|
||||||
import { RECIPES } from "../../data/recipes.js";
|
import { RECIPES } from "../../data/recipes.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
|
|
||||||
const bonusLabel: Record<string, string> = {
|
const bonusLabel: Record<string, string> = {
|
||||||
@@ -25,7 +26,9 @@ const bonusLabel: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
const CraftingPanel = (): JSX.Element => {
|
const CraftingPanel = (): JSX.Element => {
|
||||||
const { state, craftRecipe, formatNumber } = useGame();
|
const { state, craftRecipe, formatNumber } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -67,6 +70,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_craft_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCraft(recipeId: string): Promise<void> {
|
async function handleCraft(recipeId: string): Promise<void> {
|
||||||
setPendingRecipeId(recipeId);
|
setPendingRecipeId(recipeId);
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +92,7 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -105,6 +113,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
}`}
|
}`}
|
||||||
key={material.id}
|
key={material.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={material.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("materials", material.id)}
|
||||||
|
/>
|
||||||
<div className="material-info">
|
<div className="material-info">
|
||||||
<span className="material-name">{material.name}</span>
|
<span className="material-name">{material.name}</span>
|
||||||
<span className="material-rarity">{material.rarity}</span>
|
<span className="material-rarity">{material.rarity}</span>
|
||||||
@@ -144,6 +157,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={recipe.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("recipes", recipe.id)}
|
||||||
|
/>
|
||||||
<div className="recipe-info">
|
<div className="recipe-info">
|
||||||
<h4>{recipe.name}</h4>
|
<h4>{recipe.name}</h4>
|
||||||
<p className="recipe-description">{recipe.description}</p>
|
<p className="recipe-description">{recipe.description}</p>
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @file Debug panel component with administrative tools for correcting player state.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Panel has multiple async handlers and conditional renders */
|
||||||
|
/* eslint-disable stylistic/max-len -- Debug descriptions require full explanatory text */
|
||||||
|
import { type JSX, useState } from "react";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||||
|
|
||||||
|
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||||
|
|
||||||
|
interface SyncNewContentResult {
|
||||||
|
achievementsAdded: number | undefined;
|
||||||
|
achievementsPatched: number | undefined;
|
||||||
|
adventurersAdded: number | undefined;
|
||||||
|
adventurerStatsPatched: number | undefined;
|
||||||
|
bossesAdded: number | undefined;
|
||||||
|
bossesPatched: number | undefined;
|
||||||
|
bossRewardsPatched: number | undefined;
|
||||||
|
craftingRecipesReapplied: number | undefined;
|
||||||
|
equipmentAdded: number | undefined;
|
||||||
|
equipmentPatched: number | undefined;
|
||||||
|
explorationAreasAdded: number | undefined;
|
||||||
|
questRewardsPatched: number | undefined;
|
||||||
|
questsAdded: number | undefined;
|
||||||
|
questsPatched: number | undefined;
|
||||||
|
upgradesAdded: number | undefined;
|
||||||
|
upgradesPatched: number | undefined;
|
||||||
|
zonesAdded: number | undefined;
|
||||||
|
zonesPatched: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeNumber = (value: number | undefined): number => {
|
||||||
|
return value ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a human-readable summary of what the sync-new-content operation added.
|
||||||
|
* @param result - The counts returned by the operation.
|
||||||
|
* @returns A message string describing what was added, or a confirmation nothing was needed.
|
||||||
|
*/
|
||||||
|
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||||
|
const entries: Array<[ number, string ]> = [
|
||||||
|
[ safeNumber(result.zonesAdded), "zone(s)" ],
|
||||||
|
[ safeNumber(result.questsAdded), "quest(s)" ],
|
||||||
|
[ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ],
|
||||||
|
[ safeNumber(result.bossesAdded), "boss(es)" ],
|
||||||
|
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
||||||
|
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
||||||
|
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
||||||
|
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
|
||||||
|
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
|
||||||
|
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
|
||||||
|
[ safeNumber(result.achievementsAdded), "achievement(s)" ],
|
||||||
|
[ safeNumber(result.questsPatched), "quest stat(s) patched" ],
|
||||||
|
[ safeNumber(result.bossesPatched), "boss stat(s) patched" ],
|
||||||
|
[ safeNumber(result.zonesPatched), "zone stat(s) patched" ],
|
||||||
|
[ safeNumber(result.upgradesPatched), "upgrade stat(s) patched" ],
|
||||||
|
[ safeNumber(result.equipmentPatched), "equipment stat(s) patched" ],
|
||||||
|
[ safeNumber(result.achievementsPatched), "achievement stat(s) patched" ],
|
||||||
|
[ safeNumber(result.craftingRecipesReapplied), "crafting recipe(s) reapplied" ],
|
||||||
|
];
|
||||||
|
const parts = entries.
|
||||||
|
filter(([ count ]) => {
|
||||||
|
return count > 0;
|
||||||
|
}).
|
||||||
|
map(([ count, label ]) => {
|
||||||
|
return `${String(count)} ${label}`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Your save is already up to date — no new content was found.";
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
|
return sum + count;
|
||||||
|
}, 0);
|
||||||
|
return `Synced ${String(total)} item(s): ${parts.join(", ")}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ForceUnlocksResult {
|
||||||
|
adventurersUnlocked: number | undefined;
|
||||||
|
bossesUnlocked: number | undefined;
|
||||||
|
equipmentUnlocked: number | undefined;
|
||||||
|
explorationUnlocked: number | undefined;
|
||||||
|
questsUnlocked: number | undefined;
|
||||||
|
storyUnlocked: number | undefined;
|
||||||
|
upgradesUnlocked: number | undefined;
|
||||||
|
zonesUnlocked: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a human-readable summary of what the force-unlock operation corrected.
|
||||||
|
* @param result - The counts returned by the force-unlock operation.
|
||||||
|
* @returns A message string describing what was fixed, or a confirmation that nothing needed fixing.
|
||||||
|
*/
|
||||||
|
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
|
||||||
|
const entries: Array<[ number, string ]> = [
|
||||||
|
[ safeNumber(result.zonesUnlocked), "zone(s)" ],
|
||||||
|
[ safeNumber(result.questsUnlocked), "quest(s)" ],
|
||||||
|
[ safeNumber(result.bossesUnlocked), "boss(es)" ],
|
||||||
|
[ safeNumber(result.explorationUnlocked), "exploration area(s)" ],
|
||||||
|
[ safeNumber(result.adventurersUnlocked), "adventurer tier(s)" ],
|
||||||
|
[ safeNumber(result.upgradesUnlocked), "upgrade(s)" ],
|
||||||
|
[ safeNumber(result.equipmentUnlocked), "equipment item(s)" ],
|
||||||
|
[ safeNumber(result.storyUnlocked), "story chapter(s)" ],
|
||||||
|
];
|
||||||
|
const parts = entries.
|
||||||
|
filter(([ count ]) => {
|
||||||
|
return count > 0;
|
||||||
|
}).
|
||||||
|
map(([ count, label ]) => {
|
||||||
|
return `${String(count)} ${label}`;
|
||||||
|
});
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Everything looks correct — no missing unlocks were found.";
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, [ count ]) => {
|
||||||
|
return sum + count;
|
||||||
|
}, 0);
|
||||||
|
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the debug panel with tools for fixing stuck game state.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const DebugPanel = (): JSX.Element => {
|
||||||
|
const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
|
||||||
|
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
||||||
|
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
||||||
|
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleOpenForceUnlocks(): void {
|
||||||
|
setForceUnlocksResult(null);
|
||||||
|
setActiveModal("force-unlocks");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenSyncNewContent(): void {
|
||||||
|
setSyncNewContentResult(null);
|
||||||
|
setActiveModal("sync-new-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenHardReset(): void {
|
||||||
|
setActiveModal("hard-reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmForceUnlocks(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void (async(): Promise<void> => {
|
||||||
|
const result = await forceUnlocks();
|
||||||
|
setForceUnlocksResult(buildForceUnlocksMessage(result));
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmSyncNewContent(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void (async(): Promise<void> => {
|
||||||
|
const result = await syncNewContent();
|
||||||
|
setSyncNewContentResult(buildSyncNewContentMessage(result));
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmHardReset(): void {
|
||||||
|
setActiveModal(null);
|
||||||
|
void debugHardReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h2>{"🔧 Debug Tools"}</h2>
|
||||||
|
<p className="panel-description">
|
||||||
|
{
|
||||||
|
"These tools are intended to fix broken game state. Use them with care — some operations are irreversible."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="debug-actions">
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"🔓 Force Unlocks"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"Scans your game state and unlocks any zones, quests, and bosses that you have earned but that are still incorrectly locked."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenForceUnlocks}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Force Unlocks"}
|
||||||
|
</button>
|
||||||
|
{forceUnlocksResult !== null
|
||||||
|
&& <p className="debug-result-message">{forceUnlocksResult}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"🔄 Sync New Content"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenSyncNewContent}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Sync New Content"}
|
||||||
|
</button>
|
||||||
|
{syncNewContentResult !== null
|
||||||
|
&& <p className="debug-result-message">{syncNewContentResult}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-action-card">
|
||||||
|
<h3>{"💀 Hard Reset"}</h3>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
"Completely wipes all progress and resets your account to a brand-new state. This cannot be undone."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="action-button action-button-danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOpenHardReset}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Hard Reset"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeModal === "force-unlocks"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Force Unlocks"
|
||||||
|
description="This will scan your save data and grant access to any zones, quests, and bosses that you have already earned but are incorrectly locked. This operation is safe and non-destructive."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmForceUnlocks}
|
||||||
|
title="Force Unlocks"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{activeModal === "sync-new-content"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Sync New Content"
|
||||||
|
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmSyncNewContent}
|
||||||
|
title="Sync New Content"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{activeModal === "hard-reset"
|
||||||
|
&& <ConfirmationModal
|
||||||
|
confirmLabel="Yes, Wipe Everything"
|
||||||
|
description="This will permanently delete all of your current progress — gold, adventurers, upgrades, bosses, quests, and zones — and reset your account to a brand-new state. Lifetime stats are preserved, but everything else will be gone forever."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onConfirm={handleConfirmHardReset}
|
||||||
|
title="⚠️ Hard Reset — This Cannot Be Undone"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DebugPanel };
|
||||||
@@ -225,6 +225,10 @@ const EditProfileModal = ({
|
|||||||
void handleNotificationsEnable();
|
void handleNotificationsEnable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePrestigeAnnouncementsToggle(): void {
|
||||||
|
toggleSetting("enablePrestigeAnnouncements");
|
||||||
|
}
|
||||||
|
|
||||||
const isSaveDisabled = saving || characterName.trim() === "";
|
const isSaveDisabled = saving || characterName.trim() === "";
|
||||||
|
|
||||||
let saveLabel = "Save Profile";
|
let saveLabel = "Save Profile";
|
||||||
@@ -417,6 +421,23 @@ const EditProfileModal = ({
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`stat-toggle-btn ${
|
||||||
|
profileSettings.enablePrestigeAnnouncements
|
||||||
|
? "stat-toggle-on"
|
||||||
|
: "stat-toggle-off"
|
||||||
|
}`}
|
||||||
|
onClick={handlePrestigeAnnouncementsToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{"⭐ Prestige Bot Announcements"}</span>
|
||||||
|
<span className="stat-toggle-indicator">
|
||||||
|
{profileSettings.enablePrestigeAnnouncements
|
||||||
|
? "✓ On"
|
||||||
|
: "Off"
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="edit-profile-section">
|
<div className="edit-profile-section">
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||||
|
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||||
|
|
||||||
@@ -20,12 +22,6 @@ const rarityLabel: Record<string, string> = {
|
|||||||
rare: "Rare",
|
rare: "Rare",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcon: Record<EquipmentType, string> = {
|
|
||||||
armour: "🛡️",
|
|
||||||
trinket: "💍",
|
|
||||||
weapon: "⚔️",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes a human-readable bonus description for a piece of equipment.
|
* Computes a human-readable bonus description for a piece of equipment.
|
||||||
* @param item - The equipment item.
|
* @param item - The equipment item.
|
||||||
@@ -35,7 +31,7 @@ const bonusDescription = (item: Equipment): string => {
|
|||||||
const parts: Array<string> = [];
|
const parts: Array<string> = [];
|
||||||
if (item.bonus.combatMultiplier !== undefined) {
|
if (item.bonus.combatMultiplier !== undefined) {
|
||||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||||
parts.push(`+${String(pct)}% Combat`);
|
parts.push(`+${String(pct)}% Boss Combat`);
|
||||||
}
|
}
|
||||||
if (item.bonus.goldMultiplier !== undefined) {
|
if (item.bonus.goldMultiplier !== undefined) {
|
||||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||||
@@ -128,7 +124,11 @@ const EquipmentCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||||
>
|
>
|
||||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
<img
|
||||||
|
alt={item.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("equipment", item.id)}
|
||||||
|
/>
|
||||||
<div className="equipment-info">
|
<div className="equipment-info">
|
||||||
<div className="equipment-name-row">
|
<div className="equipment-name-row">
|
||||||
<h3>{item.name}</h3>
|
<h3>{item.name}</h3>
|
||||||
@@ -189,6 +189,20 @@ const EquipmentCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a combined power score for sorting — sum of all bonus multipliers.
|
||||||
|
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
|
||||||
|
* @param item - The equipment piece whose bonus multipliers are summed.
|
||||||
|
* @returns The combined bonus value.
|
||||||
|
*/
|
||||||
|
const equipmentPower = (item: Equipment): number => {
|
||||||
|
return (
|
||||||
|
(item.bonus.combatMultiplier ?? 1)
|
||||||
|
+ (item.bonus.goldMultiplier ?? 1)
|
||||||
|
+ (item.bonus.clickMultiplier ?? 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||||
const slotLabel: Record<EquipmentType, string> = {
|
const slotLabel: Record<EquipmentType, string> = {
|
||||||
armour: "🛡️ Armour",
|
armour: "🛡️ Armour",
|
||||||
@@ -262,7 +276,7 @@ const EquipmentPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
if (bonus.combatMultiplier !== undefined) {
|
if (bonus.combatMultiplier !== undefined) {
|
||||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
|
||||||
}
|
}
|
||||||
if (bonus.clickMultiplier !== undefined) {
|
if (bonus.clickMultiplier !== undefined) {
|
||||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||||
@@ -321,6 +335,8 @@ const EquipmentPanel = (): JSX.Element => {
|
|||||||
{slotOrder.map((slotType) => {
|
{slotOrder.map((slotType) => {
|
||||||
const items = equipment.filter((item) => {
|
const items = equipment.filter((item) => {
|
||||||
return item.type === slotType && (showLocked || item.owned);
|
return item.type === slotType && (showLocked || item.owned);
|
||||||
|
}).sort((a, b) => {
|
||||||
|
return equipmentPower(a) - equipmentPower(b);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="equipment-slot-section" key={slotType}>
|
<div className="equipment-slot-section" key={slotType}>
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||||
import { type JSX, useState } from "react";
|
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||||
|
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
|
||||||
|
import { type JSX, useEffect, useRef, useState } from "react";
|
||||||
|
import { checkExplorationClaimable } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { ExploreCollectResponse } from "@elysium/types";
|
import type {
|
||||||
|
ExploreClaimableResponse,
|
||||||
|
ExploreCollectResponse,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a duration in seconds to a human-readable string.
|
* Formats a duration in seconds to a human-readable string.
|
||||||
@@ -45,11 +52,21 @@ const formatDuration = (seconds: number): string => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the time remaining for an exploration in progress.
|
* Computes the time remaining for an exploration in progress.
|
||||||
|
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
|
||||||
|
* Falls back to startedAt + durationSeconds for saves predating the endsAt field.
|
||||||
|
* @param endsAt - The server-computed completion timestamp, if available.
|
||||||
* @param startedAt - The timestamp when exploration started.
|
* @param startedAt - The timestamp when exploration started.
|
||||||
* @param durationSeconds - The total duration in seconds.
|
* @param durationSeconds - The total duration in seconds.
|
||||||
* @returns The remaining seconds.
|
* @returns The remaining seconds.
|
||||||
*/
|
*/
|
||||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
const timeRemaining = (
|
||||||
|
endsAt: number | undefined,
|
||||||
|
startedAt: number,
|
||||||
|
durationSeconds: number,
|
||||||
|
): number => {
|
||||||
|
if (endsAt !== undefined) {
|
||||||
|
return Math.max(0, (endsAt - Date.now()) / 1000);
|
||||||
|
}
|
||||||
const elapsed = (Date.now() - startedAt) / 1000;
|
const elapsed = (Date.now() - startedAt) / 1000;
|
||||||
return Math.max(0, durationSeconds - elapsed);
|
return Math.max(0, durationSeconds - elapsed);
|
||||||
};
|
};
|
||||||
@@ -66,9 +83,66 @@ interface CollectResult {
|
|||||||
const ExplorationPanel = (): JSX.Element => {
|
const ExplorationPanel = (): JSX.Element => {
|
||||||
const { state, startExploration, collectExploration, formatNumber }
|
const { state, startExploration, collectExploration, formatNumber }
|
||||||
= useGame();
|
= useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||||
|
const [ claimableAreaIds, setClaimableAreaIds ]
|
||||||
|
= useState<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
|
const stateReference = useRef(state);
|
||||||
|
stateReference.current = state;
|
||||||
|
|
||||||
|
const claimableReference = useRef(claimableAreaIds);
|
||||||
|
claimableReference.current = claimableAreaIds;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pollClaimable = async(): Promise<void> => {
|
||||||
|
const currentState = stateReference.current;
|
||||||
|
if (currentState === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inProgressArea = currentState.exploration?.areas.find((a) => {
|
||||||
|
return a.status === "in_progress";
|
||||||
|
});
|
||||||
|
if (inProgressArea === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (claimableReference.current.has(inProgressArea.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||||
|
return a.id === inProgressArea.id;
|
||||||
|
});
|
||||||
|
if (areaData === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = timeRemaining(
|
||||||
|
inProgressArea.endsAt,
|
||||||
|
inProgressArea.startedAt ?? 0,
|
||||||
|
areaData.durationSeconds,
|
||||||
|
);
|
||||||
|
if (remaining > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result: ExploreClaimableResponse
|
||||||
|
= await checkExplorationClaimable(inProgressArea.id);
|
||||||
|
if (result.claimable) {
|
||||||
|
setClaimableAreaIds((previous) => {
|
||||||
|
return new Set([ ...previous, inProgressArea.id ]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
void pollClaimable();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +152,24 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { zones, exploration: explorationState } = state;
|
const { zones, exploration: explorationState, bosses, quests } = state;
|
||||||
|
|
||||||
|
const activeZone = zones.find((zone) => {
|
||||||
|
return zone.id === activeZoneId;
|
||||||
|
});
|
||||||
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
|
|
||||||
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
||||||
return area.zoneId === activeZoneId;
|
return area.zoneId === activeZoneId;
|
||||||
@@ -103,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
try {
|
try {
|
||||||
const result = await collectExploration(areaId);
|
const result = await collectExploration(areaId);
|
||||||
setLastResult({ areaId: areaId, response: result });
|
setLastResult({ areaId: areaId, response: result });
|
||||||
|
setClaimableAreaIds((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
next.delete(areaId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPendingAreaId(null);
|
setPendingAreaId(null);
|
||||||
}
|
}
|
||||||
@@ -115,6 +211,7 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
function handleZoneSelect(id: string): void {
|
function handleZoneSelect(id: string): void {
|
||||||
setActiveZoneId(id);
|
setActiveZoneId(id);
|
||||||
setLastResult(null);
|
setLastResult(null);
|
||||||
|
sessionStorage.setItem("elysium_explore_zone", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
||||||
@@ -206,6 +303,27 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock exploration by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
<div className="exploration-list">
|
<div className="exploration-list">
|
||||||
{zoneAreas.map((area) => {
|
{zoneAreas.map((area) => {
|
||||||
const areaState = explorationState?.areas.find((explorationArea) => {
|
const areaState = explorationState?.areas.find((explorationArea) => {
|
||||||
@@ -213,9 +331,10 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
const status = areaState?.status ?? "locked";
|
const status = areaState?.status ?? "locked";
|
||||||
const startedAt = areaState?.startedAt ?? 0;
|
const startedAt = areaState?.startedAt ?? 0;
|
||||||
|
const endsAt = areaState?.endsAt;
|
||||||
const isReady
|
const isReady
|
||||||
= status === "in_progress"
|
= status === "in_progress"
|
||||||
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
&& claimableAreaIds.has(area.id);
|
||||||
const isPending = pendingAreaId === area.id;
|
const isPending = pendingAreaId === area.id;
|
||||||
|
|
||||||
function handleStartClick(): void {
|
function handleStartClick(): void {
|
||||||
@@ -230,6 +349,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
className={`exploration-card exploration-${status}`}
|
className={`exploration-card exploration-${status}`}
|
||||||
key={area.id}
|
key={area.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={area.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("explorations", area.id)}
|
||||||
|
/>
|
||||||
<div className="exploration-info">
|
<div className="exploration-info">
|
||||||
<h3>
|
<h3>
|
||||||
{area.name}
|
{area.name}
|
||||||
@@ -267,9 +391,8 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
{status === "in_progress" && !isReady
|
{status === "in_progress" && !isReady
|
||||||
&& <span className="quest-badge active">
|
&& <span className="quest-badge active">
|
||||||
{"⏳ "}
|
{"⏳ "}
|
||||||
{formatDuration(
|
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
||||||
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
||||||
)}
|
|
||||||
{" remaining"}
|
{" remaining"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,18 @@ import { CodexToast } from "./codexToast.js";
|
|||||||
import { CompanionPanel } from "./companionPanel.js";
|
import { CompanionPanel } from "./companionPanel.js";
|
||||||
import { CraftingPanel } from "./craftingPanel.js";
|
import { CraftingPanel } from "./craftingPanel.js";
|
||||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||||
|
import { DebugPanel } from "./debugPanel.js";
|
||||||
import { EditProfileModal } from "./editProfileModal.js";
|
import { EditProfileModal } from "./editProfileModal.js";
|
||||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||||
import { ExplorationPanel } from "./explorationPanel.js";
|
import { ExplorationPanel } from "./explorationPanel.js";
|
||||||
|
import { JoinCommunityModal } from "./joinCommunityModal.js";
|
||||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||||
|
import { MilestoneToast } from "./milestoneToast.js";
|
||||||
import { OfflineModal } from "./offlineModal.js";
|
import { OfflineModal } from "./offlineModal.js";
|
||||||
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
|
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
|
||||||
import { PrestigePanel } from "./prestigePanel.js";
|
import { PrestigePanel } from "./prestigePanel.js";
|
||||||
import { QuestPanel } from "./questPanel.js";
|
import { QuestPanel } from "./questPanel.js";
|
||||||
|
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
|
||||||
import { StatisticsPanel } from "./statisticsPanel.js";
|
import { StatisticsPanel } from "./statisticsPanel.js";
|
||||||
import { StoryPanel } from "./storyPanel.js";
|
import { StoryPanel } from "./storyPanel.js";
|
||||||
import { StoryToast } from "./storyToast.js";
|
import { StoryToast } from "./storyToast.js";
|
||||||
@@ -55,7 +59,8 @@ type Tab =
|
|||||||
| "crafting"
|
| "crafting"
|
||||||
| "character"
|
| "character"
|
||||||
| "companions"
|
| "companions"
|
||||||
| "story";
|
| "story"
|
||||||
|
| "debug";
|
||||||
|
|
||||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||||
@@ -76,6 +81,7 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
|
|||||||
{ id: "story", label: "📖 Story" },
|
{ id: "story", label: "📖 Story" },
|
||||||
{ id: "codex", label: "🗺️ Codex" },
|
{ id: "codex", label: "🗺️ Codex" },
|
||||||
{ id: "about", label: "ℹ️ About" },
|
{ id: "about", label: "ℹ️ About" },
|
||||||
|
{ id: "debug", label: "🔧 Debug" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +136,6 @@ const GameLayout = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileUrl = `/profile/${state.player.discordId}`;
|
|
||||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||||
|
|
||||||
@@ -155,18 +160,23 @@ const GameLayout = (): JSX.Element => {
|
|||||||
onEditProfile={handleOpenEditProfile}
|
onEditProfile={handleOpenEditProfile}
|
||||||
onForceSync={forceSync}
|
onForceSync={forceSync}
|
||||||
prestigeCount={state.prestige.count}
|
prestigeCount={state.prestige.count}
|
||||||
profileUrl={profileUrl}
|
|
||||||
resources={state.resources}
|
resources={state.resources}
|
||||||
runestones={state.prestige.runestones}
|
runestones={state.prestige.runestones}
|
||||||
transcendenceCount={state.transcendence?.count ?? 0}
|
transcendenceCount={state.transcendence?.count ?? 0}
|
||||||
/>
|
/>
|
||||||
<OfflineModal />
|
<OfflineModal />
|
||||||
|
<JoinCommunityModal />
|
||||||
{schemaOutdated && !dismissedOutdatedWarning
|
{schemaOutdated && !dismissedOutdatedWarning
|
||||||
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
||||||
: null}
|
: null}
|
||||||
|
<div className="achievement-toast-container">
|
||||||
<AchievementToast />
|
<AchievementToast />
|
||||||
<CodexToast />
|
<CodexToast />
|
||||||
|
<MilestoneToast />
|
||||||
|
<QuestCompleteToast />
|
||||||
|
<QuestFailedToast />
|
||||||
<StoryToast />
|
<StoryToast />
|
||||||
|
</div>
|
||||||
{loginBonus === null
|
{loginBonus === null
|
||||||
? null
|
? null
|
||||||
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||||
@@ -182,6 +192,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
<div className="game-main">
|
<div className="game-main">
|
||||||
<aside className="game-sidebar">
|
<aside className="game-sidebar">
|
||||||
<ClickArea />
|
<ClickArea />
|
||||||
|
<div id="tree-nation-offset-website" />
|
||||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -234,6 +245,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
{activeTab === "story" && <StoryPanel />}
|
{activeTab === "story" && <StoryPanel />}
|
||||||
{activeTab === "codex" && <CodexPanel />}
|
{activeTab === "codex" && <CodexPanel />}
|
||||||
{activeTab === "about" && <AboutPanel />}
|
{activeTab === "about" && <AboutPanel />}
|
||||||
|
{activeTab === "debug" && <DebugPanel />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @file Modal prompting players to join the NHCarrigan Discord community.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { useCallback, useState, type JSX } from "react";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
|
||||||
|
const sessionKey = "elysium_join_community_dismissed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a modal prompting the player to join the NHCarrigan Discord server.
|
||||||
|
* Shown once per session when the player is not already in the guild.
|
||||||
|
* @returns The JSX element or null if the player is in the guild or dismissed.
|
||||||
|
*/
|
||||||
|
const JoinCommunityModal = (): JSX.Element | null => {
|
||||||
|
const { inGuild } = useGame();
|
||||||
|
const [ dismissed, setDismissed ] = useState(
|
||||||
|
() => {
|
||||||
|
return sessionStorage.getItem(sessionKey) === "true";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback((): void => {
|
||||||
|
sessionStorage.setItem(sessionKey, "true");
|
||||||
|
setDismissed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (inGuild || dismissed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>{"Join Our Community!"}</h2>
|
||||||
|
<p>
|
||||||
|
{"Did you know Elysium has an active Discord community? "}
|
||||||
|
{"Join to chat with other players, get updates, and earn "}
|
||||||
|
{"the exclusive Elysian role!"}
|
||||||
|
</p>
|
||||||
|
<p className="modal-note">
|
||||||
|
{"You already earn the Elysian role just by playing — "}
|
||||||
|
{"joining lets us show it off in the server!"}
|
||||||
|
</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<a
|
||||||
|
className="modal-close-button"
|
||||||
|
href="https://discord.gg/KKe7BaEnQB"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"Join Discord"}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Maybe later"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { JoinCommunityModal };
|
||||||
@@ -156,6 +156,9 @@ const LeaderboardPage = (): JSX.Element => {
|
|||||||
<p className="leaderboard-subtitle">
|
<p className="leaderboard-subtitle">
|
||||||
{"The mightiest adventurers in Elysium"}
|
{"The mightiest adventurers in Elysium"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="leaderboard-update-note">
|
||||||
|
{"🔄 Rankings update when you prestige."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="leaderboard-tabs">
|
<div className="leaderboard-tabs">
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @file Milestone toast notification component for prestige, transcendence, and apotheosis.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */
|
||||||
|
import { type JSX, useEffect } from "react";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
|
||||||
|
interface MilestoneToastItemProperties {
|
||||||
|
readonly icon: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly onDismiss: ()=> void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single milestone toast notification.
|
||||||
|
* @param props - The toast item properties.
|
||||||
|
* @param props.icon - The emoji icon.
|
||||||
|
* @param props.label - The label text.
|
||||||
|
* @param props.onDismiss - Callback to dismiss the toast.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const MilestoneToastItem = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onDismiss,
|
||||||
|
}: MilestoneToastItemProperties): JSX.Element => {
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onDismiss();
|
||||||
|
}, 4000);
|
||||||
|
return (): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [ onDismiss ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="game-toast" onClick={onDismiss}>
|
||||||
|
<span className="toast-icon">{icon}</span>
|
||||||
|
<div className="toast-content">
|
||||||
|
<span className="toast-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders all milestone toasts (prestige, transcendence, apotheosis).
|
||||||
|
* @returns The JSX element or null if no milestone toasts are pending.
|
||||||
|
*/
|
||||||
|
const MilestoneToast = (): JSX.Element | null => {
|
||||||
|
const {
|
||||||
|
showPrestigeToast,
|
||||||
|
showTranscendenceToast,
|
||||||
|
showApotheosisToast,
|
||||||
|
dismissPrestigeToast,
|
||||||
|
dismissTranscendenceToast,
|
||||||
|
dismissApotheosisToast,
|
||||||
|
} = useGame();
|
||||||
|
|
||||||
|
const hasAny
|
||||||
|
= showPrestigeToast || showTranscendenceToast || showApotheosisToast;
|
||||||
|
if (!hasAny) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showPrestigeToast
|
||||||
|
? <MilestoneToastItem
|
||||||
|
icon={"⭐"}
|
||||||
|
label={"⭐ Prestige!"}
|
||||||
|
onDismiss={dismissPrestigeToast}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
{showTranscendenceToast
|
||||||
|
? <MilestoneToastItem
|
||||||
|
icon={"🌌"}
|
||||||
|
label={"🌌 Transcendence!"}
|
||||||
|
onDismiss={dismissTranscendenceToast}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
{showApotheosisToast
|
||||||
|
? <MilestoneToastItem
|
||||||
|
icon={"✨"}
|
||||||
|
label={"✨ Apotheosis!"}
|
||||||
|
onDismiss={dismissApotheosisToast}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MilestoneToast };
|
||||||
@@ -12,24 +12,27 @@ import { useState, type JSX } from "react";
|
|||||||
import { prestige } from "../../api/client.js";
|
import { prestige } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import {
|
import {
|
||||||
PRESTIGE_UPGRADES,
|
|
||||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||||
|
PRESTIGE_UPGRADES,
|
||||||
} from "../../data/prestigeUpgrades.js";
|
} from "../../data/prestigeUpgrades.js";
|
||||||
|
import {
|
||||||
|
computeProjectedRunestones,
|
||||||
|
} from "../../engine/tick.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { sendNotification } from "../../utils/notification.js";
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
import { playSound } from "../../utils/sound.js";
|
import { playSound } from "../../utils/sound.js";
|
||||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const baseThreshold = 1_000_000;
|
const baseThreshold = 1_000_000;
|
||||||
const thresholdScale = 5;
|
|
||||||
const runestonesPerLevel = 10;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the prestige threshold for a given prestige count.
|
* Calculates the prestige threshold for a given prestige count.
|
||||||
|
* Mirrors the server formula: BASE * (count + 1)^2.5.
|
||||||
* @param prestigeCount - The current prestige count.
|
* @param prestigeCount - The current prestige count.
|
||||||
* @returns The required gold to prestige.
|
* @returns The required gold to prestige.
|
||||||
*/
|
*/
|
||||||
const calculateThreshold = (prestigeCount: number): number => {
|
const calculateThreshold = (prestigeCount: number): number => {
|
||||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
return baseThreshold * Math.pow(prestigeCount + 1, 2.5);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,33 +41,7 @@ const calculateThreshold = (prestigeCount: number): number => {
|
|||||||
* @returns The compounding multiplier applied to all income sources.
|
* @returns The compounding multiplier applied to all income sources.
|
||||||
*/
|
*/
|
||||||
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||||
return Math.pow(1.15, prestigeCount);
|
return Math.pow(1.3, prestigeCount);
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the runestone preview for a prestige.
|
|
||||||
* @param totalGoldEarned - Total gold earned this run.
|
|
||||||
* @param prestigeCount - The current prestige count.
|
|
||||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
|
||||||
* @returns The predicted runestone reward.
|
|
||||||
*/
|
|
||||||
const calculateRunestonePreview = (
|
|
||||||
totalGoldEarned: number,
|
|
||||||
prestigeCount: number,
|
|
||||||
purchasedUpgradeIds: Array<string>,
|
|
||||||
): number => {
|
|
||||||
const threshold = calculateThreshold(prestigeCount);
|
|
||||||
const base
|
|
||||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
|
||||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
|
||||||
return (
|
|
||||||
upgrade.category === "runestones"
|
|
||||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
|
||||||
);
|
|
||||||
}).reduce((mult, upgrade) => {
|
|
||||||
return mult * upgrade.multiplier;
|
|
||||||
}, 1);
|
|
||||||
return Math.floor(base * runestoneMult);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||||
@@ -83,12 +60,16 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
|||||||
const PrestigePanel = (): JSX.Element => {
|
const PrestigePanel = (): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
reload,
|
reloadSilent,
|
||||||
|
formatInteger,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
enableSounds,
|
enableSounds,
|
||||||
|
toggleAutoAdventurer,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
|
toggleAutoPrestigeMaxRunestones,
|
||||||
|
triggerPrestigeToast,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const [ isPending, setIsPending ] = useState(false);
|
const [ isPending, setIsPending ] = useState(false);
|
||||||
const [ result, setResult ] = useState<{
|
const [ result, setResult ] = useState<{
|
||||||
@@ -108,15 +89,16 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prestige: prestigeData, player } = state;
|
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||||
const threshold = calculateThreshold(prestigeData.count);
|
const threshold = calculateThreshold(prestigeData.count);
|
||||||
const isEligible = player.totalGoldEarned >= threshold;
|
const isEligible = player.totalGoldEarned >= threshold;
|
||||||
const runestonePreview = calculateRunestonePreview(
|
const runestonePreview = computeProjectedRunestones(state);
|
||||||
player.totalGoldEarned,
|
|
||||||
prestigeData.count,
|
|
||||||
prestigeData.purchasedUpgradeIds,
|
|
||||||
);
|
|
||||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||||
|
const baseRunestones = Math.min(
|
||||||
|
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const isAtMaxRunestones = baseRunestones >= 200;
|
||||||
|
|
||||||
async function handlePrestige(): Promise<void> {
|
async function handlePrestige(): Promise<void> {
|
||||||
setIsPending(true);
|
setIsPending(true);
|
||||||
@@ -128,6 +110,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
milestoneRunestones: data.milestoneRunestones,
|
milestoneRunestones: data.milestoneRunestones,
|
||||||
runestones: data.runestones,
|
runestones: data.runestones,
|
||||||
});
|
});
|
||||||
|
triggerPrestigeToast();
|
||||||
if (enableSounds) {
|
if (enableSounds) {
|
||||||
playSound("prestige");
|
playSound("prestige");
|
||||||
}
|
}
|
||||||
@@ -137,7 +120,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await reload();
|
await reloadSilent();
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
setPrestigeError(
|
setPrestigeError(
|
||||||
error_ instanceof Error
|
error_ instanceof Error
|
||||||
@@ -170,10 +153,18 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
void handlePrestige();
|
void handlePrestige();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAutoAdventurerToggle(): void {
|
||||||
|
toggleAutoAdventurer();
|
||||||
|
}
|
||||||
|
|
||||||
function handleAutoPrestigeToggle(): void {
|
function handleAutoPrestigeToggle(): void {
|
||||||
toggleAutoPrestige();
|
toggleAutoPrestige();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAutoPrestigeMaxRunestonesToggle(): void {
|
||||||
|
toggleAutoPrestigeMaxRunestones();
|
||||||
|
}
|
||||||
|
|
||||||
function handlePrestigeTabClick(): void {
|
function handlePrestigeTabClick(): void {
|
||||||
setActiveTab("prestige");
|
setActiveTab("prestige");
|
||||||
}
|
}
|
||||||
@@ -207,7 +198,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{"🔮 Runestone Shop ("}
|
{"🔮 Runestone Shop ("}
|
||||||
{formatNumber(prestigeData.runestones)}
|
{formatInteger(prestigeData.runestones)}
|
||||||
{" stones)"}
|
{" stones)"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +209,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
{"Prestige resets your progress but grants "}
|
{"Prestige resets your progress but grants "}
|
||||||
<strong>{"Runestones"}</strong>
|
<strong>{"Runestones"}</strong>
|
||||||
{"— permanent currency used for powerful upgrades."}
|
{"— permanent currency used for powerful upgrades."}
|
||||||
{" Each prestige multiplies your global production by ×1.15"}
|
{" Each prestige multiplies your global production by ×1.3"}
|
||||||
{" (compounding each run)."}
|
{" (compounding each run)."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -251,15 +242,25 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{"Runestones: "}
|
{"Runestones: "}
|
||||||
<strong>{formatNumber(prestigeData.runestones)}</strong>
|
<strong>{formatInteger(prestigeData.runestones)}</strong>
|
||||||
</p>
|
</p>
|
||||||
{isEligible
|
{isEligible
|
||||||
? <p className="runestone-preview">
|
? <p className="runestone-preview">
|
||||||
{"Runestones on prestige: "}
|
{"Runestones on prestige: "}
|
||||||
<strong>
|
<strong>
|
||||||
{"+"}
|
{"+"}
|
||||||
{formatNumber(runestonePreview)}
|
{formatInteger(runestonePreview)}
|
||||||
</strong>
|
</strong>
|
||||||
|
{isAtMaxRunestones
|
||||||
|
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
{isEligible && !isAtMaxRunestones
|
||||||
|
? <p className="runestone-progress-hint">
|
||||||
|
{"Earn more gold to increase your runestone yield "
|
||||||
|
+ "(capped at ×14³ the prestige threshold)."}
|
||||||
</p>
|
</p>
|
||||||
: null}
|
: null}
|
||||||
{isEligible
|
{isEligible
|
||||||
@@ -288,7 +289,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
{isPending
|
{isPending
|
||||||
? "Ascending..."
|
? "Ascending..."
|
||||||
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
: `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`}
|
||||||
</button>
|
</button>
|
||||||
{prestigeError === null
|
{prestigeError === null
|
||||||
? null
|
? null
|
||||||
@@ -300,12 +301,12 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
{"Ascended to Prestige "}
|
{"Ascended to Prestige "}
|
||||||
{result.count}
|
{result.count}
|
||||||
{"! Earned "}
|
{"! Earned "}
|
||||||
{formatNumber(result.runestones)}
|
{formatInteger(result.runestones)}
|
||||||
{" Runestones."}
|
{" Runestones."}
|
||||||
{result.milestoneRunestones > 0
|
{result.milestoneRunestones > 0
|
||||||
&& <>
|
&& <>
|
||||||
{" 🎉 Milestone bonus: +"}
|
{" 🎉 Milestone bonus: +"}
|
||||||
{formatNumber(result.milestoneRunestones)}
|
{formatInteger(result.milestoneRunestones)}
|
||||||
{" Runestones!"}
|
{" Runestones!"}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -326,7 +327,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
<p className="shop-balance">
|
<p className="shop-balance">
|
||||||
{"Balance: "}
|
{"Balance: "}
|
||||||
<strong>
|
<strong>
|
||||||
{formatNumber(prestigeData.runestones)}
|
{formatInteger(prestigeData.runestones)}
|
||||||
{" Runestones"}
|
{" Runestones"}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -344,10 +345,15 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
= prestigeData.runestones >= upgrade.runestonesCost;
|
= prestigeData.runestones >= upgrade.runestonesCost;
|
||||||
const isLoading = buyingId === upgrade.id;
|
const isLoading = buyingId === upgrade.id;
|
||||||
|
|
||||||
|
const isAutoAdventurerToggle
|
||||||
|
= upgrade.id === "auto_adventurer" && purchased;
|
||||||
|
const autoAdventurerEnabled = autoAdventurer ?? false;
|
||||||
const isAutoPrestigeToggle
|
const isAutoPrestigeToggle
|
||||||
= upgrade.id === "auto_prestige" && purchased;
|
= upgrade.id === "auto_prestige" && purchased;
|
||||||
const autoPrestigeEnabled
|
const autoPrestigeEnabled
|
||||||
= prestigeData.autoPrestigeEnabled ?? false;
|
= prestigeData.autoPrestigeEnabled ?? false;
|
||||||
|
const autoPrestigeMaxRunestonesOnly
|
||||||
|
= prestigeData.autoPrestigeMaxRunestonesOnly ?? false;
|
||||||
|
|
||||||
function handleBuyClick(): void {
|
function handleBuyClick(): void {
|
||||||
void handleBuyUpgrade(upgrade.id);
|
void handleBuyUpgrade(upgrade.id);
|
||||||
@@ -364,17 +370,38 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("prestige-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
<p className="upgrade-cost">
|
<p className="upgrade-cost">
|
||||||
{purchased
|
{purchased
|
||||||
? "✅ Purchased"
|
? "✅ Purchased"
|
||||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
: `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAutoPrestigeToggle
|
{isAutoAdventurerToggle
|
||||||
? <button
|
? <button
|
||||||
|
className={`auto-prestige-toggle ${
|
||||||
|
autoAdventurerEnabled
|
||||||
|
? "enabled"
|
||||||
|
: "disabled"
|
||||||
|
}`}
|
||||||
|
onClick={handleAutoAdventurerToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{autoAdventurerEnabled
|
||||||
|
? "⚡ Auto ON"
|
||||||
|
: "⏸ Auto OFF"}
|
||||||
|
</button>
|
||||||
|
: null}
|
||||||
|
{isAutoPrestigeToggle
|
||||||
|
? <>
|
||||||
|
<button
|
||||||
className={`auto-prestige-toggle ${
|
className={`auto-prestige-toggle ${
|
||||||
autoPrestigeEnabled
|
autoPrestigeEnabled
|
||||||
? "enabled"
|
? "enabled"
|
||||||
@@ -387,6 +414,23 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
? "⚡ Auto ON"
|
? "⚡ Auto ON"
|
||||||
: "⏸ Auto OFF"}
|
: "⏸ Auto OFF"}
|
||||||
</button>
|
</button>
|
||||||
|
{autoPrestigeEnabled
|
||||||
|
? <button
|
||||||
|
className={`auto-prestige-toggle ${
|
||||||
|
autoPrestigeMaxRunestonesOnly
|
||||||
|
? "enabled"
|
||||||
|
: "disabled"
|
||||||
|
}`}
|
||||||
|
onClick={handleAutoPrestigeMaxRunestonesToggle}
|
||||||
|
title="Only fire at max runestone yield"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{autoPrestigeMaxRunestonesOnly
|
||||||
|
? "⚡ Max Runes Only"
|
||||||
|
: "⏸ Max Runes OFF"}
|
||||||
|
</button>
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
: null}
|
: null}
|
||||||
{purchased
|
{purchased
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||||
import { useEffect, useState, type JSX } from "react";
|
import { useEffect, useState, type JSX } from "react";
|
||||||
import { formatNumber } from "../../utils/format.js";
|
import { formatNumber } from "../../utils/format.js";
|
||||||
|
import { logError } from "../../utils/logError.js";
|
||||||
import type { PublicProfileResponse } from "@elysium/types";
|
import type { PublicProfileResponse } from "@elysium/types";
|
||||||
|
|
||||||
interface ProfilePageProperties {
|
interface ProfilePageProperties {
|
||||||
@@ -52,11 +53,15 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
|||||||
}, [ discordId ]);
|
}, [ discordId ]);
|
||||||
|
|
||||||
function handleCopy(): void {
|
function handleCopy(): void {
|
||||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
void navigator.clipboard.writeText(window.location.href).
|
||||||
|
then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}).
|
||||||
|
catch((error_: unknown) => {
|
||||||
|
logError("clipboard_copy", error_);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,18 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
|
||||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import {
|
||||||
|
computePartyCombatPower,
|
||||||
|
zoneFailureChance,
|
||||||
|
} from "../../engine/tick.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Quest } from "@elysium/types";
|
import type { Quest } from "@elysium/types";
|
||||||
@@ -81,6 +87,11 @@ const QuestCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`quest-card quest-${quest.status}`}>
|
<div className={`quest-card quest-${quest.status}`}>
|
||||||
|
<img
|
||||||
|
alt={quest.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("quests", quest.id)}
|
||||||
|
/>
|
||||||
<div className="quest-info">
|
<div className="quest-info">
|
||||||
<h3>{quest.name}</h3>
|
<h3>{quest.name}</h3>
|
||||||
<p>{quest.description}</p>
|
<p>{quest.description}</p>
|
||||||
@@ -102,14 +113,15 @@ const QuestCard = ({
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<div className="quest-rewards">
|
<div className="quest-rewards">
|
||||||
{quest.rewards.map((reward) => {
|
{quest.rewards.map((reward, rewardIndex) => {
|
||||||
return (
|
return (
|
||||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
||||||
{reward.type === "gold"
|
{reward.type === "gold"
|
||||||
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "essence"
|
{reward.type === "essence"
|
||||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "crystals"
|
{reward.type === "crystals"
|
||||||
|
&& (reward.amount ?? 0) > 0
|
||||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||||
@@ -137,8 +149,17 @@ const QuestCard = ({
|
|||||||
: null}
|
: null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
{quest.status === "available"
|
||||||
|
&& <p className="quest-failure-chance">
|
||||||
|
{"🎲 "}
|
||||||
|
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
|
||||||
|
{"% failure chance"}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
&& <p className="quest-failed-hint">
|
||||||
|
{"⚠️ Last attempt failed — no rewards were granted."}
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
{quest.status === "available"
|
{quest.status === "available"
|
||||||
&& <button
|
&& <button
|
||||||
@@ -178,7 +199,9 @@ const QuestCard = ({
|
|||||||
*/
|
*/
|
||||||
const QuestPanel = (): JSX.Element => {
|
const QuestPanel = (): JSX.Element => {
|
||||||
const { state, toggleAutoQuest } = useGame();
|
const { state, toggleAutoQuest } = useGame();
|
||||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||||
|
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
|
||||||
|
});
|
||||||
const [ showLocked, setShowLocked ] = useState(true);
|
const [ showLocked, setShowLocked ] = useState(true);
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
@@ -189,12 +212,25 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { adventurers, autoQuest, quests, zones } = state;
|
const { autoQuest, bosses, quests, zones } = state;
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
|
||||||
const partyCombatPower = adventurers.reduce((total, adventurer) => {
|
const activeZone = zones.find((zone) => {
|
||||||
const power = total + adventurer.combatPower;
|
return zone.id === activeZoneId;
|
||||||
return power * adventurer.count;
|
});
|
||||||
}, 0);
|
const zoneIsLocked = activeZone?.status === "locked";
|
||||||
|
const unlockBoss = activeZone?.unlockBossId === null
|
||||||
|
|| activeZone?.unlockBossId === undefined
|
||||||
|
? undefined
|
||||||
|
: bosses.find((boss) => {
|
||||||
|
return boss.id === activeZone.unlockBossId;
|
||||||
|
});
|
||||||
|
const unlockQuest = activeZone?.unlockQuestId === null
|
||||||
|
|| activeZone?.unlockQuestId === undefined
|
||||||
|
? undefined
|
||||||
|
: quests.find((quest) => {
|
||||||
|
return quest.id === activeZone.unlockQuestId;
|
||||||
|
});
|
||||||
|
const partyCombatPower = computePartyCombatPower(state);
|
||||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||||
return zoneId === activeZoneId;
|
return zoneId === activeZoneId;
|
||||||
});
|
});
|
||||||
@@ -237,6 +273,11 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZoneSelect(zoneId: string): void {
|
||||||
|
setActiveZoneId(zoneId);
|
||||||
|
sessionStorage.setItem("elysium_quest_zone", zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
return !current;
|
return !current;
|
||||||
@@ -279,10 +320,35 @@ const QuestPanel = (): JSX.Element => {
|
|||||||
|
|
||||||
<ZoneSelector
|
<ZoneSelector
|
||||||
activeZoneId={activeZoneId}
|
activeZoneId={activeZoneId}
|
||||||
onSelectZone={setActiveZoneId}
|
onSelectZone={handleZoneSelect}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
||||||
|
? <div className="exploration-zone-locked-hint">
|
||||||
|
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
|
||||||
|
{unlockBoss === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"⚔️ Defeat: "}
|
||||||
|
{unlockBoss.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuest === undefined
|
||||||
|
? null
|
||||||
|
: <p>
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuest.name}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
<p className="quest-failure-note">
|
||||||
|
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="quest-list">
|
<div className="quest-list">
|
||||||
{visibleQuests.map((quest) => {
|
{visibleQuests.map((quest) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* @file Quest toast notification component for completed and failed quests.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */
|
||||||
|
import { type JSX, useEffect } from "react";
|
||||||
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import type { Quest } from "@elysium/types";
|
||||||
|
|
||||||
|
interface QuestToastItemProperties {
|
||||||
|
readonly quest: Quest;
|
||||||
|
readonly onDismiss: (id: string)=> void;
|
||||||
|
// eslint-disable-next-line react/require-default-props -- Default value set in destructuring
|
||||||
|
readonly isFailure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single quest toast notification.
|
||||||
|
* @param props - The toast item properties.
|
||||||
|
* @param props.quest - The quest to display.
|
||||||
|
* @param props.onDismiss - Callback to dismiss the toast.
|
||||||
|
* @param props.isFailure - Whether this is a failure toast.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const QuestToastItem = ({
|
||||||
|
quest,
|
||||||
|
onDismiss,
|
||||||
|
isFailure = false,
|
||||||
|
}: QuestToastItemProperties): JSX.Element => {
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onDismiss(quest.id);
|
||||||
|
}, 4000);
|
||||||
|
return (): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [ quest.id, onDismiss ]);
|
||||||
|
|
||||||
|
function handleClick(): void {
|
||||||
|
onDismiss(quest.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="game-toast" onClick={handleClick}>
|
||||||
|
<span className="toast-icon">{isFailure
|
||||||
|
? "💀"
|
||||||
|
: "📜"}</span>
|
||||||
|
<div className="toast-content">
|
||||||
|
<span className="toast-label">{isFailure
|
||||||
|
? "Quest Failed!"
|
||||||
|
: "✨ Quest Complete!"}</span>
|
||||||
|
<span className="toast-name">{quest.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the quest complete toast container.
|
||||||
|
* @returns The JSX element or null if there are no pending quest toasts.
|
||||||
|
*/
|
||||||
|
const QuestCompleteToast = (): JSX.Element | null => {
|
||||||
|
const { completedQuestToasts, dismissCompletedQuest } = useGame();
|
||||||
|
|
||||||
|
if (completedQuestToasts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{completedQuestToasts.map((quest) => {
|
||||||
|
return (
|
||||||
|
<QuestToastItem
|
||||||
|
key={quest.id}
|
||||||
|
onDismiss={dismissCompletedQuest}
|
||||||
|
quest={quest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the quest failed toast container.
|
||||||
|
* @returns The JSX element or null if there are no pending failure toasts.
|
||||||
|
*/
|
||||||
|
const QuestFailedToast = (): JSX.Element | null => {
|
||||||
|
const { failedQuestToasts, dismissFailedQuest } = useGame();
|
||||||
|
|
||||||
|
if (failedQuestToasts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{failedQuestToasts.map((quest) => {
|
||||||
|
return (
|
||||||
|
<QuestToastItem
|
||||||
|
isFailure={true}
|
||||||
|
key={quest.id}
|
||||||
|
onDismiss={dismissFailedQuest}
|
||||||
|
quest={quest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { QuestCompleteToast, QuestFailedToast };
|
||||||
@@ -59,7 +59,7 @@ const StatCard = ({
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const StatisticsPanel = (): JSX.Element => {
|
const StatisticsPanel = (): JSX.Element => {
|
||||||
const { state, formatNumber } = useGame();
|
const { state, formatInteger, formatNumber } = useGame();
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
return (
|
return (
|
||||||
@@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => {
|
|||||||
<StatCard
|
<StatCard
|
||||||
icon="💎"
|
icon="💎"
|
||||||
label="Crystals"
|
label="Crystals"
|
||||||
value={formatNumber(resources.crystals)}
|
value={formatInteger(resources.crystals)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="🔮"
|
icon="🔮"
|
||||||
label="Runestones"
|
label="Runestones"
|
||||||
sub="permanent currency"
|
sub="permanent currency"
|
||||||
value={formatNumber(prestige.runestones)}
|
value={formatInteger(prestige.runestones)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { STORY_CHAPTERS } from "@elysium/types";
|
import { STORY_CHAPTERS } from "@elysium/types";
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitutes the character name placeholder in story text.
|
* Substitutes the character name placeholder in story text.
|
||||||
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
|
|||||||
: <div className="story-chapter-view">
|
: <div className="story-chapter-view">
|
||||||
{isUnlocked
|
{isUnlocked
|
||||||
? <>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={activeChapter.title}
|
||||||
|
className="story-chapter-banner"
|
||||||
|
src={cdnImage("story-chapters", activeChapter.id)}
|
||||||
|
/>
|
||||||
<h2 className="story-chapter-title">
|
<h2 className="story-chapter-title">
|
||||||
{"Chapter "}
|
{"Chapter "}
|
||||||
{activeChapterIndex + 1}
|
{activeChapterIndex + 1}
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ const StoryToastItem = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="achievement-toast" onClick={handleClick} type="button">
|
<div className="game-toast" onClick={handleClick}>
|
||||||
<span className="achievement-toast-icon">{"📖"}</span>
|
<span className="toast-icon">{"📖"}</span>
|
||||||
<div className="achievement-toast-content">
|
<div className="toast-content">
|
||||||
<span className="achievement-toast-label">{"✨ New Chapter!"}</span>
|
<span className="toast-label">{"✨ New Chapter!"}</span>
|
||||||
<span className="achievement-toast-name">{chapter.title}</span>
|
<span className="toast-name">{chapter.title}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,11 +65,11 @@ const StoryToast = (): JSX.Element | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="achievement-toast-container">
|
<>
|
||||||
{pendingChapterIds.map((id) => {
|
{pendingChapterIds.map((id) => {
|
||||||
return <StoryToastItem chapterId={id} key={id} />;
|
return <StoryToastItem chapterId={id} key={id} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||||
|
/* eslint-disable max-lines -- Transcendence panel with CDN images exceeds line limit */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import {
|
import {
|
||||||
TRANSCENDENCE_UPGRADES,
|
TRANSCENDENCE_UPGRADES,
|
||||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/transcendenceUpgrades.js";
|
} from "../../data/transcendenceUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const echoFormulaConstant = 853;
|
const echoFormulaConstant = 853;
|
||||||
@@ -48,7 +50,7 @@ const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
|||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const TranscendencePanel = (): JSX.Element => {
|
const TranscendencePanel = (): JSX.Element => {
|
||||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
const { state, formatInteger, transcend, buyEchoUpgrade } = useGame();
|
||||||
const [ isPending, setIsPending ] = useState(false);
|
const [ isPending, setIsPending ] = useState(false);
|
||||||
const [ result, setResult ] = useState<{
|
const [ result, setResult ] = useState<{
|
||||||
echoes: number;
|
echoes: number;
|
||||||
@@ -150,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{"✨ Echo Shop ("}
|
{"✨ Echo Shop ("}
|
||||||
{formatNumber(currentEchoes)}
|
{formatInteger(currentEchoes)}
|
||||||
{" echoes)"}
|
{" echoes)"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
<p>
|
<p>
|
||||||
{"Current Echoes: "}
|
{"Current Echoes: "}
|
||||||
<strong>{formatNumber(currentEchoes)}</strong>
|
<strong>{formatInteger(currentEchoes)}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{"Current prestige count: "}
|
{"Current prestige count: "}
|
||||||
@@ -193,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
{"Echoes on transcendence: "}
|
{"Echoes on transcendence: "}
|
||||||
<strong>
|
<strong>
|
||||||
{"+"}
|
{"+"}
|
||||||
{formatNumber(echoPreview)}
|
{formatInteger(echoPreview)}
|
||||||
</strong>
|
</strong>
|
||||||
{echoMetaMultiplier > 1
|
{echoMetaMultiplier > 1
|
||||||
&& <span className="echo-meta-bonus">
|
&& <span className="echo-meta-bonus">
|
||||||
@@ -236,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
{isPending
|
{isPending
|
||||||
? "Transcending..."
|
? "Transcending..."
|
||||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
: `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`}
|
||||||
</button>
|
</button>
|
||||||
{error === null
|
{error === null
|
||||||
? null
|
? null
|
||||||
@@ -246,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
: <p className="success">
|
: <p className="success">
|
||||||
{"Transcended! Earned "}
|
{"Transcended! Earned "}
|
||||||
<strong>
|
<strong>
|
||||||
{formatNumber(result.echoes)}
|
{formatInteger(result.echoes)}
|
||||||
{" Echoes"}
|
{" Echoes"}
|
||||||
</strong>
|
</strong>
|
||||||
{". This is Transcendence "}
|
{". This is Transcendence "}
|
||||||
@@ -264,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
<p className="shop-balance">
|
<p className="shop-balance">
|
||||||
{"Balance: "}
|
{"Balance: "}
|
||||||
<strong>
|
<strong>
|
||||||
{formatNumber(currentEchoes)}
|
{formatInteger(currentEchoes)}
|
||||||
{" Echoes"}
|
{" Echoes"}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -301,13 +303,18 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("transcendence-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
<p className="upgrade-cost">
|
<p className="upgrade-cost">
|
||||||
{purchased
|
{purchased
|
||||||
? "✅ Purchased"
|
? "✅ Purchased"
|
||||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
: `✨ ${formatInteger(upgrade.cost)} Echoes`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{purchased
|
{purchased
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||||
|
/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */
|
||||||
|
/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Upgrade } from "@elysium/types";
|
import type { Adventurer, Upgrade } from "@elysium/types";
|
||||||
|
|
||||||
interface UpgradeCardProperties {
|
interface UpgradeCardProperties {
|
||||||
readonly upgrade: Upgrade;
|
readonly upgrade: Upgrade;
|
||||||
@@ -19,6 +22,7 @@ interface UpgradeCardProperties {
|
|||||||
readonly currentCrystals: number;
|
readonly currentCrystals: number;
|
||||||
readonly unlockHint: string | undefined;
|
readonly unlockHint: string | undefined;
|
||||||
readonly formatNumber: (n: number)=> string;
|
readonly formatNumber: (n: number)=> string;
|
||||||
|
readonly adventurers: ReadonlyArray<Adventurer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +34,7 @@ interface UpgradeCardProperties {
|
|||||||
* @param props.currentCrystals - The current crystals amount.
|
* @param props.currentCrystals - The current crystals amount.
|
||||||
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
||||||
* @param props.formatNumber - The number formatting utility function.
|
* @param props.formatNumber - The number formatting utility function.
|
||||||
|
* @param props.adventurers - The list of adventurers, used to resolve the affected adventurer name.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
*/
|
*/
|
||||||
const UpgradeCard = ({
|
const UpgradeCard = ({
|
||||||
@@ -39,8 +44,14 @@ const UpgradeCard = ({
|
|||||||
currentCrystals,
|
currentCrystals,
|
||||||
unlockHint,
|
unlockHint,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
adventurers,
|
||||||
}: UpgradeCardProperties): JSX.Element => {
|
}: UpgradeCardProperties): JSX.Element => {
|
||||||
const { buyUpgrade } = useGame();
|
const { buyUpgrade } = useGame();
|
||||||
|
const adventurerName = upgrade.adventurerId === undefined
|
||||||
|
? undefined
|
||||||
|
: adventurers.find((adventurer) => {
|
||||||
|
return adventurer.id === upgrade.adventurerId;
|
||||||
|
})?.name;
|
||||||
const canAfford
|
const canAfford
|
||||||
= currentGold >= upgrade.costGold
|
= currentGold >= upgrade.costGold
|
||||||
&& currentEssence >= upgrade.costEssence
|
&& currentEssence >= upgrade.costEssence
|
||||||
@@ -53,11 +64,23 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked && upgrade.purchased) {
|
if (upgrade.unlocked && upgrade.purchased) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card purchased">
|
<div className="upgrade-card purchased">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<span className="upgrade-name">
|
<span className="upgrade-name">
|
||||||
{"✅ "}
|
{"✅ "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="upgrade-desc">{upgrade.description}</span>
|
<span className="upgrade-desc">{upgrade.description}</span>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <span className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,9 +88,21 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked) {
|
if (upgrade.unlocked) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card">
|
<div className="upgrade-card">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>{upgrade.name}</h3>
|
<h3>{upgrade.name}</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p className="upgrade-multiplier">
|
<p className="upgrade-multiplier">
|
||||||
{"×"}
|
{"×"}
|
||||||
{upgrade.multiplier}
|
{upgrade.multiplier}
|
||||||
@@ -108,12 +143,24 @@ const UpgradeCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card locked">
|
<div className="upgrade-card locked">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>
|
<h3>
|
||||||
{"🔒 "}
|
{"🔒 "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
{adventurerName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="upgrade-target">
|
||||||
|
{"🗡️ Affects: "}
|
||||||
|
{adventurerName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p className="upgrade-multiplier">
|
<p className="upgrade-multiplier">
|
||||||
{"×"}
|
{"×"}
|
||||||
{upgrade.multiplier}
|
{upgrade.multiplier}
|
||||||
@@ -165,7 +212,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bosses, quests, upgrades, resources } = state;
|
const { adventurers, bosses, quests, upgrades, resources } = state;
|
||||||
const purchased = upgrades.filter((upgrade) => {
|
const purchased = upgrades.filter((upgrade) => {
|
||||||
return upgrade.purchased;
|
return upgrade.purchased;
|
||||||
});
|
});
|
||||||
@@ -193,6 +240,22 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const upgrade of locked) {
|
||||||
|
if (
|
||||||
|
!upgradeUnlockHints.has(upgrade.id)
|
||||||
|
&& upgrade.adventurerId !== undefined
|
||||||
|
) {
|
||||||
|
const adventurerForHint = adventurers.find((a) => {
|
||||||
|
return a.id === upgrade.adventurerId;
|
||||||
|
});
|
||||||
|
if (adventurerForHint !== undefined) {
|
||||||
|
upgradeUnlockHints.set(
|
||||||
|
upgrade.id,
|
||||||
|
`🗡️ Recruit: ${adventurerForHint.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggle(): void {
|
function handleToggle(): void {
|
||||||
setShowLocked((current) => {
|
setShowLocked((current) => {
|
||||||
@@ -216,6 +279,10 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{upgrades.length}
|
{upgrades.length}
|
||||||
{" purchased"}
|
{" purchased"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="upgrade-stacking-note">
|
||||||
|
{"💡 Upgrade multipliers stack multiplicatively — two ×2 upgrades"
|
||||||
|
+ " combine to give ×4, not ×3."}
|
||||||
|
</p>
|
||||||
{upgrades.length === 0
|
{upgrades.length === 0
|
||||||
? <p className="empty-state">
|
? <p className="empty-state">
|
||||||
{"No upgrades available yet — keep adventuring!"}
|
{"No upgrades available yet — keep adventuring!"}
|
||||||
@@ -224,6 +291,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{available.map((upgrade) => {
|
{available.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
@@ -237,6 +305,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
{purchased.map((upgrade) => {
|
{purchased.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
@@ -251,6 +320,7 @@ const UpgradePanel = (): JSX.Element => {
|
|||||||
? locked.map((upgrade) => {
|
? locked.map((upgrade) => {
|
||||||
return (
|
return (
|
||||||
<UpgradeCard
|
<UpgradeCard
|
||||||
|
adventurers={adventurers}
|
||||||
currentCrystals={resources.crystals}
|
currentCrystals={resources.crystals}
|
||||||
currentEssence={resources.essence}
|
currentEssence={resources.essence}
|
||||||
currentGold={resources.gold}
|
currentGold={resources.gold}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { Zone } from "@elysium/types";
|
import type { Zone } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
@@ -44,7 +45,11 @@ const ZoneSelector = ({
|
|||||||
title={zone.description}
|
title={zone.description}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="zone-emoji">{zone.emoji}</span>
|
<img
|
||||||
|
alt={zone.name}
|
||||||
|
className="zone-tab-image"
|
||||||
|
src={cdnImage("zones", zone.id)}
|
||||||
|
/>
|
||||||
<span className="zone-name">{zone.name}</span>
|
<span className="zone-name">{zone.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file Reusable confirmation modal component for destructive operations.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
|
interface ConfirmationModalProperties {
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly confirmLabel: string;
|
||||||
|
readonly onConfirm: ()=> void;
|
||||||
|
readonly onCancel: ()=> void;
|
||||||
|
readonly isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a confirmation modal for destructive operations.
|
||||||
|
* @param props - The modal properties.
|
||||||
|
* @param props.title - The modal heading.
|
||||||
|
* @param props.description - Warning text explaining what the operation does.
|
||||||
|
* @param props.confirmLabel - Label for the confirm button.
|
||||||
|
* @param props.onConfirm - Callback fired when the player confirms.
|
||||||
|
* @param props.onCancel - Callback fired when the player cancels.
|
||||||
|
* @param props.isLoading - Whether the operation is currently in progress.
|
||||||
|
* @returns The JSX element.
|
||||||
|
*/
|
||||||
|
const ConfirmationModal = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
isLoading,
|
||||||
|
}: ConfirmationModalProperties): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
<p className="modal-note">{"Are you sure you want to do this?"}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="modal-close-button modal-button-danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Working..."
|
||||||
|
: confirmLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ConfirmationModal };
|
||||||
@@ -4,12 +4,20 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- Resource bar has many resource and action elements */
|
||||||
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
||||||
|
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
|
||||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||||
|
import { useState, type FocusEvent, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
import {
|
||||||
|
RESOURCE_CAP,
|
||||||
|
computeEssencePerSecond,
|
||||||
|
computeGoldPerSecond,
|
||||||
|
computePartyCombatPower,
|
||||||
|
computeProjectedRunestones,
|
||||||
|
} from "../../engine/tick.js";
|
||||||
import type { Resource } from "@elysium/types";
|
import type { Resource } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
|
||||||
|
|
||||||
interface ResourceBarProperties {
|
interface ResourceBarProperties {
|
||||||
readonly resources: Resource;
|
readonly resources: Resource;
|
||||||
@@ -17,7 +25,6 @@ interface ResourceBarProperties {
|
|||||||
readonly prestigeCount: number;
|
readonly prestigeCount: number;
|
||||||
readonly transcendenceCount: number;
|
readonly transcendenceCount: number;
|
||||||
readonly apotheosisCount: number;
|
readonly apotheosisCount: number;
|
||||||
readonly profileUrl: string;
|
|
||||||
readonly onEditProfile: ()=> void;
|
readonly onEditProfile: ()=> void;
|
||||||
readonly lastSavedAt: number | null;
|
readonly lastSavedAt: number | null;
|
||||||
readonly isSyncing: boolean;
|
readonly isSyncing: boolean;
|
||||||
@@ -58,7 +65,6 @@ const resourceFullTooltip = [
|
|||||||
* @param props.prestigeCount - The number of prestiges completed.
|
* @param props.prestigeCount - The number of prestiges completed.
|
||||||
* @param props.transcendenceCount - The number of transcendences completed.
|
* @param props.transcendenceCount - The number of transcendences completed.
|
||||||
* @param props.apotheosisCount - The number of apotheoses completed.
|
* @param props.apotheosisCount - The number of apotheoses completed.
|
||||||
* @param props.profileUrl - The URL of the player's public profile.
|
|
||||||
* @param props.onEditProfile - Callback to open the edit profile modal.
|
* @param props.onEditProfile - Callback to open the edit profile modal.
|
||||||
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
||||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||||
@@ -71,49 +77,138 @@ const ResourceBar = ({
|
|||||||
prestigeCount,
|
prestigeCount,
|
||||||
transcendenceCount,
|
transcendenceCount,
|
||||||
apotheosisCount,
|
apotheosisCount,
|
||||||
profileUrl,
|
|
||||||
onEditProfile,
|
onEditProfile,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
onForceSync,
|
onForceSync,
|
||||||
}: ResourceBarProperties): JSX.Element => {
|
}: ResourceBarProperties): JSX.Element => {
|
||||||
const { formatNumber, syncError } = useGame();
|
const { formatInteger, formatNumber, syncError, state } = useGame();
|
||||||
|
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||||
|
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||||
|
|
||||||
const { gold, essence, crystals } = resources;
|
const { gold, essence, crystals } = resources;
|
||||||
const resourceValues = [ gold, essence, crystals ];
|
let partyCombatPower = 0;
|
||||||
const anyFull = resourceValues.some((v) => {
|
let goldPerSecond = 0;
|
||||||
return v >= RESOURCE_CAP;
|
let essencePerSecond = 0;
|
||||||
});
|
let projectedRunestones = 0;
|
||||||
|
if (state !== null) {
|
||||||
|
partyCombatPower = computePartyCombatPower(state);
|
||||||
|
goldPerSecond = computeGoldPerSecond(state);
|
||||||
|
essencePerSecond = computeEssencePerSecond(state);
|
||||||
|
projectedRunestones = computeProjectedRunestones(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (state !== null) {
|
||||||
|
avatarUrl = state.player.avatar === null
|
||||||
|
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
|
||||||
|
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
|
||||||
|
}
|
||||||
|
const profileUrl = state === null
|
||||||
|
? "#"
|
||||||
|
: `/profile/${state.player.discordId}`;
|
||||||
|
|
||||||
const goldFull = gold >= RESOURCE_CAP;
|
const goldFull = gold >= RESOURCE_CAP;
|
||||||
const essenceFull = essence >= RESOURCE_CAP;
|
const essenceFull = essence >= RESOURCE_CAP;
|
||||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||||
|
const anyFull = goldFull || essenceFull || crystalsFull;
|
||||||
|
const hiddenResourcesFull = essenceFull || crystalsFull;
|
||||||
|
|
||||||
function handleForceSync(): void {
|
function handleForceSync(): void {
|
||||||
void onForceSync();
|
void onForceSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleResources(): void {
|
||||||
|
setIsResourcesOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResourceBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsResourcesOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleProfile(): void {
|
||||||
|
setIsProfileOpen((previous) => {
|
||||||
|
return !previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditProfile(): void {
|
||||||
|
setIsProfileOpen(false);
|
||||||
|
onEditProfile();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="resource-bar">
|
<header className="resource-bar">
|
||||||
<div className={`resource${goldFull
|
<div
|
||||||
|
className="resource-menu"
|
||||||
|
onBlur={handleResourceBlur}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`resource resource-toggle${goldFull
|
||||||
? " resource-full"
|
? " resource-full"
|
||||||
: ""}`}>
|
: ""}`}
|
||||||
<span className="resource-icon">{"🪙"}</span>
|
onClick={handleToggleResources}
|
||||||
|
title="Click to see all resources"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="resource-icon">{"💰"}</span>
|
||||||
<span className="resource-value">{formatNumber(gold)}</span>
|
<span className="resource-value">{formatNumber(gold)}</span>
|
||||||
<span className="resource-label">{"Gold"}</span>
|
<span className="resource-label">{"Gold"}</span>
|
||||||
{goldFull
|
{goldFull
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
{"FULL"}
|
{"FULL"}
|
||||||
</span>
|
</span>
|
||||||
: null}
|
: null}
|
||||||
|
{hiddenResourcesFull
|
||||||
|
? <span
|
||||||
|
className="resource-alert-dot"
|
||||||
|
title={"One or more resources are full!"}
|
||||||
|
/>
|
||||||
|
: null}
|
||||||
|
</button>
|
||||||
|
{isResourcesOpen
|
||||||
|
? <div className="resources-dropdown">
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"📈"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(goldPerSecond)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Gold/s"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⚡"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(essencePerSecond)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Essence/s"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`resource${essenceFull
|
<div className={`resource${essenceFull
|
||||||
? " resource-full"
|
? " resource-full"
|
||||||
: ""}`}>
|
: ""}`}>
|
||||||
<span className="resource-icon">{"✨"}</span>
|
<span className="resource-icon">{"✨"}</span>
|
||||||
<span className="resource-value">{formatNumber(essence)}</span>
|
<span className="resource-value">
|
||||||
|
{formatNumber(essence)}
|
||||||
|
</span>
|
||||||
<span className="resource-label">{"Essence"}</span>
|
<span className="resource-label">{"Essence"}</span>
|
||||||
{essenceFull
|
{essenceFull
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
{"FULL"}
|
{"FULL"}
|
||||||
</span>
|
</span>
|
||||||
: null}
|
: null}
|
||||||
@@ -122,19 +217,43 @@ const ResourceBar = ({
|
|||||||
? " resource-full"
|
? " resource-full"
|
||||||
: ""}`}>
|
: ""}`}>
|
||||||
<span className="resource-icon">{"💎"}</span>
|
<span className="resource-icon">{"💎"}</span>
|
||||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
<span className="resource-value">
|
||||||
|
{formatInteger(crystals)}
|
||||||
|
</span>
|
||||||
<span className="resource-label">{"Crystals"}</span>
|
<span className="resource-label">{"Crystals"}</span>
|
||||||
{crystalsFull
|
{crystalsFull
|
||||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
? <span
|
||||||
|
className="resource-cap-badge"
|
||||||
|
title={resourceFullTooltip}
|
||||||
|
>
|
||||||
{"FULL"}
|
{"FULL"}
|
||||||
</span>
|
</span>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="resource">
|
<div className="resource">
|
||||||
<span className="resource-icon">{"🔮"}</span>
|
<span className="resource-icon">{"🔮"}</span>
|
||||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
<span className="resource-value">
|
||||||
|
{formatInteger(runestones)}
|
||||||
|
</span>
|
||||||
<span className="resource-label">{"Runestones"}</span>
|
<span className="resource-label">{"Runestones"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⭐"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{`+${formatInteger(projectedRunestones)}`}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"On Prestige"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">{"⚔️"}</span>
|
||||||
|
<span className="resource-value">
|
||||||
|
{formatNumber(partyCombatPower)}
|
||||||
|
</span>
|
||||||
|
<span className="resource-label">{"Combat Power"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
{apotheosisCount > 0
|
{apotheosisCount > 0
|
||||||
&& <div className="apotheosis-badge">
|
&& <div className="apotheosis-badge">
|
||||||
{"✨ Apotheosis "}
|
{"✨ Apotheosis "}
|
||||||
@@ -153,34 +272,7 @@ const ResourceBar = ({
|
|||||||
{prestigeCount}
|
{prestigeCount}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="profile-buttons">
|
<div className="resource-bar-actions">
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://donate.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Support the developer"
|
|
||||||
>
|
|
||||||
{"💜"} <span className="btn-label">{"Donate"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://chat.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Join our Discord"
|
|
||||||
>
|
|
||||||
{"💬"} <span className="btn-label">{"Discord"}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="profile-link-button"
|
|
||||||
href="https://support.nhcarrigan.com"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Get support on our forum"
|
|
||||||
>
|
|
||||||
{"🆘"} <span className="btn-label">{"Support"}</span>
|
|
||||||
</a>
|
|
||||||
{syncError === null
|
{syncError === null
|
||||||
? null
|
? null
|
||||||
: <span className="save-status save-error" title={syncError}>
|
: <span className="save-status save-error" title={syncError}>
|
||||||
@@ -207,23 +299,69 @@ const ResourceBar = ({
|
|||||||
? "⏳"
|
? "⏳"
|
||||||
: "💾"}
|
: "💾"}
|
||||||
</button>
|
</button>
|
||||||
|
{avatarUrl === null
|
||||||
|
? null
|
||||||
|
: <div
|
||||||
|
className="profile-menu"
|
||||||
|
onBlur={handleProfileBlur}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="profile-avatar-button"
|
||||||
|
onClick={handleToggleProfile}
|
||||||
|
title="Account"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Profile"
|
||||||
|
className="profile-avatar-img"
|
||||||
|
src={avatarUrl}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isProfileOpen
|
||||||
|
? <div className="profile-dropdown">
|
||||||
<a
|
<a
|
||||||
className="profile-link-button"
|
className="profile-dropdown-item"
|
||||||
href={profileUrl}
|
href={profileUrl}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="View your public profile"
|
|
||||||
>
|
>
|
||||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
{"👤 View Profile"}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
className="profile-edit-button"
|
className="profile-dropdown-item"
|
||||||
onClick={onEditProfile}
|
onClick={handleEditProfile}
|
||||||
title="Edit your profile"
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{"✏️"}
|
{"✏️ Edit Profile"}
|
||||||
</button>
|
</button>
|
||||||
|
<hr className="profile-dropdown-divider" />
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://donate.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💜 Donate"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://chat.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"💬 Discord"}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="profile-dropdown-item"
|
||||||
|
href="https://support.nhcarrigan.com"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{"🆘 Support"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{anyFull
|
{anyFull
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
|
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
|
||||||
id: "upgrade_mage_1",
|
id: "upgrade_apprentice_1",
|
||||||
sourceId: "mage_1",
|
sourceId: "apprentice_1",
|
||||||
sourceType: "upgrade",
|
sourceType: "upgrade",
|
||||||
title: "Arcane Tomes: The Written Knowledge",
|
title: "Arcane Tomes: The Written Knowledge",
|
||||||
zoneId: "guild_library",
|
zoneId: "guild_library",
|
||||||
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
|
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
|
||||||
id: "upgrade_cleric_1",
|
id: "upgrade_acolyte_1",
|
||||||
sourceId: "cleric_1",
|
sourceId: "acolyte_1",
|
||||||
sourceType: "upgrade",
|
sourceType: "upgrade",
|
||||||
title: "Holy Rites: The Sacred Routine",
|
title: "Holy Rites: The Sacred Routine",
|
||||||
zoneId: "guild_library",
|
zoneId: "guild_library",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
|
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
|
||||||
durationSeconds: 3600,
|
durationSeconds: 300,
|
||||||
id: "verdant_meadow",
|
id: "verdant_meadow",
|
||||||
name: "The Verdant Meadow",
|
name: "The Verdant Meadow",
|
||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
@@ -28,7 +28,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
|
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
|
||||||
durationSeconds: 7200,
|
durationSeconds: 600,
|
||||||
id: "whispering_forest",
|
id: "whispering_forest",
|
||||||
name: "The Whispering Forest",
|
name: "The Whispering Forest",
|
||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
@@ -36,7 +36,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
|
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
|
||||||
durationSeconds: 10_800,
|
durationSeconds: 900,
|
||||||
id: "ancient_grove",
|
id: "ancient_grove",
|
||||||
name: "The Ancient Grove",
|
name: "The Ancient Grove",
|
||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
@@ -44,7 +44,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
|
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
|
||||||
durationSeconds: 14_400,
|
durationSeconds: 1200,
|
||||||
id: "forbidden_glen",
|
id: "forbidden_glen",
|
||||||
name: "The Forbidden Glen",
|
name: "The Forbidden Glen",
|
||||||
zoneId: "verdant_vale",
|
zoneId: "verdant_vale",
|
||||||
@@ -54,7 +54,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
|
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
|
||||||
durationSeconds: 7200,
|
durationSeconds: 600,
|
||||||
id: "collapsed_outpost",
|
id: "collapsed_outpost",
|
||||||
name: "The Collapsed Outpost",
|
name: "The Collapsed Outpost",
|
||||||
zoneId: "shattered_ruins",
|
zoneId: "shattered_ruins",
|
||||||
@@ -62,7 +62,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
|
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
|
||||||
durationSeconds: 14_400,
|
durationSeconds: 1200,
|
||||||
id: "cursed_lake",
|
id: "cursed_lake",
|
||||||
name: "The Cursed Lake",
|
name: "The Cursed Lake",
|
||||||
zoneId: "shattered_ruins",
|
zoneId: "shattered_ruins",
|
||||||
@@ -70,7 +70,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
|
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
|
||||||
durationSeconds: 21_600,
|
durationSeconds: 1800,
|
||||||
id: "runic_archive",
|
id: "runic_archive",
|
||||||
name: "The Runic Archive",
|
name: "The Runic Archive",
|
||||||
zoneId: "shattered_ruins",
|
zoneId: "shattered_ruins",
|
||||||
@@ -78,7 +78,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
|
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
|
||||||
durationSeconds: 28_800,
|
durationSeconds: 2400,
|
||||||
id: "dragon_throne",
|
id: "dragon_throne",
|
||||||
name: "The Dragon's Throne",
|
name: "The Dragon's Throne",
|
||||||
zoneId: "shattered_ruins",
|
zoneId: "shattered_ruins",
|
||||||
@@ -88,7 +88,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
|
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
|
||||||
durationSeconds: 10_800,
|
durationSeconds: 900,
|
||||||
id: "glacial_cave",
|
id: "glacial_cave",
|
||||||
name: "The Glacial Cave",
|
name: "The Glacial Cave",
|
||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
@@ -96,7 +96,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
|
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
|
||||||
durationSeconds: 21_600,
|
durationSeconds: 1800,
|
||||||
id: "frozen_tundra",
|
id: "frozen_tundra",
|
||||||
name: "The Frozen Tundra",
|
name: "The Frozen Tundra",
|
||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
@@ -104,7 +104,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
|
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
|
||||||
durationSeconds: 32_400,
|
durationSeconds: 2700,
|
||||||
id: "void_rift",
|
id: "void_rift",
|
||||||
name: "The Void Rift",
|
name: "The Void Rift",
|
||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
@@ -112,7 +112,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
|
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
|
||||||
durationSeconds: 43_200,
|
durationSeconds: 3600,
|
||||||
id: "summit_shrine",
|
id: "summit_shrine",
|
||||||
name: "The Summit Shrine",
|
name: "The Summit Shrine",
|
||||||
zoneId: "frozen_peaks",
|
zoneId: "frozen_peaks",
|
||||||
@@ -122,7 +122,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
|
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
|
||||||
durationSeconds: 18_000,
|
durationSeconds: 1500,
|
||||||
id: "fog_hollow",
|
id: "fog_hollow",
|
||||||
name: "The Fog Hollow",
|
name: "The Fog Hollow",
|
||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
@@ -130,7 +130,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
|
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
|
||||||
durationSeconds: 36_000,
|
durationSeconds: 3000,
|
||||||
id: "dark_grotto",
|
id: "dark_grotto",
|
||||||
name: "The Dark Grotto",
|
name: "The Dark Grotto",
|
||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
@@ -138,7 +138,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
|
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
|
||||||
durationSeconds: 54_000,
|
durationSeconds: 5400,
|
||||||
id: "cursed_barrow",
|
id: "cursed_barrow",
|
||||||
name: "The Cursed Barrow",
|
name: "The Cursed Barrow",
|
||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
@@ -146,7 +146,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
|
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
|
||||||
durationSeconds: 72_000,
|
durationSeconds: 5400,
|
||||||
id: "marsh_depths",
|
id: "marsh_depths",
|
||||||
name: "The Marsh Depths",
|
name: "The Marsh Depths",
|
||||||
zoneId: "shadow_marshes",
|
zoneId: "shadow_marshes",
|
||||||
@@ -156,7 +156,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
|
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
|
||||||
durationSeconds: 25_200,
|
durationSeconds: 2100,
|
||||||
id: "magma_tunnel",
|
id: "magma_tunnel",
|
||||||
name: "The Magma Tunnel",
|
name: "The Magma Tunnel",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
@@ -164,7 +164,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
|
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
|
||||||
durationSeconds: 50_400,
|
durationSeconds: 3600,
|
||||||
id: "forge_chamber",
|
id: "forge_chamber",
|
||||||
name: "The Forge Chamber",
|
name: "The Forge Chamber",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
@@ -172,7 +172,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
|
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
|
||||||
durationSeconds: 75_600,
|
durationSeconds: 7200,
|
||||||
id: "fire_temple",
|
id: "fire_temple",
|
||||||
name: "The Fire Temple",
|
name: "The Fire Temple",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
@@ -180,7 +180,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
|
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
|
||||||
durationSeconds: 100_800,
|
durationSeconds: 9000,
|
||||||
id: "core_descent",
|
id: "core_descent",
|
||||||
name: "The Core Descent",
|
name: "The Core Descent",
|
||||||
zoneId: "volcanic_depths",
|
zoneId: "volcanic_depths",
|
||||||
@@ -190,7 +190,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
|
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
|
||||||
durationSeconds: 36_000,
|
durationSeconds: 3000,
|
||||||
id: "star_field",
|
id: "star_field",
|
||||||
name: "The Star Field",
|
name: "The Star Field",
|
||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
@@ -198,7 +198,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
|
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
|
||||||
durationSeconds: 72_000,
|
durationSeconds: 5400,
|
||||||
id: "probability_sea",
|
id: "probability_sea",
|
||||||
name: "The Probability Sea",
|
name: "The Probability Sea",
|
||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
@@ -206,7 +206,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
|
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
|
||||||
durationSeconds: 108_000,
|
durationSeconds: 9000,
|
||||||
id: "void_current",
|
id: "void_current",
|
||||||
name: "The Void Current",
|
name: "The Void Current",
|
||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
@@ -214,7 +214,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
|
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
|
||||||
durationSeconds: 144_000,
|
durationSeconds: 12_600,
|
||||||
id: "null_zenith",
|
id: "null_zenith",
|
||||||
name: "The Null Zenith",
|
name: "The Null Zenith",
|
||||||
zoneId: "astral_void",
|
zoneId: "astral_void",
|
||||||
@@ -224,7 +224,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
|
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
|
||||||
durationSeconds: 43_200,
|
durationSeconds: 3600,
|
||||||
id: "light_spire",
|
id: "light_spire",
|
||||||
name: "The Light Spire",
|
name: "The Light Spire",
|
||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
@@ -232,7 +232,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
|
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
|
||||||
durationSeconds: 86_400,
|
durationSeconds: 7200,
|
||||||
id: "choir_hall",
|
id: "choir_hall",
|
||||||
name: "The Choir Hall",
|
name: "The Choir Hall",
|
||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
@@ -240,7 +240,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
|
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
|
||||||
durationSeconds: 129_600,
|
durationSeconds: 10_800,
|
||||||
id: "divine_court",
|
id: "divine_court",
|
||||||
name: "The Divine Court",
|
name: "The Divine Court",
|
||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
@@ -248,7 +248,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
|
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
|
||||||
durationSeconds: 172_800,
|
durationSeconds: 14_400,
|
||||||
id: "celestial_vault",
|
id: "celestial_vault",
|
||||||
name: "The Celestial Vault",
|
name: "The Celestial Vault",
|
||||||
zoneId: "celestial_reaches",
|
zoneId: "celestial_reaches",
|
||||||
@@ -258,7 +258,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
|
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
|
||||||
durationSeconds: 50_400,
|
durationSeconds: 3600,
|
||||||
id: "trench_entrance",
|
id: "trench_entrance",
|
||||||
name: "The Trench Entrance",
|
name: "The Trench Entrance",
|
||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
@@ -266,7 +266,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
|
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
|
||||||
durationSeconds: 100_800,
|
durationSeconds: 9000,
|
||||||
id: "deep_current",
|
id: "deep_current",
|
||||||
name: "The Deep Current",
|
name: "The Deep Current",
|
||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
@@ -274,7 +274,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
|
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
|
||||||
durationSeconds: 151_200,
|
durationSeconds: 12_600,
|
||||||
id: "sunless_chamber",
|
id: "sunless_chamber",
|
||||||
name: "The Sunless Chamber",
|
name: "The Sunless Chamber",
|
||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
@@ -282,7 +282,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
|
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
|
||||||
durationSeconds: 201_600,
|
durationSeconds: 16_200,
|
||||||
id: "the_waiting_place",
|
id: "the_waiting_place",
|
||||||
name: "The Waiting Place",
|
name: "The Waiting Place",
|
||||||
zoneId: "abyssal_trench",
|
zoneId: "abyssal_trench",
|
||||||
@@ -292,7 +292,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
|
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
|
||||||
durationSeconds: 57_600,
|
durationSeconds: 5400,
|
||||||
id: "demon_market",
|
id: "demon_market",
|
||||||
name: "The Demon Market",
|
name: "The Demon Market",
|
||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
@@ -300,7 +300,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
|
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
|
||||||
durationSeconds: 115_200,
|
durationSeconds: 9000,
|
||||||
id: "torment_hall",
|
id: "torment_hall",
|
||||||
name: "The Torment Hall",
|
name: "The Torment Hall",
|
||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
@@ -308,7 +308,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
|
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
|
||||||
durationSeconds: 172_800,
|
durationSeconds: 14_400,
|
||||||
id: "soul_forge",
|
id: "soul_forge",
|
||||||
name: "The Soul Forge",
|
name: "The Soul Forge",
|
||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
@@ -316,7 +316,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
|
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
|
||||||
durationSeconds: 230_400,
|
durationSeconds: 19_800,
|
||||||
id: "lords_chamber",
|
id: "lords_chamber",
|
||||||
name: "The Lords' Chamber",
|
name: "The Lords' Chamber",
|
||||||
zoneId: "infernal_court",
|
zoneId: "infernal_court",
|
||||||
@@ -326,7 +326,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
|
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
|
||||||
durationSeconds: 64_800,
|
durationSeconds: 5400,
|
||||||
id: "facet_approach",
|
id: "facet_approach",
|
||||||
name: "The Facet Approach",
|
name: "The Facet Approach",
|
||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
@@ -334,7 +334,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
|
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
|
||||||
durationSeconds: 129_600,
|
durationSeconds: 10_800,
|
||||||
id: "calculation_chamber",
|
id: "calculation_chamber",
|
||||||
name: "The Calculation Chamber",
|
name: "The Calculation Chamber",
|
||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
@@ -342,7 +342,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
|
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
|
||||||
durationSeconds: 194_400,
|
durationSeconds: 16_200,
|
||||||
id: "mirror_hall",
|
id: "mirror_hall",
|
||||||
name: "The Mirror Hall",
|
name: "The Mirror Hall",
|
||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
@@ -350,7 +350,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
|
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
|
||||||
durationSeconds: 259_200,
|
durationSeconds: 21_600,
|
||||||
id: "core_access",
|
id: "core_access",
|
||||||
name: "The Core Access",
|
name: "The Core Access",
|
||||||
zoneId: "crystalline_spire",
|
zoneId: "crystalline_spire",
|
||||||
@@ -360,7 +360,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
|
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
|
||||||
durationSeconds: 72_000,
|
durationSeconds: 5400,
|
||||||
id: "threshold",
|
id: "threshold",
|
||||||
name: "The Threshold",
|
name: "The Threshold",
|
||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
@@ -368,7 +368,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
|
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
|
||||||
durationSeconds: 144_000,
|
durationSeconds: 12_600,
|
||||||
id: "inner_silence",
|
id: "inner_silence",
|
||||||
name: "The Inner Silence",
|
name: "The Inner Silence",
|
||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
@@ -376,7 +376,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
|
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
|
||||||
durationSeconds: 216_000,
|
durationSeconds: 18_000,
|
||||||
id: "resonance_chamber",
|
id: "resonance_chamber",
|
||||||
name: "The Resonance Chamber",
|
name: "The Resonance Chamber",
|
||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
@@ -384,7 +384,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
|
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
|
||||||
durationSeconds: 288_000,
|
durationSeconds: 25_200,
|
||||||
id: "sanctum_heart",
|
id: "sanctum_heart",
|
||||||
name: "The Sanctum Heart",
|
name: "The Sanctum Heart",
|
||||||
zoneId: "void_sanctum",
|
zoneId: "void_sanctum",
|
||||||
@@ -394,7 +394,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
|
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
|
||||||
durationSeconds: 79_200,
|
durationSeconds: 7200,
|
||||||
id: "throne_approach",
|
id: "throne_approach",
|
||||||
name: "The Throne Approach",
|
name: "The Throne Approach",
|
||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
@@ -402,7 +402,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
|
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
|
||||||
durationSeconds: 158_400,
|
durationSeconds: 12_600,
|
||||||
id: "dominion_hall",
|
id: "dominion_hall",
|
||||||
name: "The Dominion Hall",
|
name: "The Dominion Hall",
|
||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
@@ -410,7 +410,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
|
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
|
||||||
durationSeconds: 237_600,
|
durationSeconds: 19_800,
|
||||||
id: "eternity_vault",
|
id: "eternity_vault",
|
||||||
name: "The Eternity Vault",
|
name: "The Eternity Vault",
|
||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
@@ -418,7 +418,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
|
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
|
||||||
durationSeconds: 316_800,
|
durationSeconds: 25_200,
|
||||||
id: "the_seat",
|
id: "the_seat",
|
||||||
name: "The Seat",
|
name: "The Seat",
|
||||||
zoneId: "eternal_throne",
|
zoneId: "eternal_throne",
|
||||||
@@ -428,7 +428,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
|
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
|
||||||
durationSeconds: 86_400,
|
durationSeconds: 7200,
|
||||||
id: "creation_storm",
|
id: "creation_storm",
|
||||||
name: "The Creation Storm",
|
name: "The Creation Storm",
|
||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
@@ -436,7 +436,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
|
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
|
||||||
durationSeconds: 172_800,
|
durationSeconds: 14_400,
|
||||||
id: "unmaking_sea",
|
id: "unmaking_sea",
|
||||||
name: "The Unmaking Sea",
|
name: "The Unmaking Sea",
|
||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
@@ -444,7 +444,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
|
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
|
||||||
durationSeconds: 259_200,
|
durationSeconds: 21_600,
|
||||||
id: "probability_void",
|
id: "probability_void",
|
||||||
name: "The Probability Void",
|
name: "The Probability Void",
|
||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
@@ -452,7 +452,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
|
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
|
||||||
durationSeconds: 345_600,
|
durationSeconds: 28_800,
|
||||||
id: "chaos_core",
|
id: "chaos_core",
|
||||||
name: "The Chaos Core",
|
name: "The Chaos Core",
|
||||||
zoneId: "primordial_chaos",
|
zoneId: "primordial_chaos",
|
||||||
@@ -462,7 +462,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
|
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
|
||||||
durationSeconds: 93_600,
|
durationSeconds: 7200,
|
||||||
id: "first_horizon",
|
id: "first_horizon",
|
||||||
name: "The First Horizon",
|
name: "The First Horizon",
|
||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
@@ -470,7 +470,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
|
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
|
||||||
durationSeconds: 187_200,
|
durationSeconds: 16_200,
|
||||||
id: "middle_nowhere",
|
id: "middle_nowhere",
|
||||||
name: "The Middle of Nowhere",
|
name: "The Middle of Nowhere",
|
||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
@@ -478,7 +478,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
|
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
|
||||||
durationSeconds: 280_800,
|
durationSeconds: 25_200,
|
||||||
id: "edge_approach",
|
id: "edge_approach",
|
||||||
name: "The Edge Approach",
|
name: "The Edge Approach",
|
||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
@@ -486,7 +486,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
|
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
|
||||||
durationSeconds: 374_400,
|
durationSeconds: 32_400,
|
||||||
id: "the_furthest",
|
id: "the_furthest",
|
||||||
name: "The Furthest",
|
name: "The Furthest",
|
||||||
zoneId: "infinite_expanse",
|
zoneId: "infinite_expanse",
|
||||||
@@ -496,7 +496,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
|
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
|
||||||
durationSeconds: 100_800,
|
durationSeconds: 9000,
|
||||||
id: "workshop_entrance",
|
id: "workshop_entrance",
|
||||||
name: "The Workshop Entrance",
|
name: "The Workshop Entrance",
|
||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
@@ -504,7 +504,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
|
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
|
||||||
durationSeconds: 201_600,
|
durationSeconds: 16_200,
|
||||||
id: "creation_floor",
|
id: "creation_floor",
|
||||||
name: "The Creation Floor",
|
name: "The Creation Floor",
|
||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
@@ -512,7 +512,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
|
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
|
||||||
durationSeconds: 302_400,
|
durationSeconds: 25_200,
|
||||||
id: "master_forge",
|
id: "master_forge",
|
||||||
name: "The Master Forge",
|
name: "The Master Forge",
|
||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
@@ -520,7 +520,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
|
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
|
||||||
durationSeconds: 403_200,
|
durationSeconds: 32_400,
|
||||||
id: "forge_core",
|
id: "forge_core",
|
||||||
name: "The Forge Core",
|
name: "The Forge Core",
|
||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
@@ -530,7 +530,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
|
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
|
||||||
durationSeconds: 108_000,
|
durationSeconds: 9000,
|
||||||
id: "outer_current",
|
id: "outer_current",
|
||||||
name: "The Outer Current",
|
name: "The Outer Current",
|
||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
@@ -538,7 +538,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
|
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
|
||||||
durationSeconds: 216_000,
|
durationSeconds: 18_000,
|
||||||
id: "debris_field",
|
id: "debris_field",
|
||||||
name: "The Debris Field",
|
name: "The Debris Field",
|
||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
@@ -546,7 +546,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
|
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
|
||||||
durationSeconds: 324_000,
|
durationSeconds: 28_800,
|
||||||
id: "force_confluence",
|
id: "force_confluence",
|
||||||
name: "The Force Confluence",
|
name: "The Force Confluence",
|
||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
@@ -554,7 +554,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
|
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
|
||||||
durationSeconds: 432_000,
|
durationSeconds: 36_000,
|
||||||
id: "eye_approach",
|
id: "eye_approach",
|
||||||
name: "The Eye Approach",
|
name: "The Eye Approach",
|
||||||
zoneId: "cosmic_maelstrom",
|
zoneId: "cosmic_maelstrom",
|
||||||
@@ -564,7 +564,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
|
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
|
||||||
durationSeconds: 115_200,
|
durationSeconds: 9000,
|
||||||
id: "first_steps",
|
id: "first_steps",
|
||||||
name: "The First Steps",
|
name: "The First Steps",
|
||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
@@ -572,7 +572,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
|
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
|
||||||
durationSeconds: 230_400,
|
durationSeconds: 19_800,
|
||||||
id: "ancient_archive",
|
id: "ancient_archive",
|
||||||
name: "The Ancient Archive",
|
name: "The Ancient Archive",
|
||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
@@ -580,7 +580,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
|
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
|
||||||
durationSeconds: 345_600,
|
durationSeconds: 28_800,
|
||||||
id: "memory_chamber",
|
id: "memory_chamber",
|
||||||
name: "The Memory Chamber",
|
name: "The Memory Chamber",
|
||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
@@ -588,7 +588,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
|
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
|
||||||
durationSeconds: 460_800,
|
durationSeconds: 39_600,
|
||||||
id: "the_oldest_place",
|
id: "the_oldest_place",
|
||||||
name: "The Oldest Place",
|
name: "The Oldest Place",
|
||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
@@ -598,7 +598,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
|
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
|
||||||
durationSeconds: 129_600,
|
durationSeconds: 10_800,
|
||||||
id: "edge_of_everything",
|
id: "edge_of_everything",
|
||||||
name: "The Edge of Everything",
|
name: "The Edge of Everything",
|
||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
@@ -606,7 +606,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
|
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
|
||||||
durationSeconds: 259_200,
|
durationSeconds: 21_600,
|
||||||
id: "truth_approach",
|
id: "truth_approach",
|
||||||
name: "The Truth Approach",
|
name: "The Truth Approach",
|
||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
@@ -614,7 +614,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
|
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
|
||||||
durationSeconds: 388_800,
|
durationSeconds: 32_400,
|
||||||
id: "final_antechamber",
|
id: "final_antechamber",
|
||||||
name: "The Final Antechamber",
|
name: "The Final Antechamber",
|
||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
@@ -622,7 +622,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
|
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
|
||||||
durationSeconds: 518_400,
|
durationSeconds: 43_200,
|
||||||
id: "the_absolute_heart",
|
id: "the_absolute_heart",
|
||||||
name: "The Absolute Heart",
|
name: "The Absolute Heart",
|
||||||
zoneId: "the_absolute",
|
zoneId: "the_absolute",
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
|
|||||||
runestonesCost: 1200,
|
runestonesCost: 1200,
|
||||||
},
|
},
|
||||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
category: "utility",
|
||||||
|
description:
|
||||||
|
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
|
||||||
|
id: "auto_adventurer",
|
||||||
|
multiplier: 1,
|
||||||
|
name: "Autonomous Recruitment",
|
||||||
|
runestonesCost: 50,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "utility",
|
category: "utility",
|
||||||
description:
|
description:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user