22 Commits

Author SHA1 Message Date
hikari 7b81f6cb33 fix: resolve auto-boss signature mismatch, expose full CP, cap auto-buy, show unlock hints
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
CI / Lint, Build & Test (pull_request) Failing after 1m9s
Closes #148: clear stale signature after each boss fight so subsequent
auto-boss pre-saves don't send a mismatched HMAC.

Closes #151: auto-buy skips non-max-tier adventurers once they reach 100,
keeping gold flowing to the highest-unlocked tier.

Closes #152: introduce computePartyCombatPower() in tick.ts mirroring the
server-side formula (global upgrades, prestige, equipment, set bonuses,
echo, crafted, companion). Resource bar, auto-quest gate, and boss panel
all now use the same multiplier-accurate value.

Closes #146: tick engine auto-unlocks adventurer-specific upgrades when
their adventurer is first recruited; upgrade panel shows a recruit hint
for locked entries with no boss/quest source.
2026-03-25 16:38:42 -07:00
hikari ad4fcc2811 fix: resolve sync count inflation, add essence/s display, sort auto-buy by level
Closes #147: patch functions now detect actual changes before incrementing
the patched counter, preventing inflated sync reports.

Closes #149: computeEssencePerSecond exported from tick engine and shown
in the resource bar dropdown alongside Gold/s.

Closes #150: auto-buy now sorts adventurers by level descending for
semantic clarity, ensuring highest-tier units are purchased first.
2026-03-25 16:16:21 -07:00
naomi 9926e7f639 release: v0.3.2
CI / Lint, Build & Test (push) Successful in 1m13s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m17s
2026-03-24 18:50:37 -07:00
hikari 6bf1ac5e7d feat: grant Elysian role on auth and prompt non-members to join (#134)
CI / Lint, Build & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

- Grants the Elysian Discord role to players on login/registration and persists an `inGuild` flag on the Player record
- Connects to the Discord Gateway via WebSocket to keep `inGuild` in sync as players join or leave the server
- Shows a dismissible "Join our community" modal to players who are not yet in the guild
- Hardens `inGuild` exposure through the load endpoint and game context
- Moves all non-secret Discord IDs (guild, role, client, redirect URI) out of env vars and into hardcoded constants; removes them from `prod.env`

## Test plan

- [ ] Lint, build, and test pipeline passes (100% coverage maintained)
- [ ] New player auth grants Elysian role and sets `inGuild: true`
- [ ] Existing player auth re-attempts role grant and updates `inGuild`
- [ ] Join community modal appears for players not in the guild
- [ ] Modal does not reappear within the same browser session after dismissal
- [ ] Gateway correctly sets `inGuild: true/false` on member add/remove events

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #134
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 18:49:51 -07:00
hikari b48beef474 feat: sync and patch all content stats on existing saves (#130)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

- Sync New Content now **injects** missing entries AND **patches canonical fields** on all existing entries to match current defaults
- Adventurers: stats (baseCost, combatPower, goldPerSecond, essencePerSecond, name, class, level)
- Quests: duration, prerequisites, combat requirement, rewards
- Bosses: HP, damage, rewards, prestige requirement, upgrade rewards
- Zones: unlock conditions (boss/quest required)
- Upgrades: multiplier, costs
- Equipment: bonus, cost, set membership
- Achievements: condition, reward
- Crafting: multipliers recomputed from `craftedRecipeIds` so recipe balance changes apply retroactively

Closes #126

## Test plan

- [ ] On an existing save, click Sync New Content and verify the notification reports patched counts for all content types
- [ ] Verify that rebalanced adventurer/boss/upgrade stats are reflected in the UI after syncing
- [ ] Verify that player-owned state (counts, unlock status, boss HP, quest status) is preserved after syncing
- [ ] Verify crafting multipliers are correct after syncing if any recipes were previously crafted

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #130
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 16:01:48 -07:00
hikari 6e573bea14 chore: more feedback fixes (#129)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

- Fix `NaN` displayed in Sync New Content / Force Unlock notifications by guarding against undefined counts
- Poll server for exploration claimability before showing Collect button to prevent client/server desync
- Return authoritative materials list from craft API to prevent client desync causing false affordability
- Add test coverage for `sync-new-content` and `explore/claimable` endpoints

Closes #125
Closes #127
Closes #128

## Test plan

- [ ] Trigger a sync with new content and verify the notification shows a real count instead of `NaN`
- [ ] Start an exploration, wait for it to complete, and verify the Collect button only appears after the server confirms claimable
- [ ] Attempt to craft a recipe and verify the material counts in the UI update to match the server's authoritative values

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #129
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-24 13:20:37 -07:00
hikari 790d35420f fix: patch quest and boss rewards on sync to restore unlock conditions
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Failing after 1m11s
2026-03-23 18:45:14 -07:00
naomi 9f9edae45e release: v0.3.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint, Build & Test (push) Failing after 1m11s
2026-03-23 18:32:15 -07:00
hikari a7a255dab6 fix: sort injected entries by canonical defaults order after sync
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Failing after 1m13s
2026-03-23 18:18:59 -07:00
hikari e92cf3c9a1 feat: add sync new content debug tool
CI / Lint, Build & Test (push) Failing after 51s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
Adds a new debug panel button that injects any adventurers, quests,
bosses, equipment, upgrades, achievements, zones, and exploration areas
that exist in the current game data but are missing from an existing
player save (e.g. content added after the save was first created).
2026-03-23 18:10:39 -07:00
naomi 26d30c271d release: v0.3.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint, Build & Test (push) Successful in 1m11s
2026-03-23 17:39:55 -07:00
hikari 34d07bec95 balance: comprehensive game balance pass (#103-#123) (#124)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

Comprehensive balance pass addressing 20 tickets (#103–#122) plus one audit-discovered fix (#123), ensuring no player soft-locks and aligning all content counts with achievements and progression milestones.

### Changes

- **Equipment** (#103–#111): Differentiated all stat pairs so every piece has a unique bonus combination; added missing stats to `eternal_flame` and increased `eternal_prism` multiplier to justify cost tier
- **Recipes** (#112–#115): Added 4 cross-zone crafting recipes requiring materials from multiple zones to incentivise exploration breadth
- **Achievements** (#116–#118): Aligned `fully_equipped` (40→65), `quest_eternal` (72→95), and `boss_eternal` (60→72) thresholds with actual content counts; updated `devourer_slayer` description
- **Quest CP scaling** (#120–#122): Verified and corrected combat power requirements across all zones to follow consistent 4×/4× progression pattern
- **Zone file ordering** (#123): Swapped Frozen Peaks and Shadow Marshes quest sections so file order matches the actual unlock chain (no gameplay change)

### Tickets Closed

Closes #103
Closes #104
Closes #105
Closes #106
Closes #107
Closes #108
Closes #109
Closes #110
Closes #111
Closes #112
Closes #113
Closes #114
Closes #115
Closes #116
Closes #117
Closes #118
Closes #120
Closes #121
Closes #122
Closes #123

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #124
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-23 17:28:29 -07:00
hikari 3ac1d566cb chore: community feedback fixes and UI improvements (#102)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

Addresses all community feedback tickets from the last deploy, plus several UI improvements made during the same session.

### Bug fixes & balance
- **#97** — Fix auto-adventurer tier priority: sort by combat power instead of current cost so the highest-tier affordable unit is always purchased
- **#98** — Add Dark Templar adventurer (80k CP) to bridge the Volcanic Depths progression wall; rewire upgrade and quest rewards accordingly
- **#99** — Reorder and buff Shadow Assassin (55k CP, level 12) so Witch Coven feels rewarding rather than a regression
- **#100** — Display effective Gold/s (all multipliers applied) in the resource bar
- **#101** — Add Peasant tier 2 (10x, essence) and tier 3 (50x, crystals) upgrades for meaningful late-game scaling

### Other fixes
- Sync game state to server before auto-boss challenges (matching manual challenge behaviour)
- Refresh Discord avatar hash on every game load via bot token so stale CDN URLs are corrected automatically

### UI improvements
- Replace Donate / Discord / Support / View Profile / Edit Profile buttons with a single avatar dropdown menu
- Collapse all resources except Gold into a click-to-toggle dropdown; orange alert dot appears when a hidden resource is capped

## Closes

Closes #97
Closes #98
Closes #99
Closes #100
Closes #101

Reviewed-on: #102
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-23 16:07:25 -07:00
naomi 7bd6b2d3e3 release: v0.2.1
CI / Lint, Build & Test (push) Successful in 1m9s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m6s
2026-03-20 15:23:13 -07:00
hikari 354b7e372e fix: break fire_temple combat power wall (#96)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m13s
CI / Lint, Build & Test (push) Successful in 1m17s
Closes #95

## Summary

`void_walker` adventurers (130K combat power each) were locked behind `fire_temple`, which requires 4.8B combat power. The best adventurer available before completing that quest was `arcane_scholar` at 45K CP each — meaning players needed ~107K arcane scholars to break through, versus ~37K if they had void walkers. Classic chicken-and-egg wall.

## Changes

- Moved `void_walker` adventurer reward from `fire_temple` to `lava_flows` (the entry quest to Volcanic Depths, no CP requirement)
- Added 40M gold reward to `fire_temple` to replace the removed adventurer unlock

Players now unlock `void_walker` as soon as they enter the zone, giving them the combat power boost before they need to grind toward the temple.

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #96
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 15:17:23 -07:00
hikari dc1782bec9 chore: add auto-adventurer toggle to adventurer panel header (#94)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m9s
The auto-adventurer toggle is now surfaced directly in the adventurer shop panel header, mirroring the auto-boss button. It only renders when the `auto_adventurer` prestige upgrade has been purchased, so players who have not reached prestige see no change.

Closes #89

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #94
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 14:35:04 -07:00
hikari 635c630e49 fix: adventurer unlocks not applied by force-unlock tool (#93)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m10s
The force-unlock debug route now scans completed quests for adventurer rewards and ensures those tiers are marked as unlocked in game state.

The UI and API response type both surface the new `adventurersUnlocked` count alongside the existing zone/quest/boss/exploration counts.

Closes #88

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #93
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 10:28:17 -07:00
hikari bb60ae3390 fix: auto-quest continues after quest failure (#92)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m17s
## Summary

Fixes #87. When a quest failed, the tick loop detected the failure and turned auto-quest off so the "player could reassess". This meant every quest failure required the player to manually re-enable the toggle.

## Root Cause

The tick applies quest failure by resetting the quest to `status: "available"` with `lastFailedAt` set. Auto-quest picks up `available` quests automatically — so turning off auto-quest on failure was entirely unnecessary, it just broke the loop.

## Fix

Remove the auto-quest-off-on-failure block entirely. The quest returns to `available` immediately after failure, so auto-quest naturally retries on the next tick. Players can still disable it manually if they want to stop.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #92
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 09:35:55 -07:00
hikari ee47c1e8c9 fix: auto-boss no longer halts on client/server save race condition (#91)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

Fixes #86. When the client state is ahead of the server save, the auto-boss tick would receive a "Boss is not currently available" error from the API. This error was already acknowledged as an expected race condition and suppressed from telemetry — but it was still setting the error state and turning auto-boss off.

## Root Cause

The `catch` handler treated all errors identically: set `autoBossError`, turn off `autoBoss`. The race-condition case should instead silently skip so the next tick can retry naturally.

## Fix

When the error is `"Boss is not currently available"`, return early from the `catch` handler. The `finally` block still runs, resetting `isAutoBossingReference.current = false`, so the next tick retries cleanly.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #91
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 09:30:57 -07:00
hikari 2236d1dc9f fix: correct quest combat power requirements for SM, VD, and AV (#90)
CI / Lint, Build & Test (push) Successful in 1m10s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m11s
## Summary

Fixes #85. Quest combat power requirements for Shadow Marshes, Volcanic Depths, and Astral Void were all drastically too low, breaking the zone progression curve.

### Root Cause

All three zones appear to have had their `combatPowerRequired` values entered at the wrong magnitude. Shadow Marshes was using K values where M was intended; Volcanic Depths and Astral Void were similarly off, resulting in later zones being trivially easier than earlier ones.

### Changes

| Zone | Before | After |
|---|---|---|
| Shadow Marshes | 5K / 20K / 80K / 300K | 5M / 20M / 80M / 300M |
| Volcanic Depths | 2M / 8M / 30M / 120M | 1.2B / 4.8B / 18B / 72B |
| Astral Void | 50M / 200M / 800M / 3B | 300B / 1.2T / 4.8T / 18T |

### Progression

All values now maintain a consistent ~×4 multiplier within each zone and ~×4 jump between zones, matching the established pattern from Verdant Vale through Frozen Peaks.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #90
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-20 09:19:16 -07:00
naomi 621f594018 release: v0.2.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m7s
2026-03-19 21:24:55 -07:00
hikari 1e845b14ce chore: UI clarity improvements (#84)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Move quest failure explanation to a static note above the quest list (cards now show failure % only)
- Show zone unlock requirements (boss + quest) on the Boss and Quest panels, matching the existing Exploration panel behaviour
- Display combat power per adventurer on adventurer cards, alongside gold/s and essence/s

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #84
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 21:22:13 -07:00
51 changed files with 3918 additions and 910 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.1.2",
"version": "0.3.2",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+1
View File
@@ -35,6 +35,7 @@ model Player {
lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String?
loginStreak Int @default(1)
inGuild Boolean @default(false)
}
model GameState {
-4
View File
@@ -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_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
@@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+7 -7
View File
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
},
{
condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
description: "Defeat all 18 bosses across the first six zones.",
icon: "🌟",
id: "devourer_slayer",
name: "World Saver",
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 40, type: "equipmentOwned" },
description: "Own 40 pieces of equipment.",
condition: { amount: 65, type: "equipmentOwned" },
description: "Own all 65 pieces of equipment.",
icon: "🛡️",
id: "fully_equipped",
name: "Fully Equipped",
@@ -289,8 +289,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 72, type: "questsCompleted" },
description: "Complete all 72 quests across the known multiverse.",
condition: { amount: 95, type: "questsCompleted" },
description: "Complete all 95 quests across the known multiverse.",
icon: "🌌",
id: "quest_eternal",
name: "Quest Eternal",
@@ -317,8 +317,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 60, type: "bossesDefeated" },
description: "Defeat all 60 bosses across every plane of existence.",
condition: { amount: 72, type: "bossesDefeated" },
description: "Defeat all 72 bosses across every plane of existence.",
icon: "💀",
id: "boss_eternal",
name: "Eternal Vanquisher",
+45 -33
View File
@@ -129,27 +129,39 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 4_000_000_000,
class: "rogue",
combatPower: 18_000,
baseCost: 2_600_000_000,
class: "mage",
combatPower: 13_000,
count: 0,
essencePerSecond: 6,
goldPerSecond: 5000,
id: "shadow_assassin",
goldPerSecond: 4500,
id: "arcane_scholar",
level: 11,
name: "Arcane Scholar",
unlocked: false,
},
{
baseCost: 11_000_000_000,
class: "rogue",
combatPower: 28_000,
count: 0,
essencePerSecond: 11,
goldPerSecond: 9500,
id: "shadow_assassin",
level: 12,
name: "Shadow Assassin",
unlocked: false,
},
{
baseCost: 28_000_000_000,
class: "mage",
combatPower: 45_000,
baseCost: 47_000_000_000,
class: "paladin",
combatPower: 60_000,
count: 0,
essencePerSecond: 15,
goldPerSecond: 14_000,
id: "arcane_scholar",
level: 12,
name: "Arcane Scholar",
essencePerSecond: 20,
goldPerSecond: 20_000,
id: "dark_templar",
level: 13,
name: "Dark Templar",
unlocked: false,
},
{
@@ -160,7 +172,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35,
goldPerSecond: 40_000,
id: "void_walker",
level: 13,
level: 14,
name: "Void Walker",
unlocked: false,
},
@@ -172,7 +184,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100,
goldPerSecond: 120_000,
id: "celestial_guard",
level: 14,
level: 15,
name: "Celestial Guard",
unlocked: false,
},
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300,
goldPerSecond: 400_000,
id: "divine_champion",
level: 15,
level: 16,
name: "Divine Champion",
unlocked: false,
},
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 800,
goldPerSecond: 1_200_000,
id: "seraph_knight",
level: 16,
level: 17,
name: "Seraph Knight",
unlocked: false,
},
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2000,
goldPerSecond: 3_500_000,
id: "abyss_diver",
level: 17,
level: 18,
name: "Abyss Diver",
unlocked: false,
},
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 5000,
goldPerSecond: 10_000_000,
id: "infernal_warden",
level: 18,
level: 19,
name: "Infernal Warden",
unlocked: false,
},
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000,
goldPerSecond: 30_000_000,
id: "crystal_sage",
level: 19,
level: 20,
name: "Crystal Sage",
unlocked: false,
},
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 30_000,
goldPerSecond: 90_000_000,
id: "void_sentinel",
level: 20,
level: 21,
name: "Void Sentinel",
unlocked: false,
},
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 80_000,
goldPerSecond: 270_000_000,
id: "eternal_champion",
level: 21,
level: 22,
name: "Eternal Champion",
unlocked: false,
},
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 220_000,
goldPerSecond: 800_000_000,
id: "aether_weaver",
level: 22,
level: 23,
name: "Aether Weaver",
unlocked: false,
},
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 600_000,
goldPerSecond: 2_500_000_000,
id: "titan_warrior",
level: 23,
level: 24,
name: "Titan Warrior",
unlocked: false,
},
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 1_600_000,
goldPerSecond: 7_500_000_000,
id: "nexus_sage",
level: 24,
level: 25,
name: "Nexus Sage",
unlocked: false,
},
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 4_500_000,
goldPerSecond: 22_000_000_000,
id: "cosmos_knight",
level: 25,
level: 26,
name: "Cosmos Knight",
unlocked: false,
},
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000_000,
goldPerSecond: 65_000_000_000,
id: "astral_sovereign",
level: 26,
level: 27,
name: "Astral Sovereign",
unlocked: false,
},
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35_000_000,
goldPerSecond: 200_000_000_000,
id: "primordial_mage",
level: 27,
level: 28,
name: "Primordial Mage",
unlocked: false,
},
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100_000_000,
goldPerSecond: 600_000_000_000,
id: "reality_warden",
level: 28,
level: 29,
name: "Reality Warden",
unlocked: false,
},
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300_000_000,
goldPerSecond: 1_800_000_000_000,
id: "infinity_ranger",
level: 29,
level: 30,
name: "Infinity Ranger",
unlocked: false,
},
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 850_000_000,
goldPerSecond: 5_500_000_000_000,
id: "oblivion_paladin",
level: 30,
level: 31,
name: "Oblivion Paladin",
unlocked: false,
},
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2_500_000_000,
goldPerSecond: 16_000_000_000_000,
id: "transcendent_rogue",
level: 31,
level: 32,
name: "Transcendent Rogue",
unlocked: false,
},
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 7_000_000_000,
goldPerSecond: 50_000_000_000_000,
id: "omniversal_champion",
level: 32,
level: 33,
name: "Omniversal Champion",
unlocked: false,
},
+46 -46
View File
@@ -121,17 +121,17 @@ export const defaultBosses: Array<Boss> = [
},
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
bountyRunestones: 5,
crystalReward: 30,
currentHp: 80_000,
damagePerSecond: 80,
bountyRunestones: 20,
crystalReward: 700,
currentHp: 6_000_000,
damagePerSecond: 1200,
description:
"She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.",
equipmentRewards: [],
essenceReward: 800,
goldReward: 800_000,
essenceReward: 30_000,
goldReward: 80_000_000,
id: "swamp_witch",
maxHp: 80_000,
maxHp: 6_000_000,
name: "Morgantha the Swamp Witch",
prestigeRequirement: 0,
status: "locked",
@@ -139,17 +139,17 @@ export const defaultBosses: Array<Boss> = [
zoneId: "shadow_marshes",
},
{
bountyRunestones: 8,
crystalReward: 80,
currentHp: 300_000,
damagePerSecond: 180,
bountyRunestones: 25,
crystalReward: 1500,
currentHp: 12_000_000,
damagePerSecond: 2400,
description:
"A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.",
equipmentRewards: [ "runestone_amulet" ],
essenceReward: 2000,
goldReward: 3_000_000,
essenceReward: 60_000,
goldReward: 180_000_000,
id: "plague_lord",
maxHp: 300_000,
maxHp: 12_000_000,
name: "The Plague Lord",
prestigeRequirement: 0,
status: "locked",
@@ -157,17 +157,17 @@ export const defaultBosses: Array<Boss> = [
zoneId: "shadow_marshes",
},
{
bountyRunestones: 10,
crystalReward: 150,
currentHp: 800_000,
damagePerSecond: 350,
bountyRunestones: 30,
crystalReward: 3000,
currentHp: 20_000_000,
damagePerSecond: 4000,
description:
"An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.",
equipmentRewards: [ "crystal_shard" ],
essenceReward: 4000,
goldReward: 8_000_000,
essenceReward: 100_000,
goldReward: 350_000_000,
id: "mud_kraken",
maxHp: 800_000,
maxHp: 20_000_000,
name: "The Mud Kraken",
prestigeRequirement: 0,
status: "locked",
@@ -231,53 +231,53 @@ export const defaultBosses: Array<Boss> = [
},
// ── Volcanic Depths ───────────────────────────────────────────────────────
{
bountyRunestones: 12,
crystalReward: 150,
currentHp: 1_000_000,
damagePerSecond: 400,
bountyRunestones: 32,
crystalReward: 4000,
currentHp: 25_000_000,
damagePerSecond: 5000,
description:
"Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.",
equipmentRewards: [ "flame_lance" ],
essenceReward: 6000,
goldReward: 10_000_000,
essenceReward: 150_000,
goldReward: 500_000_000,
id: "fire_elemental",
maxHp: 1_000_000,
maxHp: 25_000_000,
name: "The Ancient Fire Elemental",
prestigeRequirement: 0,
status: "locked",
upgradeRewards: [ "celestial_guard_1" ],
upgradeRewards: [ "dark_templar_1" ],
zoneId: "volcanic_depths",
},
{
bountyRunestones: 18,
crystalReward: 400,
currentHp: 4_000_000,
damagePerSecond: 1000,
bountyRunestones: 40,
crystalReward: 8000,
currentHp: 60_000_000,
damagePerSecond: 12_000,
description:
"Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.",
equipmentRewards: [ "volcanic_plate" ],
essenceReward: 15_000,
goldReward: 40_000_000,
essenceReward: 300_000,
goldReward: 1_000_000_000,
id: "magma_titan",
maxHp: 4_000_000,
maxHp: 60_000_000,
name: "The Magma Titan",
prestigeRequirement: 0,
status: "locked",
upgradeRewards: [ "crystal_resonance" ],
upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
zoneId: "volcanic_depths",
},
{
bountyRunestones: 25,
crystalReward: 800,
currentHp: 12_000_000,
damagePerSecond: 2500,
bountyRunestones: 50,
crystalReward: 15_000,
currentHp: 150_000_000,
damagePerSecond: 30_000,
description:
"The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.",
equipmentRewards: [ "eternal_flame" ],
essenceReward: 40_000,
goldReward: 120_000_000,
essenceReward: 600_000,
goldReward: 2_000_000_000,
id: "phoenix_lord",
maxHp: 12_000_000,
maxHp: 150_000_000,
name: "The Phoenix Lord",
prestigeRequirement: 0,
status: "locked",
@@ -1120,7 +1120,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Storm Colossus",
prestigeRequirement: 51,
status: "locked",
upgradeRewards: [ "cosmos_knight_1" ],
upgradeRewards: [],
zoneId: "cosmic_maelstrom",
},
{
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_absolute_one",
maxHp: 2e145,
name: "The Absolute One",
prestigeRequirement: 90,
prestigeRequirement: 88,
status: "locked",
upgradeRewards: [],
zoneId: "the_absolute",
+2 -2
View File
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
description:
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
equipped: false,
@@ -757,7 +757,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour",
},
{
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 },
bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
description:
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
+4 -4
View File
@@ -92,18 +92,18 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
{
category: "income",
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",
multiplier: 500,
multiplier: 200,
name: "Eternal Rune I",
runestonesCost: 30_000,
},
{
category: "income",
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",
multiplier: 1000,
multiplier: 500,
name: "Eternal Rune II",
runestonesCost: 80_000,
},
+152 -76
View File
@@ -33,6 +33,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 2000, type: "gold" },
{ amount: 5, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" },
],
status: "locked",
@@ -64,7 +65,6 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [ "haunted_mine" ],
rewards: [
{ amount: 50, type: "essence" },
{ targetId: "click_2", type: "upgrade" },
{ targetId: "acolyte", type: "adventurer" },
],
status: "locked",
@@ -82,7 +82,8 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 15_000, type: "gold" },
{ amount: 20, type: "essence" },
{ targetId: "cleric_1", type: "upgrade" },
{ targetId: "militia_1", type: "upgrade" },
{ targetId: "acolyte_1", type: "upgrade" },
{ targetId: "ranger", type: "adventurer" },
],
status: "locked",
@@ -116,7 +117,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 300, type: "essence" },
{ amount: 30, type: "crystals" },
{ targetId: "mage_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" },
{ targetId: "archmage", type: "adventurer" },
],
status: "locked",
@@ -139,69 +140,6 @@ export const defaultQuests: Array<Quest> = [
status: "locked",
zoneId: "shattered_ruins",
},
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 150, type: "essence" },
{ targetId: "militia_1", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
],
status: "locked",
zoneId: "shadow_marshes",
},
// ── Frozen Peaks ──────────────────────────────────────────────────────────
{
combatPowerRequired: 100_000,
@@ -246,14 +184,78 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 30_000_000, type: "gold" },
{ amount: 10_000, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
],
status: "locked",
zoneId: "frozen_peaks",
},
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5_000_000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 150, type: "essence" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
{ targetId: "peasant_2", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
{ targetId: "dark_templar", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
// ── Volcanic Depths ───────────────────────────────────────────────────────
{
combatPowerRequired: 2_000_000,
combatPowerRequired: 1_200_000_000,
description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 3 * 60 * 60,
@@ -263,12 +265,13 @@ export const defaultQuests: Array<Quest> = [
rewards: [
{ amount: 15_000_000, type: "gold" },
{ amount: 4000, type: "essence" },
{ targetId: "void_walker", type: "adventurer" },
],
status: "locked",
zoneId: "volcanic_depths",
},
{
combatPowerRequired: 8_000_000,
combatPowerRequired: 4_800_000_000,
description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 5 * 60 * 60,
@@ -276,15 +279,16 @@ export const defaultQuests: Array<Quest> = [
name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ],
rewards: [
{ amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" },
{ targetId: "void_walker", type: "adventurer" },
{ targetId: "peasant_3", type: "upgrade" },
],
status: "locked",
zoneId: "volcanic_depths",
},
{
combatPowerRequired: 30_000_000,
combatPowerRequired: 18_000_000_000,
description:
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
durationSeconds: 7 * 60 * 60,
@@ -300,7 +304,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "volcanic_depths",
},
{
combatPowerRequired: 120_000_000,
combatPowerRequired: 72_000_000_000,
description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
durationSeconds: 10 * 60 * 60,
@@ -317,7 +321,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Astral Void ───────────────────────────────────────────────────────────
{
combatPowerRequired: 50_000_000,
combatPowerRequired: 300_000_000_000,
description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 4 * 60 * 60,
@@ -332,7 +336,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void",
},
{
combatPowerRequired: 200_000_000,
combatPowerRequired: 1_200_000_000_000,
description:
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
durationSeconds: 8 * 60 * 60,
@@ -348,7 +352,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void",
},
{
combatPowerRequired: 800_000_000,
combatPowerRequired: 4_800_000_000_000,
description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
durationSeconds: 12 * 60 * 60,
@@ -364,7 +368,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void",
},
{
combatPowerRequired: 3_000_000_000,
combatPowerRequired: 18_000_000_000_000,
description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
durationSeconds: 24 * 60 * 60,
@@ -381,6 +385,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Celestial Reaches ─────────────────────────────────────────────────────
{
combatPowerRequired: 7.2e13,
description:
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
durationSeconds: Math.round(1.5 * 60 * 60),
@@ -396,6 +401,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches",
},
{
combatPowerRequired: 3e14,
description:
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
durationSeconds: 3 * 60 * 60,
@@ -410,6 +416,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches",
},
{
combatPowerRequired: 1.2e15,
description:
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
durationSeconds: 5 * 60 * 60,
@@ -425,6 +432,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches",
},
{
combatPowerRequired: 4.8e15,
description:
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
durationSeconds: 8 * 60 * 60,
@@ -440,6 +448,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches",
},
{
combatPowerRequired: 1.8e16,
description:
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
durationSeconds: 12 * 60 * 60,
@@ -456,6 +465,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches",
},
{
combatPowerRequired: 7.2e16,
description:
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
durationSeconds: 20 * 60 * 60,
@@ -472,6 +482,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Abyssal Trench ────────────────────────────────────────────────────────
{
combatPowerRequired: 3e17,
description:
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
durationSeconds: 2 * 60 * 60,
@@ -487,6 +498,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench",
},
{
combatPowerRequired: 1.2e18,
description:
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
durationSeconds: 4 * 60 * 60,
@@ -502,6 +514,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench",
},
{
combatPowerRequired: 4.8e18,
description:
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
durationSeconds: 7 * 60 * 60,
@@ -517,6 +530,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench",
},
{
combatPowerRequired: 1.8e19,
description:
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
durationSeconds: 12 * 60 * 60,
@@ -532,6 +546,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench",
},
{
combatPowerRequired: 7.2e19,
description:
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
durationSeconds: 18 * 60 * 60,
@@ -548,6 +563,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench",
},
{
combatPowerRequired: 3e20,
description:
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
durationSeconds: 30 * 60 * 60,
@@ -564,6 +580,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Infernal Court ────────────────────────────────────────────────────────
{
combatPowerRequired: 1.2e21,
description:
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
durationSeconds: 3 * 60 * 60,
@@ -579,6 +596,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court",
},
{
combatPowerRequired: 4.8e21,
description:
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
durationSeconds: 6 * 60 * 60,
@@ -594,6 +612,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court",
},
{
combatPowerRequired: 1.8e22,
description:
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
durationSeconds: 10 * 60 * 60,
@@ -609,6 +628,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court",
},
{
combatPowerRequired: 7.2e22,
description:
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
durationSeconds: 16 * 60 * 60,
@@ -624,6 +644,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court",
},
{
combatPowerRequired: 3e23,
description:
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
durationSeconds: 24 * 60 * 60,
@@ -640,6 +661,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court",
},
{
combatPowerRequired: 1.2e24,
description:
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
durationSeconds: 40 * 60 * 60,
@@ -656,6 +678,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Crystalline Spire ─────────────────────────────────────────────────────
{
combatPowerRequired: 4.8e24,
description:
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
durationSeconds: 4 * 60 * 60,
@@ -671,6 +694,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire",
},
{
combatPowerRequired: 1.8e25,
description:
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
durationSeconds: 8 * 60 * 60,
@@ -686,6 +710,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire",
},
{
combatPowerRequired: 7.2e25,
description:
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
durationSeconds: 14 * 60 * 60,
@@ -701,6 +726,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire",
},
{
combatPowerRequired: 3e26,
description:
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
durationSeconds: 20 * 60 * 60,
@@ -716,6 +742,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire",
},
{
combatPowerRequired: 1.2e27,
description:
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
durationSeconds: 32 * 60 * 60,
@@ -732,6 +759,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire",
},
{
combatPowerRequired: 4.8e27,
description:
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
durationSeconds: 50 * 60 * 60,
@@ -748,6 +776,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Void Sanctum ──────────────────────────────────────────────────────────
{
combatPowerRequired: 1.8e28,
description:
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
durationSeconds: 6 * 60 * 60,
@@ -763,6 +792,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum",
},
{
combatPowerRequired: 7.2e28,
description:
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
durationSeconds: 12 * 60 * 60,
@@ -778,6 +808,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum",
},
{
combatPowerRequired: 3e29,
description:
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
durationSeconds: 20 * 60 * 60,
@@ -793,6 +824,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum",
},
{
combatPowerRequired: 1.2e30,
description:
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
durationSeconds: 30 * 60 * 60,
@@ -808,6 +840,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum",
},
{
combatPowerRequired: 4.8e30,
description:
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
durationSeconds: 48 * 60 * 60,
@@ -824,6 +857,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum",
},
{
combatPowerRequired: 1.8e31,
description:
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
durationSeconds: 72 * 60 * 60,
@@ -840,6 +874,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Eternal Throne ────────────────────────────────────────────────────────
{
combatPowerRequired: 7.2e31,
description:
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
durationSeconds: 8 * 60 * 60,
@@ -855,6 +890,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne",
},
{
combatPowerRequired: 3e32,
description:
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
durationSeconds: 16 * 60 * 60,
@@ -870,6 +906,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne",
},
{
combatPowerRequired: 1.2e33,
description:
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
durationSeconds: 28 * 60 * 60,
@@ -885,6 +922,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne",
},
{
combatPowerRequired: 4.8e33,
description:
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
durationSeconds: 40 * 60 * 60,
@@ -901,6 +939,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne",
},
{
combatPowerRequired: 1.8e34,
description:
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
durationSeconds: 60 * 60 * 60,
@@ -916,6 +955,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne",
},
{
combatPowerRequired: 7.2e34,
description:
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
durationSeconds: 96 * 60 * 60,
@@ -932,6 +972,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Primordial Chaos ──────────────────────────────────────────────────────
{
combatPowerRequired: 3e35,
description:
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
durationSeconds: 10 * 60 * 60,
@@ -947,6 +988,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos",
},
{
combatPowerRequired: 1.2e36,
description:
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
durationSeconds: 18 * 60 * 60,
@@ -962,6 +1004,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos",
},
{
combatPowerRequired: 4.8e36,
description:
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
durationSeconds: 30 * 60 * 60,
@@ -978,6 +1021,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos",
},
{
combatPowerRequired: 1.8e37,
description:
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
durationSeconds: 45 * 60 * 60,
@@ -993,6 +1037,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos",
},
{
combatPowerRequired: 7.2e37,
description:
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
durationSeconds: 65 * 60 * 60,
@@ -1009,6 +1054,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos",
},
{
combatPowerRequired: 3e38,
description:
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
durationSeconds: 90 * 60 * 60,
@@ -1025,6 +1071,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Infinite Expanse ──────────────────────────────────────────────────────
{
combatPowerRequired: 1.2e39,
description:
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
durationSeconds: 12 * 60 * 60,
@@ -1040,6 +1087,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse",
},
{
combatPowerRequired: 4.8e39,
description:
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
durationSeconds: 22 * 60 * 60,
@@ -1055,6 +1103,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse",
},
{
combatPowerRequired: 1.8e40,
description:
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
durationSeconds: 36 * 60 * 60,
@@ -1071,6 +1120,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse",
},
{
combatPowerRequired: 7.2e40,
description:
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
durationSeconds: 55 * 60 * 60,
@@ -1087,6 +1137,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse",
},
{
combatPowerRequired: 3e41,
description:
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
durationSeconds: 80 * 60 * 60,
@@ -1102,6 +1153,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse",
},
{
combatPowerRequired: 1.2e42,
description:
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
durationSeconds: 110 * 60 * 60,
@@ -1118,6 +1170,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Reality Forge ─────────────────────────────────────────────────────────
{
combatPowerRequired: 4.8e42,
description:
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
durationSeconds: 14 * 60 * 60,
@@ -1133,6 +1186,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge",
},
{
combatPowerRequired: 1.8e43,
description:
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
durationSeconds: 25 * 60 * 60,
@@ -1148,6 +1202,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge",
},
{
combatPowerRequired: 7.2e43,
description:
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
durationSeconds: 40 * 60 * 60,
@@ -1164,6 +1219,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge",
},
{
combatPowerRequired: 3e44,
description:
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
durationSeconds: 60 * 60 * 60,
@@ -1180,6 +1236,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge",
},
{
combatPowerRequired: 1.2e45,
description:
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
durationSeconds: 85 * 60 * 60,
@@ -1195,6 +1252,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge",
},
{
combatPowerRequired: 4.8e45,
description:
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
durationSeconds: 120 * 60 * 60,
@@ -1211,6 +1269,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{
combatPowerRequired: 1.8e46,
description:
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
durationSeconds: 16 * 60 * 60,
@@ -1226,6 +1285,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom",
},
{
combatPowerRequired: 7.2e46,
description:
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
durationSeconds: 28 * 60 * 60,
@@ -1241,6 +1301,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom",
},
{
combatPowerRequired: 3e47,
description:
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
durationSeconds: 45 * 60 * 60,
@@ -1257,6 +1318,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom",
},
{
combatPowerRequired: 1.2e48,
description:
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
durationSeconds: 65 * 60 * 60,
@@ -1273,6 +1335,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom",
},
{
combatPowerRequired: 4.8e48,
description:
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
durationSeconds: 90 * 60 * 60,
@@ -1288,6 +1351,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom",
},
{
combatPowerRequired: 1.8e49,
description:
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
durationSeconds: 130 * 60 * 60,
@@ -1304,6 +1368,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── Primeval Sanctum ──────────────────────────────────────────────────────
{
combatPowerRequired: 7.2e49,
description:
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
durationSeconds: 18 * 60 * 60,
@@ -1319,6 +1384,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum",
},
{
combatPowerRequired: 3e50,
description:
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
durationSeconds: 32 * 60 * 60,
@@ -1334,6 +1400,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum",
},
{
combatPowerRequired: 1.2e51,
description:
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
durationSeconds: 50 * 60 * 60,
@@ -1350,6 +1417,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum",
},
{
combatPowerRequired: 4.8e51,
description:
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
durationSeconds: 72 * 60 * 60,
@@ -1366,6 +1434,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum",
},
{
combatPowerRequired: 1.8e52,
description:
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
durationSeconds: 100 * 60 * 60,
@@ -1381,6 +1450,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum",
},
{
combatPowerRequired: 7.2e52,
description:
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
durationSeconds: 144 * 60 * 60,
@@ -1397,6 +1467,7 @@ export const defaultQuests: Array<Quest> = [
},
// ── The Absolute ──────────────────────────────────────────────────────────
{
combatPowerRequired: 3e53,
description:
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
durationSeconds: 20 * 60 * 60,
@@ -1412,6 +1483,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute",
},
{
combatPowerRequired: 1.2e54,
description:
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
durationSeconds: 36 * 60 * 60,
@@ -1427,6 +1499,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute",
},
{
combatPowerRequired: 4.8e54,
description:
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
durationSeconds: 56 * 60 * 60,
@@ -1443,6 +1516,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute",
},
{
combatPowerRequired: 1.8e55,
description:
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
durationSeconds: 80 * 60 * 60,
@@ -1458,6 +1532,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute",
},
{
combatPowerRequired: 7.2e55,
description:
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
durationSeconds: 120 * 60 * 60,
@@ -1474,6 +1549,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute",
},
{
combatPowerRequired: 3e56,
description:
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
durationSeconds: 168 * 60 * 60,
+56
View File
@@ -451,6 +451,62 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum",
},
// ── Cross-zone recipes ─────────────────────────────────────────────────────
{
bonus: { type: "gold_income", value: 1.28 },
description:
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
id: "verdant_pyre_seal",
name: "Verdant Pyre Seal",
requiredMaterials: [
{ materialId: "verdant_sap", quantity: 8 },
{ materialId: "ember_crystal", quantity: 6 },
{ materialId: "legendary_ore", quantity: 2 },
],
zoneId: "volcanic_depths",
},
{
bonus: { type: "click_power", value: 1.22 },
description:
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
id: "voidfrost_weave",
name: "Voidfrost Weave",
requiredMaterials: [
{ materialId: "glacial_ice", quantity: 8 },
{ materialId: "void_shard", quantity: 3 },
{ materialId: "shadow_essence", quantity: 5 },
],
zoneId: "shadow_marshes",
},
{
bonus: { type: "essence_income", value: 1.28 },
description:
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
id: "choir_of_the_deep",
name: "Choir of the Deep",
requiredMaterials: [
{ materialId: "celestial_dust", quantity: 8 },
{ materialId: "choir_shard", quantity: 2 },
{ materialId: "ancient_tooth", quantity: 2 },
{ materialId: "pressure_gem", quantity: 5 },
],
zoneId: "abyssal_trench",
},
{
bonus: { type: "combat_power", value: 1.4 },
description:
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
id: "eternal_omega",
name: "Eternal Omega",
requiredMaterials: [
{ materialId: "crown_fragment", quantity: 6 },
{ materialId: "eternity_splinter", quantity: 2 },
{ materialId: "boundary_shard", quantity: 4 },
{ materialId: "omega_crystal", quantity: 2 },
],
zoneId: "the_absolute",
},
// Zone 18: the_absolute
{
bonus: { type: "gold_income", value: 1.3 },
+3 -3
View File
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
category: "echo_meta",
cost: 10,
cost: 50,
description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 25,
cost: 150,
description:
"Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2",
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 50,
cost: 400,
description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3",
+61 -19
View File
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
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",
costCrystals: 0,
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 2,
costGold: 5000,
description: "Ancient books of magic double mage output.",
id: "mage_1",
id: "apprentice_1",
multiplier: 2,
name: "Arcane Tomes",
purchased: false,
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 3,
costGold: 8000,
description: "Sacred ceremonies double the output of your clerics.",
id: "cleric_1",
id: "acolyte_1",
multiplier: 2,
name: "Holy Rites",
purchased: false,
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
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",
costCrystals: 0,
costEssence: 150,
costEssence: 1000,
costGold: 0,
description: "Access to forbidden libraries doubles scholar output.",
id: "arcane_scholar_1",
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
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",
costCrystals: 0,
costEssence: 300,
costEssence: 100_000,
costGold: 0,
description:
"Walking through the void itself doubles the output of your void walkers.",
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{
adventurerId: "celestial_guard",
costCrystals: 0,
costEssence: 750,
costEssence: 500_000,
costGold: 0,
description:
"A blessing from the celestials themselves doubles guard output.",
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{
adventurerId: "divine_champion",
costCrystals: 0,
costEssence: 2000,
costEssence: 2_000_000,
costGold: 0,
description: "An unbreakable oath to the divine doubles champion output.",
id: "divine_champion_1",
+2
View File
@@ -21,6 +21,7 @@ import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js";
import { connectGateway } from "./services/gateway.js";
import { logger } from "./services/logger.js";
const app = new Hono();
@@ -68,6 +69,7 @@ const port = Number(process.env.PORT ?? 3001);
try {
serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
connectGateway();
});
} catch (error) {
void logger.error(
+9
View File
@@ -16,6 +16,7 @@ import {
} from "../services/discord.js";
import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import { grantElysianRole } from "../services/webhook.js";
import type { Player } from "@elysium/types";
const authRouter = new Hono();
@@ -92,6 +93,12 @@ authRouter.get("/callback", async(context) => {
},
});
const inGuild = await grantElysianRole(player.discordId);
await prisma.player.update({
data: { inGuild },
where: { discordId: player.discordId },
});
const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId });
@@ -104,10 +111,12 @@ authRouter.get("/callback", async(context) => {
);
}
const inGuild = await grantElysianRole(discordUser.id);
const updated = await prisma.player.update({
data: {
avatar: discordUser.avatar,
discriminator: discordUser.discriminator,
inGuild: inGuild,
username: discordUser.username,
},
where: { discordId: discordUser.id },
+12 -1
View File
@@ -148,11 +148,22 @@ craftRouter.post("/", async(context) => {
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const { materials } = state.exploration;
const {
craftedGoldMultiplier,
craftedEssenceMultiplier,
craftedClickMultiplier,
craftedCombatMultiplier,
} = updatedMultipliers;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
craftedClickMultiplier,
craftedCombatMultiplier,
craftedEssenceMultiplier,
craftedGoldMultiplier,
materials,
recipeId,
...updatedMultipliers,
};
return context.json(response);
} catch (error) {
+814 -4
View File
@@ -7,18 +7,27 @@
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
import { createHmac } from "node:crypto";
import {
STORY_CHAPTERS,
isStoryChapterUnlocked,
type GameState,
} from "@elysium/types";
import { Hono } from "hono";
import { defaultAchievements } from "../data/achievements.js";
import { defaultAdventurers } from "../data/adventurers.js";
import { defaultBosses } from "../data/bosses.js";
import { defaultEquipment } from "../data/equipment.js";
import { defaultExplorations } from "../data/explorations.js";
import { initialGameState } from "../data/initialState.js";
import { defaultQuests } from "../data/quests.js";
import { defaultRecipes } from "../data/recipes.js";
import { currentSchemaVersion } from "../data/schemaVersion.js";
import { defaultUpgrades } from "../data/upgrades.js";
import { defaultZones } from "../data/zones.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
/**
* Computes the HMAC-SHA256 of data using the given secret.
@@ -257,6 +266,180 @@ const applyBossUnlocks = (state: GameState): number => {
return count;
};
/**
* Unlocks any adventurer tiers that were granted as rewards for completed quests
* but are still locked in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of adventurer tiers that were unlocked.
*/
const applyAdventurerUnlocks = (state: GameState): number => {
let count = 0;
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
const earnedAdventurerIds = new Set<string>();
for (const questDefinition of defaultQuests) {
if (!completedQuestIds.has(questDefinition.id)) {
continue;
}
for (const reward of questDefinition.rewards) {
if (reward.type === "adventurer" && reward.targetId !== undefined) {
earnedAdventurerIds.add(reward.targetId);
}
}
}
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
adventurer.unlocked = true;
count = count + 1;
}
}
return count;
};
/**
* Collects all upgrade IDs the player has legitimately earned via boss defeats
* and completed quest rewards, sourcing reward data from game definitions.
* @param state - The player's current game state.
* @returns A set of earned upgrade IDs.
*/
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
const earnedIds = new Set<string>();
const defeatedBossIds = new Set(
state.bosses.
filter((b) => {
return b.status === "defeated";
}).
map((b) => {
return b.id;
}),
);
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
for (const bossDefinition of defaultBosses) {
if (!defeatedBossIds.has(bossDefinition.id)) {
continue;
}
for (const upgradeId of bossDefinition.upgradeRewards) {
earnedIds.add(upgradeId);
}
}
for (const questDefinition of defaultQuests) {
if (!completedQuestIds.has(questDefinition.id)) {
continue;
}
for (const reward of questDefinition.rewards) {
if (reward.type === "upgrade" && reward.targetId !== undefined) {
earnedIds.add(reward.targetId);
}
}
}
return earnedIds;
};
/**
* Unlocks any upgrades that were granted as rewards for defeated bosses or
* completed quests but are still locked in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of upgrades that were unlocked.
*/
const applyUpgradeUnlocks = (state: GameState): number => {
let count = 0;
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
for (const upgrade of state.upgrades) {
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
upgrade.unlocked = true;
count = count + 1;
}
}
return count;
};
/**
* Marks as owned any equipment that was granted as a reward for defeated bosses
* but is still unowned in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of equipment items that were marked as owned.
*/
const applyEquipmentUnlocks = (state: GameState): number => {
let count = 0;
const defeatedBossIds = new Set(
state.bosses.
filter((b) => {
return b.status === "defeated";
}).
map((b) => {
return b.id;
}),
);
const earnedEquipmentIds = new Set<string>();
for (const bossDefinition of defaultBosses) {
if (!defeatedBossIds.has(bossDefinition.id)) {
continue;
}
for (const equipmentId of bossDefinition.equipmentRewards) {
earnedEquipmentIds.add(equipmentId);
}
}
for (const item of state.equipment) {
if (!item.owned && earnedEquipmentIds.has(item.id)) {
item.owned = true;
count = count + 1;
}
}
return count;
};
/**
* Unlocks any story chapters whose conditions are met by the current game state
* but are still absent from the player's unlockedChapterIds list.
* @param state - The player's current game state (mutated directly).
* @returns The number of story chapters that were unlocked.
*/
const applyStoryUnlocks = (state: GameState): number => {
if (state.story === undefined) {
return 0;
}
let count = 0;
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
for (const chapter of STORY_CHAPTERS) {
if (alreadyUnlocked.has(chapter.id)) {
continue;
}
if (isStoryChapterUnlocked(chapter, state)) {
state.story.unlockedChapterIds.push(chapter.id);
count = count + 1;
}
}
return count;
};
/**
* Makes available any exploration areas whose parent zone is now unlocked.
* @param state - The player's current game state (mutated directly).
@@ -301,18 +484,552 @@ const applyExplorationUnlocks = (state: GameState): number => {
const applyForceUnlocks = (
state: GameState,
): {
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
} => {
const zonesUnlocked = applyZoneUnlocks(state);
const questsUnlocked = applyQuestUnlocks(state);
const bossesUnlocked = applyBossUnlocks(state);
const explorationUnlocked = applyExplorationUnlocks(state);
return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked };
const adventurersUnlocked = applyAdventurerUnlocks(state);
const upgradesUnlocked = applyUpgradeUnlocks(state);
const equipmentUnlocked = applyEquipmentUnlocks(state);
const storyUnlocked = applyStoryUnlocks(state);
return {
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
};
};
/**
* Injects any entries from a defaults array that are missing from an existing
* saved array (matched by `id`), cloning each new entry before pushing.
* @param existing - The player's saved array (mutated in place).
* @param defaults - The current default data array to compare against.
* @returns The number of entries that were added.
*/
const injectMissingEntries = <T extends { id: string }>(
existing: Array<T>,
defaults: Array<T>,
): number => {
const existingIds = new Set(existing.map((item) => {
return item.id;
}));
let added = 0;
for (const item of defaults) {
if (!existingIds.has(item.id)) {
existing.push(structuredClone(item));
added = added + 1;
}
}
const defaultOrder = new Map(defaults.map((item, index) => {
return [ item.id, index ] as const;
}));
existing.sort((itemA, itemB) => {
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
});
return added;
};
/**
* Injects any exploration areas from the defaults that are missing from the
* player's exploration state, seeding each new area as locked.
* @param state - The player's current game state (mutated in place).
* @returns The number of exploration areas that were added.
*/
const injectMissingExplorationAreas = (state: GameState): number => {
if (state.exploration === undefined) {
return 0;
}
const existingIds = new Set(state.exploration.areas.map((area) => {
return area.id;
}));
let added = 0;
for (const area of defaultExplorations) {
if (!existingIds.has(area.id)) {
state.exploration.areas.push({ id: area.id, status: "locked" });
added = added + 1;
}
}
return added;
};
/**
* Patches rewards on existing quests whose reward lists have grown since the
* save was created (e.g. A new upgrade added as a reward to an old quest).
* @param state - The player's current game state (mutated in place).
* @returns The total number of individual rewards that were added.
*/
const patchQuestRewards = (state: GameState): number => {
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
return [ quest.id, quest ] as const;
}));
let added = 0;
for (const savedQuest of state.quests) {
const defaultQuest = defaultQuestMap.get(savedQuest.id);
if (defaultQuest === undefined) {
continue;
}
const existingKeys = new Set(savedQuest.rewards.map((reward) => {
return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
}));
for (const reward of defaultQuest.rewards) {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
if (!existingKeys.has(key)) {
savedQuest.rewards.push(structuredClone(reward));
added = added + 1;
}
}
}
return added;
};
/**
* Patches upgradeRewards on existing bosses whose reward lists have grown
* since the save was created.
* @param state - The player's current game state (mutated in place).
* @returns The total number of upgrade reward IDs that were added.
*/
const patchBossUpgradeRewards = (state: GameState): number => {
const defaultBossMap = new Map(defaultBosses.map((boss) => {
return [ boss.id, boss ] as const;
}));
let added = 0;
for (const savedBoss of state.bosses) {
const defaultBoss = defaultBossMap.get(savedBoss.id);
if (defaultBoss === undefined) {
continue;
}
const existingIds = new Set(savedBoss.upgradeRewards);
for (const upgradeId of defaultBoss.upgradeRewards) {
if (!existingIds.has(upgradeId)) {
savedBoss.upgradeRewards.push(upgradeId);
added = added + 1;
}
}
}
return added;
};
/**
* Updates the stat fields of existing adventurers to match the current defaults,
* preserving only player-state fields (count and unlocked status).
* @param state - The player's current game state (mutated in place).
* @returns The number of adventurer entries whose stats were updated.
*/
const patchAdventurerStats = (state: GameState): number => {
const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => {
return [ adventurer.id, adventurer ] as const;
}));
let patched = 0;
for (const savedAdventurer of state.adventurers) {
const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id);
if (defaultAdventurer === undefined) {
continue;
}
const hasChanged
= savedAdventurer.baseCost !== defaultAdventurer.baseCost
|| savedAdventurer.class !== defaultAdventurer.class
|| savedAdventurer.combatPower !== defaultAdventurer.combatPower
|| savedAdventurer.essencePerSecond !== defaultAdventurer.essencePerSecond
|| savedAdventurer.goldPerSecond !== defaultAdventurer.goldPerSecond
|| savedAdventurer.level !== defaultAdventurer.level
|| savedAdventurer.name !== defaultAdventurer.name;
savedAdventurer.baseCost = defaultAdventurer.baseCost;
savedAdventurer.class = defaultAdventurer.class;
savedAdventurer.combatPower = defaultAdventurer.combatPower;
savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond;
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
savedAdventurer.level = defaultAdventurer.level;
savedAdventurer.name = defaultAdventurer.name;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing quests to match the current defaults,
* preserving only player-state fields (status, startedAt, lastFailedAt, rewards).
* @param state - The player's current game state (mutated in place).
* @returns The number of quest entries whose stats were updated.
*/
const patchQuestStats = (state: GameState): number => {
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
return [ quest.id, quest ] as const;
}));
let patched = 0;
for (const savedQuest of state.quests) {
const defaultQuest = defaultQuestMap.get(savedQuest.id);
if (defaultQuest === undefined) {
continue;
}
const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds);
const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds);
const hasChanged
= savedQuest.name !== defaultQuest.name
|| savedQuest.description !== defaultQuest.description
|| savedQuest.durationSeconds !== defaultQuest.durationSeconds
|| savedPrereqs !== defaultPrereqs
|| savedQuest.zoneId !== defaultQuest.zoneId
|| savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired;
savedQuest.name = defaultQuest.name;
savedQuest.description = defaultQuest.description;
savedQuest.durationSeconds = defaultQuest.durationSeconds;
savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds;
savedQuest.zoneId = defaultQuest.zoneId;
if (defaultQuest.combatPowerRequired !== undefined) {
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
}
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing bosses to match the current defaults,
* preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards).
* @param state - The player's current game state (mutated in place).
* @returns The number of boss entries whose stats were updated.
*/
/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */
const patchBossStats = (state: GameState): number => {
const defaultBossMap = new Map(defaultBosses.map((boss) => {
return [ boss.id, boss ] as const;
}));
let patched = 0;
for (const savedBoss of state.bosses) {
const defaultBoss = defaultBossMap.get(savedBoss.id);
if (defaultBoss === undefined) {
continue;
}
const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
const hasChanged
= savedBoss.name !== defaultBoss.name
|| savedBoss.description !== defaultBoss.description
|| savedBoss.maxHp !== defaultBoss.maxHp
|| savedBoss.damagePerSecond !== defaultBoss.damagePerSecond
|| savedBoss.goldReward !== defaultBoss.goldReward
|| savedBoss.essenceReward !== defaultBoss.essenceReward
|| savedBoss.crystalReward !== defaultBoss.crystalReward
|| savedRewards !== defaultRewards
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|| savedBoss.zoneId !== defaultBoss.zoneId
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones;
savedBoss.name = defaultBoss.name;
savedBoss.description = defaultBoss.description;
savedBoss.maxHp = defaultBoss.maxHp;
savedBoss.damagePerSecond = defaultBoss.damagePerSecond;
savedBoss.goldReward = defaultBoss.goldReward;
savedBoss.essenceReward = defaultBoss.essenceReward;
savedBoss.crystalReward = defaultBoss.crystalReward;
savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ];
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing zones to match the current defaults,
* preserving only player-state fields (status).
* @param state - The player's current game state (mutated in place).
* @returns The number of zone entries whose stats were updated.
*/
const patchZoneStats = (state: GameState): number => {
const defaultZoneMap = new Map(defaultZones.map((zone) => {
return [ zone.id, zone ] as const;
}));
let patched = 0;
for (const savedZone of state.zones) {
const defaultZone = defaultZoneMap.get(savedZone.id);
if (defaultZone === undefined) {
continue;
}
const hasChanged
= savedZone.name !== defaultZone.name
|| savedZone.description !== defaultZone.description
|| savedZone.emoji !== defaultZone.emoji
|| savedZone.unlockBossId !== defaultZone.unlockBossId
|| savedZone.unlockQuestId !== defaultZone.unlockQuestId;
savedZone.name = defaultZone.name;
savedZone.description = defaultZone.description;
savedZone.emoji = defaultZone.emoji;
savedZone.unlockBossId = defaultZone.unlockBossId;
savedZone.unlockQuestId = defaultZone.unlockQuestId;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing upgrades to match the current defaults,
* preserving only player-state fields (purchased, unlocked).
* @param state - The player's current game state (mutated in place).
* @returns The number of upgrade entries whose stats were updated.
*/
/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */
const patchUpgradeStats = (state: GameState): number => {
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
return [ upgrade.id, upgrade ] as const;
}));
let patched = 0;
for (const savedUpgrade of state.upgrades) {
const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id);
if (defaultUpgrade === undefined) {
continue;
}
const hasChanged
= savedUpgrade.name !== defaultUpgrade.name
|| savedUpgrade.description !== defaultUpgrade.description
|| savedUpgrade.target !== defaultUpgrade.target
|| savedUpgrade.adventurerId !== defaultUpgrade.adventurerId
|| savedUpgrade.multiplier !== defaultUpgrade.multiplier
|| savedUpgrade.costGold !== defaultUpgrade.costGold
|| savedUpgrade.costEssence !== defaultUpgrade.costEssence
|| savedUpgrade.costCrystals !== defaultUpgrade.costCrystals;
savedUpgrade.name = defaultUpgrade.name;
savedUpgrade.description = defaultUpgrade.description;
savedUpgrade.target = defaultUpgrade.target;
if (defaultUpgrade.adventurerId !== undefined) {
savedUpgrade.adventurerId = defaultUpgrade.adventurerId;
}
savedUpgrade.multiplier = defaultUpgrade.multiplier;
savedUpgrade.costGold = defaultUpgrade.costGold;
savedUpgrade.costEssence = defaultUpgrade.costEssence;
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing equipment items to match the current defaults,
* preserving only player-state fields (owned, equipped).
* @param state - The player's current game state (mutated in place).
* @returns The number of equipment entries whose stats were updated.
*/
/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */
const patchEquipmentStats = (state: GameState): number => {
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
return [ item.id, item ] as const;
}));
let patched = 0;
for (const savedItem of state.equipment) {
const defaultItem = defaultEquipmentMap.get(savedItem.id);
if (defaultItem === undefined) {
continue;
}
const savedBonus = JSON.stringify(savedItem.bonus);
const defaultBonus = JSON.stringify(defaultItem.bonus);
const savedCost = JSON.stringify(savedItem.cost);
const defaultCost = JSON.stringify(defaultItem.cost);
const hasChanged
= savedItem.name !== defaultItem.name
|| savedItem.description !== defaultItem.description
|| savedItem.type !== defaultItem.type
|| savedItem.rarity !== defaultItem.rarity
|| savedBonus !== defaultBonus
|| savedCost !== defaultCost
|| savedItem.setId !== defaultItem.setId;
savedItem.name = defaultItem.name;
savedItem.description = defaultItem.description;
savedItem.type = defaultItem.type;
savedItem.rarity = defaultItem.rarity;
savedItem.bonus = structuredClone(defaultItem.bonus);
if (defaultItem.cost !== undefined) {
savedItem.cost = { ...defaultItem.cost };
}
if (defaultItem.setId !== undefined) {
savedItem.setId = defaultItem.setId;
}
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/**
* Updates the stat fields of existing achievements to match the current defaults,
* preserving only player-state fields (unlockedAt).
* @param state - The player's current game state (mutated in place).
* @returns The number of achievement entries whose stats were updated.
*/
const patchAchievementStats = (state: GameState): number => {
const defaultAchievementMap = new Map(defaultAchievements.map((a) => {
return [ a.id, a ] as const;
}));
let patched = 0;
for (const savedAchievement of state.achievements) {
const defaultAchievement = defaultAchievementMap.get(savedAchievement.id);
if (defaultAchievement === undefined) {
continue;
}
const savedCondition = JSON.stringify(savedAchievement.condition);
const defaultCondition = JSON.stringify(defaultAchievement.condition);
const savedReward = JSON.stringify(savedAchievement.reward);
const defaultReward = JSON.stringify(defaultAchievement.reward);
const hasChanged
= savedAchievement.name !== defaultAchievement.name
|| savedAchievement.description !== defaultAchievement.description
|| savedAchievement.icon !== defaultAchievement.icon
|| savedCondition !== defaultCondition
|| savedReward !== defaultReward;
savedAchievement.name = defaultAchievement.name;
savedAchievement.description = defaultAchievement.description;
savedAchievement.icon = defaultAchievement.icon;
savedAchievement.condition = structuredClone(defaultAchievement.condition);
if (defaultAchievement.reward !== undefined) {
savedAchievement.reward = { ...defaultAchievement.reward };
}
if (hasChanged) {
patched = patched + 1;
}
}
return patched;
};
/* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */
/**
* Recomputes all four crafting multipliers from the player's craftedRecipeIds,
* replacing any stale cached values with the correct product of all crafted bonuses.
* @param state - The player's current game state (mutated in place).
* @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined.
*/
const recomputeCraftingMultipliers = (state: GameState): number => {
if (state.exploration === undefined) {
return 0;
}
const { craftedRecipeIds } = state.exploration;
state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
}).reduce((multiplier, recipe) => {
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
}).reduce((multiplier, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return multiplier * recipe.bonus.value;
}, 1);
return craftedRecipeIds.length;
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
/**
* Syncs a player's save with the current game data, injecting any content
* entries that are missing because they were added after the save was created,
* and patching stat fields on existing entries to match the current defaults.
* @param state - The player's current game state (mutated in place).
* @returns Counts of how many entries were added or patched per content type.
*/
const syncNewContent = (
state: GameState,
): {
achievementsAdded: number;
achievementsPatched: number;
adventurersAdded: number;
adventurerStatsPatched: number;
bossesAdded: number;
bossesPatched: number;
bossRewardsPatched: number;
craftingRecipesReapplied: number;
equipmentAdded: number;
equipmentPatched: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
} => {
const adventurerStatsPatched = patchAdventurerStats(state);
const questsPatched = patchQuestStats(state);
const bossesPatched = patchBossStats(state);
const zonesPatched = patchZoneStats(state);
const upgradesPatched = patchUpgradeStats(state);
const equipmentPatched = patchEquipmentStats(state);
const achievementsPatched = patchAchievementStats(state);
const craftingRecipesReapplied = recomputeCraftingMultipliers(state);
const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
const bossRewardsPatched = patchBossUpgradeRewards(state);
const bossesAdded = injectMissingEntries(state.bosses, defaultBosses);
const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment);
const explorationAreasAdded = injectMissingExplorationAreas(state);
const questRewardsPatched = patchQuestRewards(state);
const questsAdded = injectMissingEntries(state.quests, defaultQuests);
const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades);
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
return {
achievementsAdded,
achievementsPatched,
adventurerStatsPatched,
adventurersAdded,
bossRewardsPatched,
bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
};
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
const debugRouter = new Hono<HonoEnvironment>();
debugRouter.use(authMiddleware);
@@ -330,8 +1047,16 @@ debugRouter.post("/force-unlocks", async(context) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
const state = gameStateRecord.state as unknown as GameState;
const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }
= applyForceUnlocks(state);
const {
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
} = applyForceUnlocks(state);
const updatedAt = Date.now();
await prisma.gameState.update({
@@ -347,11 +1072,15 @@ debugRouter.post("/force-unlocks", async(context) => {
: computeHmac(JSON.stringify(state), secret);
return context.json({
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
signature,
state,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
});
} catch (error) {
@@ -365,6 +1094,87 @@ debugRouter.post("/force-unlocks", async(context) => {
}
});
debugRouter.post("/sync-new-content", async(context) => {
try {
const discordId = context.get("discordId");
const gameStateRecord = await prisma.gameState.findUnique({
where: { discordId },
});
if (!gameStateRecord) {
return context.json({ error: "No game state found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
const state = gameStateRecord.state as unknown as GameState;
const {
achievementsAdded,
achievementsPatched,
adventurersAdded,
adventurerStatsPatched,
bossesAdded,
bossesPatched,
bossRewardsPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
} = syncNewContent(state);
const updatedAt = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: updatedAt },
where: { discordId },
});
const secret = process.env.ANTI_CHEAT_SECRET;
const signature
= secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
return context.json({
achievementsAdded,
achievementsPatched,
adventurerStatsPatched,
adventurersAdded,
bossRewardsPatched,
bossesAdded,
bossesPatched,
craftingRecipesReapplied,
equipmentAdded,
equipmentPatched,
explorationAreasAdded,
questRewardsPatched,
questsAdded,
questsPatched,
signature,
state,
upgradesAdded,
upgradesPatched,
zonesAdded,
zonesPatched,
});
} catch (error) {
void logger.error(
"debug_sync_new_content",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
debugRouter.post("/hard-reset", async(context) => {
try {
const discordId = context.get("discordId");
+60
View File
@@ -7,6 +7,7 @@
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable max-lines -- Route file requires multiple handlers */
import { Hono } from "hono";
import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js";
@@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
ExploreClaimableResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
@@ -49,6 +51,64 @@ const pickNothingMessage = (): string => {
return nothingMessages[index] ?? nothingMessages[0] ?? "";
};
exploreRouter.get("/claimable", async(context) => {
try {
const discordId = context.get("discordId");
const areaId = context.req.query("areaId");
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
const response: ExploreClaimableResponse = { claimable: false };
return context.json(response);
}
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area || area.status !== "in_progress") {
const response: ExploreClaimableResponse = { claimable: false };
return context.json(response);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
const claimable = Date.now() >= expiresAt;
const response: ExploreClaimableResponse = { claimable };
return context.json(response);
} catch (error) {
void logger.error(
"explore_claimable",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
exploreRouter.post("/start", async(context) => {
try {
const discordId = context.get("discordId");
+31 -3
View File
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.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 {
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
try {
const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
Promise.all([
prisma.gameState.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) {
// No save found — create a fresh state (handles nuked DB or first-time load race)
if (!playerRecord) {
@@ -736,6 +760,7 @@ gameRouter.get("/load", async(context) => {
: computeHmac(JSON.stringify(freshState), secret);
return context.json({
currentSchemaVersion: currentSchemaVersion,
inGuild: playerRecord.inGuild,
loginBonus: null,
loginStreak: playerRecord.loginStreak,
offlineEssence: 0,
@@ -757,6 +782,7 @@ gameRouter.get("/load", async(context) => {
*/
if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName;
state.player.avatar = playerRecord.avatar;
}
const now = Date.now();
@@ -873,8 +899,10 @@ gameRouter.get("/load", async(context) => {
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
const inGuild = playerRecord?.inGuild ?? false;
return context.json({
currentSchemaVersion,
inGuild,
loginBonus,
loginStreak,
offlineEssence,
+43 -22
View File
@@ -7,6 +7,9 @@
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js";
const discordClientId = "1479551654264049908";
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
interface DiscordTokenResponse {
access_token: string;
token_type: string;
@@ -31,24 +34,18 @@ interface DiscordUser {
const exchangeCode = async(
code: string,
): Promise<DiscordTokenResponse> => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
if (clientSecret === undefined || clientSecret === "") {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({
client_id: clientId,
client_id: discordClientId,
client_secret: clientSecret,
code: code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
redirect_uri: discordRedirectUri,
});
try {
@@ -106,25 +103,49 @@ const fetchDiscordUser = async(
}
};
/**
* 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;
}
};
/**
* Builds the Discord OAuth authorisation URL.
* @returns The full OAuth URL to redirect the user to.
* @throws {Error} If OAuth environment variables are missing.
*/
const buildOAuthUrl = (): string => {
const 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({
client_id: clientId,
redirect_uri: redirectUri,
client_id: discordClientId,
redirect_uri: discordRedirectUri,
response_type: "code",
scope: "identify",
});
@@ -133,4 +154,4 @@ const buildOAuthUrl = (): string => {
};
export type { DiscordTokenResponse, DiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
+182
View File
@@ -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 };
+51 -11
View File
@@ -15,6 +15,49 @@ const discordApi = "https://discord.com/api/v10";
*/
const suppressNotifications = 4096;
/**
* The Discord role ID for the Elysian role granted to all Elysium players.
*/
const discordGuildId = "1354624415861833870";
const elysianRoleId = "1486144823684628490";
const apotheosisRoleId = "1479966598210129991";
/**
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
* Fails silently so role grant errors do not affect the auth flow.
* @param discordId - The Discord user ID to grant the role to.
* @returns True if the player is in the guild and the role was granted, false otherwise.
*/
const grantElysianRole = async(discordId: string): Promise<boolean> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return false;
}
try {
const response = await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
return response.ok || response.status === 204;
} catch (error) {
void logger.error(
"webhook_elysian_role",
error instanceof Error
? error
: new Error(String(error)),
);
return false;
}
};
/**
* Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action.
@@ -23,23 +66,20 @@ const suppressNotifications = 4096;
*/
const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
if (
botToken === undefined || botToken === ""
|| guildId === undefined || guildId === ""
|| roleId === undefined || roleId === ""
) {
if (botToken === undefined || botToken === "") {
return;
}
try {
await fetch(
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
{
headers: { Authorization: `Bot ${botToken}` },
method: "PUT",
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
} catch (error) {
@@ -109,4 +149,4 @@ const postMilestoneWebhook = async(
}
};
export { grantApotheosisRole, postMilestoneWebhook };
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
+608
View File
@@ -366,6 +366,161 @@ describe("debug route", () => {
expect(body.explorationUnlocked).toBe(0);
});
it("unlocks adventurer tier when its quest has been completed", async () => {
const state = makeState({
adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"],
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { adventurersUnlocked: number };
expect(body.adventurersUnlocked).toBe(1);
});
it("does not unlock adventurer tier when it is already unlocked", async () => {
const state = makeState({
adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"],
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { adventurersUnlocked: number };
expect(body.adventurersUnlocked).toBe(0);
});
it("unlocks upgrade when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(1);
});
it("does not unlock upgrade when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(0);
});
it("does not unlock upgrade when it is already unlocked", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(0);
});
it("unlocks upgrade granted as a quest reward", async () => {
const state = makeState({
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(1);
});
it("marks equipment as owned when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(1);
});
it("does not mark equipment as owned when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(0);
});
it("does not mark equipment as owned when it is already owned", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(0);
});
it("returns storyUnlocked=0 when story is undefined", async () => {
const state = makeState({
story: undefined as unknown as GameState["story"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("unlocks story chapter when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { storyUnlocked: number };
expect(body.storyUnlocked).toBe(1);
});
it("does not unlock story chapter when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("does not unlock story chapter when it is already unlocked", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState();
@@ -402,6 +557,459 @@ describe("debug route", () => {
});
});
const syncNewContent = () =>
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
describe("POST /sync-new-content", () => {
it("returns 404 when no game state found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await syncNewContent();
expect(res.status).toBe(404);
});
it("returns 200 with zero added counts when state already has all content", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number; bossRewardsPatched: number; questRewardsPatched: number };
expect(body.adventurerStatsPatched).toBe(0);
expect(body.bossRewardsPatched).toBe(0);
expect(body.questRewardsPatched).toBe(0);
});
it("patches adventurer stats when saved adventurer has outdated stats", async () => {
const state = makeState({
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number; state: GameState };
expect(body.adventurerStatsPatched).toBe(1);
const adventurer = body.state.adventurers.find((a) => a.id === "militia");
expect(adventurer?.baseCost).not.toBe(1);
expect(adventurer?.count).toBe(5);
expect(adventurer?.unlocked).toBe(true);
});
it("skips adventurer stat patching for adventurers not in defaults", async () => {
const state = makeState({
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurerStatsPatched: number };
expect(body.adventurerStatsPatched).toBe(0);
});
it("injects missing entries when arrays are empty", async () => {
const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], zones: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { adventurersAdded: number; bossesAdded: number };
expect(body.adventurersAdded).toBeGreaterThan(0);
expect(body.bossesAdded).toBeGreaterThan(0);
});
it("injects missing exploration areas when exploration has no areas", async () => {
const state = makeState({ exploration: makeExploration([]) });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
expect(body.explorationAreasAdded).toBeGreaterThan(0);
});
it("skips existing exploration areas when building the id set", async () => {
const state = makeState({ exploration: makeExploration([
{ id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0],
]) });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
// One area already existed so total injected is one less than full count
expect(body.explorationAreasAdded).toBeGreaterThan(0);
});
it("returns explorationAreasAdded=0 when exploration state is undefined", async () => {
const state = makeState({ exploration: undefined as unknown as GameState["exploration"] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { explorationAreasAdded: number };
expect(body.explorationAreasAdded).toBe(0);
});
it("patches quest rewards when saved quest has fewer rewards than default", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "first_steps");
expect(quest?.rewards.length).toBeGreaterThan(0);
});
it("skips quest reward patching for quests not in defaults", async () => {
const state = makeState({
quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "nonexistent_quest");
expect(quest?.rewards).toStrictEqual([]);
});
it("does not re-add rewards that are already present in the saved quest", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const quest = body.state.quests.find((q) => q.id === "first_steps");
// Reward already present so count stays the same
expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1);
});
it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.upgradeRewards.length).toBeGreaterThan(0);
});
it("skips boss reward patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss");
expect(boss?.upgradeRewards).toStrictEqual([]);
});
it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => {
const state = makeState({
achievements: [
{ id: "legacy_achievement_a", status: "locked" },
{ id: "legacy_achievement_b", status: "locked" },
] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("uses amount field when building the reward key for quests with amount-based rewards", async () => {
const state = makeState({
quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("falls back to empty string when reward has neither targetId nor amount", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
});
it("patches upgrade adventurerId when default has it set", async () => {
const state = makeState({
upgrades: [{ id: "peasant_1", purchased: false, unlocked: false, multiplier: 0.1, name: "Old", description: "Old", target: "click", costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const upgrade = body.state.upgrades.find((u) => u.id === "peasant_1");
expect(upgrade?.adventurerId).toBe("peasant");
});
it("patches equipment cost when default has it set", async () => {
const state = makeState({
equipment: [{ id: "shadow_dagger", owned: false, equipped: false, name: "Old", description: "Old", type: "weapon", rarity: "common", bonus: {} }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
const item = body.state.equipment.find((e) => e.id === "shadow_dagger");
expect(item?.cost).toBeDefined();
});
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("returns 500 when DB throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await syncNewContent();
expect(res.status).toBe(500);
});
it("returns 500 when DB throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
const res = await syncNewContent();
expect(res.status).toBe(500);
});
it("patches quest stats when saved quest has outdated fields", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available", rewards: [], durationSeconds: 1, name: "Old Name", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { questsPatched: number; state: GameState };
expect(body.questsPatched).toBe(1);
const quest = body.state.quests.find((q) => q.id === "first_steps");
expect(quest?.name).not.toBe("Old Name");
expect(quest?.durationSeconds).not.toBe(1);
expect(quest?.status).toBe("available");
});
it("skips quest stat patching for quests not in defaults", async () => {
const state = makeState({
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { questsPatched: number };
expect(body.questsPatched).toBe(0);
});
it("patches boss stats when saved boss has outdated fields", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Old Name", description: "Old" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number; state: GameState };
expect(body.bossesPatched).toBe(1);
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.maxHp).not.toBe(1);
expect(boss?.name).not.toBe("Old Name");
expect(boss?.status).toBe("available");
expect(boss?.currentHp).toBe(100);
});
it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number };
expect(body.bossesPatched).toBe(0);
});
it("patches zone stats when saved zone has outdated fields", async () => {
const state = makeState({
zones: [{ id: "verdant_vale", status: "unlocked", name: "Old Name", description: "Old", emoji: "❓", unlockBossId: "wrong_boss", unlockQuestId: "wrong_quest" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { zonesPatched: number; state: GameState };
expect(body.zonesPatched).toBe(1);
const zone = body.state.zones.find((z) => z.id === "verdant_vale");
expect(zone?.name).not.toBe("Old Name");
expect(zone?.status).toBe("unlocked");
});
it("skips zone stat patching for zones not in defaults", async () => {
const state = makeState({
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { zonesPatched: number };
expect(body.zonesPatched).toBe(0);
});
it("patches upgrade stats when saved upgrade has outdated fields", async () => {
const state = makeState({
upgrades: [{ id: "click_2", purchased: false, unlocked: true, multiplier: 0.1, name: "Old Name", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { upgradesPatched: number; state: GameState };
expect(body.upgradesPatched).toBe(1);
const upgrade = body.state.upgrades.find((u) => u.id === "click_2");
expect(upgrade?.multiplier).not.toBe(0.1);
expect(upgrade?.name).not.toBe("Old Name");
expect(upgrade?.purchased).toBe(false);
expect(upgrade?.unlocked).toBe(true);
});
it("skips upgrade stat patching for upgrades not in defaults", async () => {
const state = makeState({
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { upgradesPatched: number };
expect(body.upgradesPatched).toBe(0);
});
it("patches equipment stats when saved item has outdated fields", async () => {
const state = makeState({
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Rusty Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { equipmentPatched: number; state: GameState };
expect(body.equipmentPatched).toBe(1);
const item = body.state.equipment.find((e) => e.id === "iron_sword");
expect(item?.name).not.toBe("Rusty Sword");
expect(item?.owned).toBe(true);
expect(item?.equipped).toBe(false);
});
it("skips equipment stat patching for items not in defaults", async () => {
const state = makeState({
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { equipmentPatched: number };
expect(body.equipmentPatched).toBe(0);
});
it("patches achievement stats when saved achievement has outdated fields", async () => {
const state = makeState({
achievements: [{ id: "first_click", unlockedAt: null, name: "Old Name", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 999 }, reward: undefined }] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { achievementsPatched: number; state: GameState };
expect(body.achievementsPatched).toBe(1);
const achievement = body.state.achievements.find((a) => a.id === "first_click");
expect(achievement?.name).not.toBe("Old Name");
expect(achievement?.condition.amount).not.toBe(999);
expect(achievement?.unlockedAt).toBeNull();
});
it("skips achievement stat patching for achievements not in defaults", async () => {
const state = makeState({
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { achievementsPatched: number };
expect(body.achievementsPatched).toBe(0);
});
it("recomputes crafting multipliers from craftedRecipeIds", async () => {
const state = makeState({
exploration: { ...makeExploration(), craftedRecipeIds: ["heartwood_tincture"], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { craftingRecipesReapplied: number; state: GameState };
expect(body.craftingRecipesReapplied).toBe(1);
expect(body.state.exploration?.craftedGoldMultiplier).toBeGreaterThan(1);
});
it("returns 0 for crafting recompute when exploration is undefined", async () => {
const state = makeState({
exploration: undefined as unknown as GameState["exploration"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { craftingRecipesReapplied: number };
expect(body.craftingRecipesReapplied).toBe(0);
});
it("sets multipliers to 1 when craftedRecipeIds is empty", async () => {
const state = makeState({
exploration: { ...makeExploration(), craftedRecipeIds: [], craftedGoldMultiplier: 5, craftedEssenceMultiplier: 5, craftedClickMultiplier: 5, craftedCombatMultiplier: 5 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.exploration?.craftedGoldMultiplier).toBe(1);
expect(body.state.exploration?.craftedEssenceMultiplier).toBe(1);
expect(body.state.exploration?.craftedClickMultiplier).toBe(1);
expect(body.state.exploration?.craftedCombatMultiplier).toBe(1);
});
});
describe("POST /hard-reset", () => {
it("returns 404 when no player found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
+93
View File
@@ -77,6 +77,99 @@ describe("explore route", () => {
body: JSON.stringify(body),
}));
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/explore/claimable"
: `http://localhost/explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("nonexistent_area");
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when no exploration state exists", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area is not in_progress", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when exploration is still in progress", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=true when exploration is complete", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postStart({});
+73
View File
@@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({
}),
}));
vi.mock("../../src/services/discord.js", () => ({
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
}));
const DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1;
@@ -200,6 +204,75 @@ describe("game route", () => {
expect(body.offlineGold).toBeGreaterThan(0);
expect(body.offlineEssence).toBeGreaterThan(0);
});
it("syncs updated avatar from Discord into the returned state", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("new_hash");
});
it("continues loading when the avatar DB update fails", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("keeps stored avatar when Discord returns null", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("stored_hash");
});
});
describe("POST /save", () => {
+52 -25
View File
@@ -18,51 +18,31 @@ describe("discord service", () => {
});
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 () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js");
const url = buildOAuthUrl();
expect(url).toContain("client_id=client123");
expect(url).toContain("client_id=1479551654264049908");
expect(url).toContain("response_type=code");
expect(url).toContain("scope=identify");
});
});
describe("exchangeCode", () => {
it("throws when env vars are missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
delete process.env["DISCORD_CLIENT_SECRET"];
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
});
it("throws when response is not ok", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
});
it("returns parsed body on success", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
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" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
const { exchangeCode } = await import("../../src/services/discord.js");
@@ -96,12 +76,59 @@ describe("discord service", () => {
describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
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" });
});
});
});
+105
View File
@@ -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"),
);
});
});
});
+60 -29
View File
@@ -20,42 +20,20 @@ describe("webhook service", () => {
describe("grantApotheosisRole", () => {
it("does nothing when bot token is missing", async () => {
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");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when guild id is missing", 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 () => {
it("calls Discord API with correct URL and auth when bot token is set", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
mockFetch.mockResolvedValueOnce({ ok: true });
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user789");
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({
method: "PUT",
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
@@ -63,8 +41,6 @@ describe("webhook service", () => {
it("swallows fetch errors gracefully", async () => {
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"));
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
@@ -72,14 +48,69 @@ describe("webhook service", () => {
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
});
describe("grantElysianRole", () => {
it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("returns true when Discord API responds with ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user789");
expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
expect(result).toBe(true);
});
it("returns true when Discord API responds with 204", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(true);
});
it("returns false when Discord API responds with an error status", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows non-Error fetch rejections", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
});
describe("postMilestoneWebhook", () => {
const counts = { prestige: 1, transcendence: 0, apotheosis: 0 };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.1.2",
"version": "0.3.2",
"private": true,
"type": "module",
"scripts": {
+27
View File
@@ -17,6 +17,7 @@ import type {
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
@@ -28,6 +29,7 @@ import type {
PublicProfileResponse,
SaveRequest,
SaveResponse,
SyncNewContentResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
@@ -243,6 +245,19 @@ const collectExploration = async(
});
};
/**
* Checks whether a given exploration area is ready to claim on the server.
* @param areaId - The area ID to check.
* @returns Whether the exploration is claimable.
*/
const checkExplorationClaimable = async(
areaId: string,
): Promise<ExploreClaimableResponse> => {
return await fetchJson<ExploreClaimableResponse>(
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
);
};
/**
* Crafts a recipe on the server.
* @param body - The craft recipe request payload.
@@ -267,6 +282,16 @@ const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
});
};
/**
* 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.
@@ -305,10 +330,12 @@ export {
buyEchoUpgrade,
buyPrestigeUpgrade,
challengeBoss,
checkExplorationClaimable,
collectExploration,
craftRecipe,
debugHardReset,
forceUnlocks,
syncNewContent,
getAbout,
getAuthUrl,
getPublicProfile,
@@ -158,15 +158,6 @@ const howToPlay = [
+ " is visible on your public profile page.",
title: "📋 Character Sheet",
},
{
body:
"Customise your adventurer's appearance from the Character tab. Choose"
+ " your skin tone, hair style, hair colour, outfit, and accessory."
+ " Your paper doll is displayed in the sidebar for you to see at all"
+ " times. Appearance settings are purely cosmetic and persist through"
+ " prestige and transcendence resets.",
title: "🎨 Paper Doll",
},
{
body:
"Earn Titles by reaching milestones — defeating bosses, completing"
@@ -143,6 +143,10 @@ const AdventurerCard = ({
{" essence/s each"}
</p>
}
<p>
{formatNumber(adventurer.combatPower)}
{" combat power each"}
</p>
</div>
<div className="adventurer-count">
{"×"}
@@ -171,7 +175,7 @@ const AdventurerCard = ({
* @returns The JSX element.
*/
const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame();
const { state, formatNumber, toggleAutoAdventurer } = useGame();
const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
@@ -203,6 +207,11 @@ const AdventurerPanel = (): JSX.Element => {
}
}
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
"auto_adventurer",
);
const autoAdventurerOn = state.autoAdventurer === true;
function handleToggle(): void {
setShowLocked((current) => {
return !current;
@@ -213,11 +222,34 @@ const AdventurerPanel = (): JSX.Element => {
<section className="panel adventurer-panel">
<div className="panel-header">
<h2>{"Adventurers"}</h2>
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
<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
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
</div>
<div className="batch-selector">
{batchOptions.map((option) => {
+54 -69
View File
@@ -11,10 +11,11 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react";
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 { ZoneSelector } from "./zoneSelector.js";
import type { Boss, GameState } from "@elysium/types";
import type { Boss } from "@elysium/types";
interface BossCardProperties {
readonly boss: Boss;
@@ -157,72 +158,6 @@ 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.
* @returns The JSX element.
@@ -266,7 +201,31 @@ const BossPanel = (): JSX.Element => {
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) => {
return boss.zoneId === activeZoneId;
});
@@ -332,7 +291,12 @@ const BossPanel = (): JSX.Element => {
}
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;
return (
@@ -393,6 +357,27 @@ const BossPanel = (): JSX.Element => {
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="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span>
@@ -9,10 +9,8 @@
/* eslint-disable max-statements -- Component requires many state declarations */
/* eslint-disable max-lines -- Large component with editing and view modes */
import {
defaultAppearance,
DEFAULT_PROFILE_SETTINGS,
STORY_CHAPTERS,
type Appearance,
type EquipmentBonus,
type EquipmentRarity,
type EquipmentType,
@@ -89,7 +87,7 @@ const formatBonus = (bonus: EquipmentBonus): string => {
* @returns The JSX element.
*/
const CharacterSheetPanel = (): JSX.Element => {
const { state, loginStreak, updateAppearance } = useGame();
const { state, loginStreak } = useGame();
const player = state?.player;
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
@@ -278,35 +276,6 @@ const CharacterSheetPanel = (): JSX.Element => {
});
}
const currentAppearance = state?.appearance ?? defaultAppearance;
function handleAppearanceChange(
field: keyof Appearance,
value: string,
): void {
updateAppearance({ ...currentAppearance, [field]: value });
}
function handleSkinToneChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("skinTone", event.target.value);
}
function handleHairStyleChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("hairStyle", event.target.value);
}
function handleHairColourChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("hairColour", event.target.value);
}
function handleOutfitChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("outfit", event.target.value);
}
function handleAccessoryChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("accessory", event.target.value);
}
if (loading) {
return (
<section className="panel">
@@ -604,116 +573,6 @@ const CharacterSheetPanel = (): JSX.Element => {
}
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">
{"🎨 Appearance"}
</h3>
<p className="character-sheet-hint">
{"Customise your adventurer's look. Changes save automatically."}
</p>
<div className="appearance-editor">
<label
className="character-sheet-label"
htmlFor="appearance-skin-tone"
>
{"Skin Tone"}
</label>
<select
className="character-sheet-select"
id="appearance-skin-tone"
onChange={handleSkinToneChange}
value={currentAppearance.skinTone}
>
<option value="pale">{"Pale"}</option>
<option value="light">{"Light"}</option>
<option value="tan">{"Tan"}</option>
<option value="medium">{"Medium"}</option>
<option value="dark">{"Dark"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-hair-style"
>
{"Hair Style"}
</label>
<select
className="character-sheet-select"
id="appearance-hair-style"
onChange={handleHairStyleChange}
value={currentAppearance.hairStyle}
>
<option value="short">{"Short"}</option>
<option value="shoulder">{"Shoulder-length"}</option>
<option value="long">{"Long"}</option>
<option value="ponytail">{"Ponytail"}</option>
<option value="twintails">{"Twin Tails"}</option>
<option value="bun">{"Bun"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-hair-colour"
>
{"Hair Colour"}
</label>
<select
className="character-sheet-select"
id="appearance-hair-colour"
onChange={handleHairColourChange}
value={currentAppearance.hairColour}
>
<option value="brown">{"Brown"}</option>
<option value="black">{"Black"}</option>
<option value="blonde">{"Blonde"}</option>
<option value="red">{"Red"}</option>
<option value="auburn">{"Auburn"}</option>
<option value="silver">{"Silver"}</option>
<option value="blue">{"Blue"}</option>
<option value="purple">{"Purple"}</option>
<option value="pink">{"Pink"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-outfit"
>
{"Outfit"}
</label>
<select
className="character-sheet-select"
id="appearance-outfit"
onChange={handleOutfitChange}
value={currentAppearance.outfit}
>
<option value="warrior">{"Warrior"}</option>
<option value="mage">{"Mage"}</option>
<option value="rogue">{"Rogue"}</option>
<option value="archer">{"Archer"}</option>
<option value="bard">{"Bard"}</option>
<option value="ranger">{"Ranger"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-accessory"
>
{"Accessory"}
</label>
<select
className="character-sheet-select"
id="appearance-accessory"
onChange={handleAccessoryChange}
value={currentAppearance.accessory}
>
<option value="none">{"None"}</option>
<option value="glasses">{"Glasses"}</option>
<option value="hat">{"Hat"}</option>
<option value="cape">{"Cape"}</option>
</select>
</div>
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
{sheet.equippedItems.length > 0
+158 -25
View File
@@ -10,22 +10,138 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | null;
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, isLoading } = useGame();
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");
}
@@ -38,29 +154,15 @@ const DebugPanel = (): JSX.Element => {
setActiveModal(null);
void (async(): Promise<void> => {
const result = await forceUnlocks();
const parts: Array<string> = [];
if (result.zonesUnlocked > 0) {
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
}
if (result.questsUnlocked > 0) {
parts.push(`${String(result.questsUnlocked)} quest(s)`);
}
if (result.bossesUnlocked > 0) {
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
}
if (result.explorationUnlocked > 0) {
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
}
const total
= result.zonesUnlocked
+ result.questsUnlocked
+ result.bossesUnlocked
+ result.explorationUnlocked;
const message
= parts.length === 0
? "Everything looks correct — no missing unlocks were found."
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
setForceUnlocksResult(message);
setForceUnlocksResult(buildForceUnlocksMessage(result));
})();
}
function handleConfirmSyncNewContent(): void {
setActiveModal(null);
void (async(): Promise<void> => {
const result = await syncNewContent();
setSyncNewContentResult(buildSyncNewContentMessage(result));
})();
}
@@ -99,6 +201,26 @@ const DebugPanel = (): JSX.Element => {
}
</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>
@@ -128,6 +250,17 @@ const DebugPanel = (): JSX.Element => {
/>
}
{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"
@@ -7,12 +7,17 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
import { type JSX, useState } from "react";
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
import { type JSX, useEffect, useRef, useState } from "react";
import { checkExplorationClaimable } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js";
import { cdnImage } from "../../utils/cdn.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.
@@ -83,6 +88,61 @@ const ExplorationPanel = (): JSX.Element => {
});
const [ pendingAreaId, setPendingAreaId ] = useState<string | 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) {
return (
@@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => {
try {
const result = await collectExploration(areaId);
setLastResult({ areaId: areaId, response: result });
setClaimableAreaIds((previous) => {
const next = new Set(previous);
next.delete(areaId);
return next;
});
} finally {
setPendingAreaId(null);
}
@@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => {
const endsAt = areaState?.endsAt;
const isReady
= status === "in_progress"
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
&& claimableAreaIds.has(area.id);
const isPending = pendingAreaId === area.id;
function handleStartClick(): void {
+2 -4
View File
@@ -27,11 +27,11 @@ import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js";
import { JoinCommunityModal } from "./joinCommunityModal.js";
import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js";
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
import { PaperDoll } from "./paperDoll.js";
import { PrestigePanel } from "./prestigePanel.js";
import { QuestPanel } from "./questPanel.js";
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
@@ -136,7 +136,6 @@ const GameLayout = (): JSX.Element => {
);
}
const profileUrl = `/profile/${state.player.discordId}`;
const codexBadgeCount = pendingCodexEntryIds.length;
const storyBadgeCount = pendingStoryChapterIds.length;
@@ -161,12 +160,12 @@ const GameLayout = (): JSX.Element => {
onEditProfile={handleOpenEditProfile}
onForceSync={forceSync}
prestigeCount={state.prestige.count}
profileUrl={profileUrl}
resources={state.resources}
runestones={state.prestige.runestones}
transcendenceCount={state.transcendence?.count ?? 0}
/>
<OfflineModal />
<JoinCommunityModal />
{schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null}
@@ -194,7 +193,6 @@ const GameLayout = (): JSX.Element => {
<aside className="game-sidebar">
<ClickArea />
<div id="tree-nation-offset-website" />
<PaperDoll />
<p className="game-copyright">{"© NHCarrigan"}</p>
</aside>
@@ -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 };
@@ -1,89 +0,0 @@
/**
* @file Paper doll component for displaying layered adventurer appearance.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Hikari
*/
import {
defaultAppearance,
type HairColour,
type SkinTone,
} from "@elysium/types";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import type { JSX } from "react";
/**
* CSS filter strings for each skin tone, applied to the base body layer.
* Uses brightness + sepia + saturation to shift the neutral base skin.
*/
const skinToneFilters: Record<SkinTone, string> = {
dark: "brightness(0.55) saturate(0.55) sepia(0.5) contrast(1.1)",
light: "brightness(0.98) saturate(0.4) sepia(0.12)",
medium: "brightness(0.74) saturate(0.75) sepia(0.42)",
pale: "brightness(1.05) saturate(0.2) sepia(0.08)",
tan: "brightness(0.88) saturate(0.65) sepia(0.28)",
};
/**
* CSS filter strings for each hair colour.
* Applied to the greyscale hair layer via sepia + hue-rotate tinting.
*/
const hairColourFilters: Record<HairColour, string> = {
auburn: "sepia(1) saturate(3) hue-rotate(350deg)",
black: "brightness(0.15)",
blonde: "sepia(1) saturate(3) hue-rotate(5deg) brightness(1.6)",
blue: "sepia(1) saturate(5) hue-rotate(190deg)",
brown: "sepia(1) saturate(2) hue-rotate(0deg)",
pink: "sepia(1) saturate(5) hue-rotate(305deg)",
purple: "sepia(1) saturate(5) hue-rotate(245deg)",
red: "sepia(1) saturate(4) hue-rotate(345deg)",
silver: "grayscale(1) brightness(1.9) contrast(0.8)",
};
/**
* Renders the paper doll — a layered composite of body, outfit, hair, and
* accessory images that together represent the player's adventurer appearance.
* All layers use mix-blend-mode: multiply so white backgrounds become
* transparent, allowing the layers to composite cleanly.
* @returns The JSX element.
*/
const PaperDoll = (): JSX.Element => {
const { state } = useGame();
const appearance = state?.appearance ?? defaultAppearance;
const { skinTone, hairStyle, hairColour, outfit, accessory } = appearance;
return (
<div className="paper-doll">
{/* Base body — skin-toneable */}
<img
alt=""
className="paper-doll-layer paper-doll-body"
src={cdnImage("paper-doll", "body")}
style={{ filter: skinToneFilters[skinTone] }}
/>
{/* Outfit layer */}
<img
alt=""
className="paper-doll-layer paper-doll-outfit"
src={cdnImage("paper-doll", `outfit-${outfit}`)}
/>
{/* Hair layer — colour-tintable greyscale */}
<img
alt=""
className="paper-doll-layer paper-doll-hair"
src={cdnImage("paper-doll", `hair-${hairStyle}`)}
style={{ filter: hairColourFilters[hairColour] }}
/>
{accessory === "none"
? null
: <img
alt=""
className="paper-doll-layer paper-doll-accessory"
src={cdnImage("paper-doll", `accessory-${accessory}`)}
/>}
</div>
);
};
export { PaperDoll };
+45 -3
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License
* @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 max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */
@@ -148,8 +149,7 @@ const QuestCard = ({
&& <p className="quest-failure-chance">
{"🎲 "}
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
{"% failure chance — if failed, the quest resets"}
{" and must be retried."}
{"% failure chance"}
</p>
}
{quest.status === "available" && quest.lastFailedAt !== undefined
@@ -208,7 +208,24 @@ const QuestPanel = (): JSX.Element => {
);
}
const { adventurers, autoQuest, quests, zones } = state;
const { adventurers, autoQuest, bosses, quests, zones } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
let partyCombatPower = 0;
for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
@@ -307,6 +324,31 @@ const QuestPanel = (): JSX.Element => {
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">
{visibleQuests.map((quest) => {
return (
@@ -7,6 +7,8 @@
/* 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 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 { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
@@ -238,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 {
setShowLocked((current) => {
+218 -104
View File
@@ -4,12 +4,19 @@
* @license Naomi's Public License
* @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-statements -- Resource bar requires many local computations and handlers */
/* 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 { RESOURCE_CAP } from "../../engine/tick.js";
import {
RESOURCE_CAP,
computeEssencePerSecond,
computeGoldPerSecond,
computePartyCombatPower,
} from "../../engine/tick.js";
import type { Resource } from "@elysium/types";
import type { JSX } from "react";
interface ResourceBarProperties {
readonly resources: Resource;
@@ -17,7 +24,6 @@ interface ResourceBarProperties {
readonly prestigeCount: number;
readonly transcendenceCount: number;
readonly apotheosisCount: number;
readonly profileUrl: string;
readonly onEditProfile: ()=> void;
readonly lastSavedAt: number | null;
readonly isSyncing: boolean;
@@ -58,7 +64,6 @@ const resourceFullTooltip = [
* @param props.prestigeCount - The number of prestiges completed.
* @param props.transcendenceCount - The number of transcendences 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.lastSavedAt - Timestamp of the last cloud save.
* @param props.isSyncing - Whether a sync is currently in progress.
@@ -71,84 +76,174 @@ const ResourceBar = ({
prestigeCount,
transcendenceCount,
apotheosisCount,
profileUrl,
onEditProfile,
lastSavedAt,
isSyncing,
onForceSync,
}: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError, state } = useGame();
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
const { gold, essence, crystals } = resources;
let partyCombatPower = 0;
let goldPerSecond = 0;
let essencePerSecond = 0;
if (state !== null) {
for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
partyCombatPower = computePartyCombatPower(state);
goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state);
}
const resourceValues = [ gold, essence, crystals ];
const anyFull = resourceValues.some((v) => {
return v >= RESOURCE_CAP;
});
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 essenceFull = essence >= RESOURCE_CAP;
const crystalsFull = crystals >= RESOURCE_CAP;
const anyFull = goldFull || essenceFull || crystalsFull;
const hiddenResourcesFull = essenceFull || crystalsFull;
function handleForceSync(): void {
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 (
<>
<header className="resource-bar">
<div className={`resource${goldFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"🪙"}</span>
<span className="resource-value">{formatNumber(gold)}</span>
<span className="resource-label">{"Gold"}</span>
{goldFull
? <span className="resource-cap-badge" title={resourceFullTooltip}>
{"FULL"}
</span>
<div
className="resource-menu"
onBlur={handleResourceBlur}
>
<button
className={`resource resource-toggle${goldFull
? " resource-full"
: ""}`}
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-label">{"Gold"}</span>
{goldFull
? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"}
</span>
: null}
{hiddenResourcesFull
? <span
className="resource-alert-dot"
title={"One or more resources are full!"}
/>
: null}
</button>
{isResourcesOpen
? <div className="resources-dropdown">
<div className="resource">
<span className="resource-icon">{"📈"}</span>
<span className="resource-value">
{formatNumber(goldPerSecond)}
</span>
<span className="resource-label">{"Gold/s"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚡"}</span>
<span className="resource-value">
{formatNumber(essencePerSecond)}
</span>
<span className="resource-label">{"Essence/s"}</span>
</div>
<div className={`resource${essenceFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"✨"}</span>
<span className="resource-value">
{formatNumber(essence)}
</span>
<span className="resource-label">{"Essence"}</span>
{essenceFull
? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"}
</span>
: null}
</div>
<div className={`resource${crystalsFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"💎"}</span>
<span className="resource-value">
{formatNumber(crystals)}
</span>
<span className="resource-label">{"Crystals"}</span>
{crystalsFull
? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"}
</span>
: null}
</div>
<div className="resource">
<span className="resource-icon">{"🔮"}</span>
<span className="resource-value">
{formatNumber(runestones)}
</span>
<span className="resource-label">{"Runestones"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚔️"}</span>
<span className="resource-value">
{formatNumber(partyCombatPower)}
</span>
<span className="resource-label">{"Combat Power"}</span>
</div>
</div>
: null}
</div>
<div className={`resource${essenceFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"✨"}</span>
<span className="resource-value">{formatNumber(essence)}</span>
<span className="resource-label">{"Essence"}</span>
{essenceFull
? <span className="resource-cap-badge" title={resourceFullTooltip}>
{"FULL"}
</span>
: null}
</div>
<div className={`resource${crystalsFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"💎"}</span>
<span className="resource-value">{formatNumber(crystals)}</span>
<span className="resource-label">{"Crystals"}</span>
{crystalsFull
? <span className="resource-cap-badge" title={resourceFullTooltip}>
{"FULL"}
</span>
: null}
</div>
<div className="resource">
<span className="resource-icon">{"🔮"}</span>
<span className="resource-value">{formatNumber(runestones)}</span>
<span className="resource-label">{"Runestones"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚔️"}</span>
<span className="resource-value">
{formatNumber(partyCombatPower)}
</span>
<span className="resource-label">{"Combat Power"}</span>
</div>
{apotheosisCount > 0
&& <div className="apotheosis-badge">
{"✨ Apotheosis "}
@@ -167,34 +262,7 @@ const ResourceBar = ({
{prestigeCount}
</div>
}
<div className="profile-buttons">
<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>
<div className="resource-bar-actions">
{syncError === null
? null
: <span className="save-status save-error" title={syncError}>
@@ -221,23 +289,69 @@ const ResourceBar = ({
? "⏳"
: "💾"}
</button>
<a
className="profile-link-button"
href={profileUrl}
rel="noreferrer"
target="_blank"
title="View your public profile"
>
{"👤"} <span className="btn-label">{"Profile"}</span>
</a>
<button
className="profile-edit-button"
onClick={onEditProfile}
title="Edit your profile"
type="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
className="profile-dropdown-item"
href={profileUrl}
rel="noreferrer"
target="_blank"
>
{"👤 View Profile"}
</a>
<button
className="profile-dropdown-item"
onClick={handleEditProfile}
type="button"
>
{"✏️ Edit Profile"}
</button>
<hr className="profile-dropdown-divider" />
<a
className="profile-dropdown-item"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💜 Donate"}
</a>
<a
className="profile-dropdown-item"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💬 Discord"}
</a>
<a
className="profile-dropdown-item"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"🆘 Support"}
</a>
</div>
: null}
</div>}
</div>
</header>
{anyFull
+160 -50
View File
@@ -14,7 +14,6 @@
import {
STORY_CHAPTERS,
type Achievement,
type Appearance,
type ApotheosisResponse,
type BossChallengeResponse,
type ExploreCollectResponse,
@@ -45,6 +44,7 @@ import {
craftRecipe as craftRecipeApi,
debugHardReset as debugHardResetApi,
forceUnlocks as forceUnlocksApi,
syncNewContent as syncNewContentApi,
loadGame,
prestige as prestigeApi,
resetProgress as resetProgressApi,
@@ -58,6 +58,7 @@ import {
RESOURCE_CAP,
applyTick,
calculateClickPower,
computePartyCombatPower,
} from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
@@ -243,6 +244,11 @@ interface GameContextValue {
isLoading: boolean;
error: string | null;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/**
* Click the crystal to earn gold.
*/
@@ -453,12 +459,6 @@ interface GameContextValue {
*/
toggleAutoAdventurer: ()=> void;
/**
* Update the player's paper doll appearance customisation.
* @param appearance - The new appearance settings.
*/
updateAppearance: (appearance: Appearance)=> void;
/**
* Queue of newly unlocked codex entry IDs (for toast notifications).
*/
@@ -565,9 +565,13 @@ interface GameContextValue {
* @returns Counts of what was corrected.
*/
forceUnlocks: ()=> Promise<{
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
}>;
@@ -577,6 +581,31 @@ interface GameContextValue {
*/
debugHardReset: ()=> Promise<void>;
/**
* Syncs any content added to the game after the player's save was created.
* @returns Counts of what was added per content type.
*/
syncNewContent: ()=> Promise<{
achievementsAdded: number;
achievementsPatched: number;
adventurerStatsPatched: number;
adventurersAdded: number;
bossRewardsPatched: number;
bossesAdded: number;
bossesPatched: number;
craftingRecipesReapplied: number;
equipmentAdded: number;
equipmentPatched: number;
explorationAreasAdded: number;
questRewardsPatched: number;
questsAdded: number;
questsPatched: number;
upgradesAdded: number;
upgradesPatched: number;
zonesAdded: number;
zonesPatched: number;
}>;
/**
* Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off.
@@ -671,6 +700,7 @@ export const GameProvider = ({
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ inGuild, setInGuild ] = useState(false);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string>
>([]);
@@ -708,6 +738,7 @@ export const GameProvider = ({
setSchemaOutdated(data.schemaOutdated);
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild);
// Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -1048,11 +1079,7 @@ export const GameProvider = ({
return q.status === "active";
});
if (!hasActiveQuest) {
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
const partyCombatPower = next.adventurers.reduce((total, a) => {
const power = total + a.combatPower;
return power * a.count;
}, 0);
const partyCombatPower = computePartyCombatPower(next);
const zoneOrder = new Map(
next.zones.map((z, index) => {
return [ z.id, index ];
@@ -1090,18 +1117,31 @@ export const GameProvider = ({
next.autoAdventurer === true
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
) {
const maxAdventurerLevel = Math.max(
...next.adventurers.
filter((a) => {
return a.unlocked;
}).
map((a) => {
return a.level;
}),
);
const autoBuyCap = 100;
const [ bestAdventurer ] = next.adventurers.
filter((adventurer) => {
const cost
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
return adventurer.unlocked && next.resources.gold >= cost;
const isMaxTier = adventurer.level === maxAdventurerLevel;
const withinCap
= isMaxTier || adventurer.count < autoBuyCap;
return (
adventurer.unlocked
&& next.resources.gold >= cost
&& withinCap
);
}).
sort((adventurerA, adventurerB) => {
const costA
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
const costB
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
return costB - costA;
return adventurerB.level - adventurerA.level;
});
if (bestAdventurer !== undefined) {
const purchaseCost
@@ -1151,14 +1191,6 @@ export const GameProvider = ({
},
);
// Quest failure — turn off auto-quest so the player can reassess
if (
newlyFailedQuestsReference.current.length > 0
&& next.autoQuest === true
) {
next = { ...next, autoQuest: false };
}
return next;
});
@@ -1296,7 +1328,26 @@ export const GameProvider = ({
if (availableBoss !== undefined) {
const { id: bossId, name: bossName } = availableBoss;
isAutoBossingReference.current = true;
void challengeBossApi({ bossId }).
const syncBeforeBoss
= stateReference.current !== null && !isSyncingReference.current
? saveGame({
state: stateReference.current,
...signatureReference.current === null
? {}
: { signature: signatureReference.current },
}).then((response) => {
if (response.signature !== undefined) {
signatureReference.current = response.signature;
localStorage.setItem(
"elysium_save_signature",
response.signature,
);
}
})
: Promise.resolve();
void syncBeforeBoss.then(async() => {
return await challengeBossApi({ bossId });
}).
then((result) => {
setState((previous) => {
if (previous === null) {
@@ -1309,6 +1360,13 @@ export const GameProvider = ({
}
return afterBoss;
});
/*
* Boss fight modifies server state; clear stale signature so
* the next pre-save or auto-save does not send a mismatched one.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
setAutoBossLastResult({
at: Date.now(),
bossName: bossName,
@@ -1323,11 +1381,13 @@ export const GameProvider = ({
/*
* "Boss is not currently available" is an expected race condition
* in the tick loop — suppress telemetry for this case only
* when the client is ahead of the server save — silently skip and
* let the next tick retry rather than halting automation.
*/
if (message !== "Boss is not currently available") {
logError("auto_boss", error_);
if (message === "Boss is not currently available") {
return;
}
logError("auto_boss", error_);
setAutoBossError(message);
setState((previous) => {
if (previous === null) {
@@ -1840,14 +1900,6 @@ export const GameProvider = ({
if (previous?.exploration === undefined) {
return previous;
}
let materials = [ ...previous.exploration.materials ];
for (const request of recipe.requiredMaterials) {
materials = materials.map((mat) => {
return mat.materialId === request.materialId
? { ...mat, quantity: mat.quantity - request.quantity }
: mat;
});
}
return {
...previous,
exploration: {
@@ -1860,7 +1912,7 @@ export const GameProvider = ({
...previous.exploration.craftedRecipeIds,
recipeId,
],
materials: materials,
materials: result.materials,
},
};
});
@@ -1917,15 +1969,6 @@ export const GameProvider = ({
});
}, []);
const updateAppearance = useCallback((appearance: Appearance) => {
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, appearance };
});
}, []);
const setActiveCompanion = useCallback((companionId: string | null) => {
setState((previous) => {
if (previous === null) {
@@ -2126,9 +2169,13 @@ export const GameProvider = ({
localStorage.setItem("elysium_save_signature", data.signature);
}
return {
adventurersUnlocked: data.adventurersUnlocked,
bossesUnlocked: data.bossesUnlocked,
equipmentUnlocked: data.equipmentUnlocked,
explorationUnlocked: data.explorationUnlocked,
questsUnlocked: data.questsUnlocked,
storyUnlocked: data.storyUnlocked,
upgradesUnlocked: data.upgradesUnlocked,
zonesUnlocked: data.zonesUnlocked,
};
} catch (error_: unknown) {
@@ -2138,14 +2185,75 @@ export const GameProvider = ({
: "Failed to force unlocks",
);
return {
adventurersUnlocked: 0,
bossesUnlocked: 0,
equipmentUnlocked: 0,
explorationUnlocked: 0,
questsUnlocked: 0,
storyUnlocked: 0,
upgradesUnlocked: 0,
zonesUnlocked: 0,
};
}
}, []);
const syncNewContent = useCallback(async() => {
try {
const data = await syncNewContentApi();
setState(data.state);
if (data.signature !== undefined) {
signatureReference.current = data.signature;
localStorage.setItem("elysium_save_signature", data.signature);
}
return {
achievementsAdded: data.achievementsAdded,
achievementsPatched: data.achievementsPatched,
adventurerStatsPatched: data.adventurerStatsPatched,
adventurersAdded: data.adventurersAdded,
bossRewardsPatched: data.bossRewardsPatched,
bossesAdded: data.bossesAdded,
bossesPatched: data.bossesPatched,
craftingRecipesReapplied: data.craftingRecipesReapplied,
equipmentAdded: data.equipmentAdded,
equipmentPatched: data.equipmentPatched,
explorationAreasAdded: data.explorationAreasAdded,
questRewardsPatched: data.questRewardsPatched,
questsAdded: data.questsAdded,
questsPatched: data.questsPatched,
upgradesAdded: data.upgradesAdded,
upgradesPatched: data.upgradesPatched,
zonesAdded: data.zonesAdded,
zonesPatched: data.zonesPatched,
};
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "Failed to sync new content",
);
return {
achievementsAdded: 0,
achievementsPatched: 0,
adventurerStatsPatched: 0,
adventurersAdded: 0,
bossRewardsPatched: 0,
bossesAdded: 0,
bossesPatched: 0,
craftingRecipesReapplied: 0,
equipmentAdded: 0,
equipmentPatched: 0,
explorationAreasAdded: 0,
questRewardsPatched: 0,
questsAdded: 0,
questsPatched: 0,
upgradesAdded: 0,
upgradesPatched: 0,
zonesAdded: 0,
zonesPatched: 0,
};
}
}, []);
const debugHardReset = useCallback(async() => {
setIsLoading(true);
setError(null);
@@ -2223,6 +2331,7 @@ export const GameProvider = ({
forceUnlocks,
formatNumber,
handleClick,
inGuild,
isLoading,
isSyncing,
lastSavedAt,
@@ -2246,6 +2355,7 @@ export const GameProvider = ({
startQuest,
state,
syncError,
syncNewContent,
toggleAutoAdventurer,
toggleAutoBoss,
toggleAutoPrestige,
@@ -2255,7 +2365,6 @@ export const GameProvider = ({
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
updateAppearance,
};
}, [
apotheosis,
@@ -2294,6 +2403,7 @@ export const GameProvider = ({
error,
flushBossLoreToasts,
forceSync,
inGuild,
forceUnlocks,
handleClick,
isLoading,
@@ -2319,6 +2429,7 @@ export const GameProvider = ({
startQuest,
state,
syncError,
syncNewContent,
toggleAutoAdventurer,
toggleAutoBoss,
toggleAutoPrestige,
@@ -2328,7 +2439,6 @@ export const GameProvider = ({
unlockedAchievements,
unlockedCodexEntryIds,
unlockedStoryChapterIds,
updateAppearance,
]);
return (
+4 -4
View File
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
{
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.",
id: "upgrade_mage_1",
sourceId: "mage_1",
id: "upgrade_apprentice_1",
sourceId: "apprentice_1",
sourceType: "upgrade",
title: "Arcane Tomes: The Written Knowledge",
zoneId: "guild_library",
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
{
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.",
id: "upgrade_cleric_1",
sourceId: "cleric_1",
id: "upgrade_acolyte_1",
sourceId: "acolyte_1",
sourceType: "upgrade",
title: "Holy Rites: The Sacred Routine",
zoneId: "guild_library",
+217
View File
@@ -123,6 +123,210 @@ const capResource = (value: number): number => {
return Math.min(value, RESOURCE_CAP);
};
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
* Returns a new GameState (does not mutate the original).
* @param state - The current game state.
* @param deltaSeconds - Time elapsed since last tick in seconds.
* @returns A new GameState with the tick applied.
*/
/**
* Computes the effective gold earned per second across all adventurers,
* including all active multipliers (upgrades, prestige, equipment, etc.).
* @param state - The current game state.
* @returns Gold per second as a number.
*/
export const computeGoldPerSecond = (state: GameState): number => {
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
return item.equipped;
});
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.goldMultiplier ?? 1);
}, 1);
const setGoldMultiplier = computeSetBonuses(
equippedItems.map((item) => {
return item.id;
}),
EQUIPMENT_SETS,
).goldMultiplier;
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionGoldMult
= companionBonus?.type === "passiveGold"
? 1 + companionBonus.value
: 1;
let goldPerSecond = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const contribution
= adventurer.goldPerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesIncome
* echoIncome
* equipmentGoldMultiplier
* setGoldMultiplier
* craftedGoldMultiplier
* companionGoldMult;
goldPerSecond = goldPerSecond + contribution;
}
return goldPerSecond;
};
/**
* Computes the current essence per second for the given game state,
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
* @param state - The current game state.
* @returns The total essence per second.
*/
export const computeEssencePerSecond = (state: GameState): number => {
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
let essencePerSecond = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const contribution
= adventurer.essencePerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
essencePerSecond = essencePerSecond + contribution;
}
return essencePerSecond;
};
/**
* Computes the party's total combat power, applying all active multipliers
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
* This mirrors the server-side calculatePartyStats in boss.ts.
* @param state - The current game state.
* @returns The total party combat power.
*/
export const computePartyCombatPower = (state: GameState): number => {
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier = globalMultiplier * upgrade.multiplier;
}
}
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const equipmentCombatMultiplier = state.equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
const equippedItemIds = state.equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
});
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
equippedItemIds,
EQUIPMENT_SETS,
);
const echoCombatMultiplier
= state.transcendence?.echoCombatMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
let partyCombatPower = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased
&& upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
}
}
const contribution
= adventurer.combatPower
* adventurer.count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyCombatPower = partyCombatPower + contribution;
}
return partyCombatPower
* equipmentCombatMultiplier
* setCombatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
};
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
@@ -397,6 +601,19 @@ export const applyTick = (
challengeCrystals = result.crystalsAwarded;
}
// Auto-unlock adventurer-specific upgrades when their adventurer is recruited
updatedUpgrades = updatedUpgrades.map((upgrade) => {
if (upgrade.unlocked || upgrade.adventurerId === undefined) {
return upgrade;
}
const adventurer = updatedAdventurers.find((a) => {
return a.id === upgrade.adventurerId;
});
return adventurer !== undefined && adventurer.count > 0
? { ...upgrade, unlocked: true }
: upgrade;
});
const goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence,
+124 -43
View File
@@ -116,6 +116,66 @@ body::before {
text-align: center;
}
/* ── Resource toggle + dropdown ─────────────────────────────────────────── */
.resource-menu {
position: relative;
}
.resource-toggle {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0.3rem 0.6rem;
position: relative;
transition: background 0.2s, border-color 0.2s;
}
.resource-toggle:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary);
}
.resource-alert-dot {
background: var(--colour-warning, #f59e0b);
border-radius: 50%;
height: 0.45rem;
position: absolute;
right: 0;
top: 0;
width: 0.45rem;
}
.resources-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 0.1rem;
left: 0;
padding: 0.4rem;
position: absolute;
top: calc(100% + 0.4rem);
z-index: 100;
}
.resources-dropdown .resource {
border-radius: 0.35rem;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
white-space: nowrap;
}
.resources-dropdown .resource:hover {
background: rgba(255, 255, 255, 0.04);
}
/* ===================== GAME LAYOUT ===================== */
.game-layout {
display: flex;
@@ -1492,57 +1552,87 @@ body::before {
font-size: 0.85rem;
}
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */
/* ── Resource bar actions (save + profile menu) ─────────────────────────── */
.profile-buttons {
.resource-bar-actions {
align-items: center;
display: flex;
gap: 0.35rem;
margin-left: auto;
}
.profile-link-button {
align-items: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 1rem;
color: var(--colour-text-muted);
display: flex;
font-size: 0.8rem;
gap: 0.3rem;
padding: 0.3rem 0.8rem;
text-decoration: none;
transition: all 0.2s;
white-space: nowrap;
.profile-menu {
position: relative;
}
.profile-link-button:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary);
color: var(--colour-text);
}
.profile-edit-button {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
.profile-avatar-button {
background: none;
border: 2px solid rgba(147, 51, 234, 0.4);
border-radius: 50%;
color: var(--colour-text-muted);
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
display: flex;
height: 2rem;
line-height: 1;
overflow: hidden;
padding: 0;
transition: all 0.2s;
transition: border-color 0.2s;
width: 2rem;
}
.profile-edit-button:hover {
background: rgba(147, 51, 234, 0.2);
.profile-avatar-button:hover {
border-color: var(--colour-primary);
}
.profile-avatar-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.profile-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
min-width: 10rem;
padding: 0.25rem;
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 100;
}
.profile-dropdown-item {
align-items: center;
background: none;
border: none;
border-radius: 0.35rem;
color: var(--colour-text-muted);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: 0.85rem;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
text-align: left;
text-decoration: none;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
width: 100%;
}
.profile-dropdown-item:hover {
background: rgba(147, 51, 234, 0.15);
color: var(--colour-text);
}
.profile-dropdown-divider {
border: none;
border-top: 1px solid rgba(147, 51, 234, 0.2);
margin: 0.25rem 0;
}
.save-status {
color: var(--colour-text-muted);
font-size: 0.75rem;
@@ -3167,10 +3257,10 @@ body::before {
display: none;
}
/* Profile buttons fill their own row, aligned right */
.profile-buttons {
margin-left: 0;
/* Resource bar actions fill their own row, aligned right */
.resource-bar-actions {
justify-content: flex-end;
margin-left: 0;
width: 100%;
}
@@ -3240,15 +3330,6 @@ body::before {
/* --- Small mobile (≤ 480px) --------------------------- */
@media (max-width: 480px) {
/* Icon-only profile link buttons to save horizontal space */
.btn-label {
display: none;
}
.profile-link-button {
padding: 0.3rem 0.5rem;
}
/* Slightly smaller tab buttons */
.tab-button {
font-size: 0.8rem;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "elysium",
"version": "0.1.2",
"version": "0.3.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.1.2",
"version": "0.3.2",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+2 -9
View File
@@ -5,15 +5,6 @@
* @author Naomi Carrigan
*/
export type { ApotheosisData } from "./interfaces/apotheosis.js";
export type {
Accessory,
Appearance,
HairColour,
HairStyle,
Outfit,
SkinTone,
} from "./interfaces/appearance.js";
export { defaultAppearance } from "./interfaces/appearance.js";
export type {
Companion,
CompanionBonus,
@@ -64,6 +55,7 @@ export type {
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
@@ -81,6 +73,7 @@ export type {
PublicProfileResponse,
SaveRequest,
SaveResponse,
SyncNewContentResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
+136
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- API types file grows with each new endpoint */
import type {
EquipmentBonus,
EquipmentRarity,
@@ -69,6 +70,11 @@ interface LoginBonusResult {
interface LoadResponse {
state: GameState;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/**
* Offline gold earned since last save (server-calculated).
*/
@@ -384,6 +390,10 @@ interface ExploreCollectResponse {
event: ExploreCollectEventResult | null;
}
interface ExploreClaimableResponse {
claimable: boolean;
}
interface CraftRecipeRequest {
recipeId: string;
}
@@ -396,6 +406,7 @@ interface CraftRecipeResponse {
craftedEssenceMultiplier: number;
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
}
interface ForceUnlocksResponse {
@@ -425,12 +436,135 @@ interface ForceUnlocksResponse {
*/
explorationUnlocked: number;
/**
* Number of adventurer tiers that were unlocked by this operation.
*/
adventurersUnlocked: number;
/**
* Number of upgrades that were unlocked by this operation.
*/
upgradesUnlocked: number;
/**
* Number of equipment items that were marked as owned by this operation.
*/
equipmentUnlocked: number;
/**
* Number of story chapters that were unlocked by this operation.
*/
storyUnlocked: number;
/**
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
*/
signature?: string;
}
interface SyncNewContentResponse {
/**
* The updated game state after injecting all missing content entries.
*/
state: GameState;
/**
* Number of adventurer tiers added to the save.
*/
adventurersAdded: number;
/**
* Number of existing adventurer entries whose stats were patched to match current defaults.
*/
adventurerStatsPatched: number;
/**
* Number of upgrades added to the save.
*/
upgradesAdded: number;
/**
* Number of rewards patched onto existing quests.
*/
questRewardsPatched: number;
/**
* Number of quests added to the save.
*/
questsAdded: number;
/**
* Number of bosses added to the save.
*/
bossesAdded: number;
/**
* Number of upgrade reward IDs patched onto existing bosses.
*/
bossRewardsPatched: number;
/**
* Number of equipment items added to the save.
*/
equipmentAdded: number;
/**
* Number of achievements added to the save.
*/
achievementsAdded: number;
/**
* Number of zones added to the save.
*/
zonesAdded: number;
/**
* Number of exploration areas added to the save.
*/
explorationAreasAdded: number;
/**
* Number of achievements whose stats were updated to match current defaults.
*/
achievementsPatched: number;
/**
* Number of bosses whose stats were updated to match current defaults.
*/
bossesPatched: number;
/**
* Number of crafted recipes whose multiplier contribution was reapplied during recompute.
*/
craftingRecipesReapplied: number;
/**
* Number of equipment items whose stats were updated to match current defaults.
*/
equipmentPatched: number;
/**
* Number of quests whose stats were updated to match current defaults.
*/
questsPatched: number;
/**
* Number of upgrades whose stats were updated to match current defaults.
*/
upgradesPatched: number;
/**
* Number of zones whose stats were updated to match current defaults.
*/
zonesPatched: number;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
}
export type {
AboutResponse,
ApiError,
@@ -445,6 +579,7 @@ export type {
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
@@ -462,6 +597,7 @@ export type {
PublicProfileResponse,
SaveRequest,
SaveResponse,
SyncNewContentResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
@@ -1,50 +0,0 @@
/**
* @file Appearance type for the paper doll customisation system.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Hikari
*/
type SkinTone = "pale" | "light" | "tan" | "medium" | "dark";
type HairStyle =
| "short"
| "shoulder"
| "long"
| "ponytail"
| "twintails"
| "bun";
type HairColour =
| "brown"
| "black"
| "blonde"
| "red"
| "auburn"
| "silver"
| "blue"
| "purple"
| "pink";
type Outfit = "warrior" | "mage" | "rogue" | "archer" | "bard" | "ranger";
type Accessory = "none" | "glasses" | "hat" | "cape";
interface Appearance {
skinTone: SkinTone;
hairStyle: HairStyle;
hairColour: HairColour;
outfit: Outfit;
accessory: Accessory;
}
const defaultAppearance: Appearance = {
accessory: "none",
hairColour: "brown",
hairStyle: "short",
outfit: "warrior",
skinTone: "pale",
};
export type { Accessory, Appearance, HairColour, HairStyle, Outfit, SkinTone };
export { defaultAppearance };
@@ -7,7 +7,6 @@
import type { Achievement } from "./achievement.js";
import type { Adventurer } from "./adventurer.js";
import type { ApotheosisData } from "./apotheosis.js";
import type { Appearance } from "./appearance.js";
import type { Boss } from "./boss.js";
import type { CodexState } from "./codex.js";
import type { CompanionState } from "./companion.js";
@@ -99,12 +98,6 @@ interface GameState {
* Schema version used to detect saves from older game versions.
*/
schemaVersion?: number;
/**
* Paper doll appearance customisation optional for backwards compatibility.
* Persists across prestige and transcendence resets.
*/
appearance?: Appearance;
}
export type { GameState };