34 Commits

Author SHA1 Message Date
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
hikari 81ae1f18e1 chore: clarify equipment combat bonus applies to boss fights only (#83)
CI / Lint, Build & Test (push) Successful in 1m4s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m34s
## Summary

Resolves player confusion about equipment combat bonuses not affecting quest combat power. The behaviour is by design — combat multipliers only apply to boss DPS — but this was never communicated anywhere.

- **Equipment bonus labels** now read `+X% Boss Combat` instead of `+X% Combat` (both individual items and set bonuses)
- **About panel** — both equipment entries updated to explicitly state that combat bonuses only affect boss fights, and that quest combat power is determined solely by adventurers

No game logic changed.

Closes #81

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #83
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 19:02:24 -07:00
hikari 0057cfeaaa feat: communicate quest failure mechanics in the UI (#82)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

Addresses recurring community confusion about quests failing — multiple players asked whether it was a bug or intended behaviour with no in-game explanation.

- **Exports `zoneFailureChance`** from `tick.ts` so the quest panel can read it
- **Quest cards** now show a `🎲 X% failure chance` note on all available quests, with a brief explanation that a failure resets the quest with no rewards
- **"Last attempt failed" hint** now reads `"⚠️ Last attempt failed — no rewards were granted."` so players understand the consequence immediately
- **About panel** updated to document the failure mechanic, including the 10%–40% range across zones

Closes #80

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #82
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 17:57:17 -07:00
hikari 161127dc21 chore: audit frontend error reporting to exclude expected behaviours (#79)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

Audits all `logError` call sites in `gameContext.tsx` and suppresses telemetry for expected business logic rejections, eliminating alert fatigue without hiding real errors.

### Changes per call site

| Context | Before | After |
|---|---|---|
| `auto_save` | Logged all non-signature errors | Network failures silently swallowed — next tick retries |
| `auto_prestige` | Logged eligibility failures | Silently ignored — eligibility re-checked every tick |
| `auto_boss` | Logged all errors | Filters out `"Boss is not currently available"` (race condition); other errors still logged |
| `challenge_boss` | Logged all errors | Filters out `"Boss is not currently available"` (race condition); other errors still logged |
| `start_exploration` | Logged then rethrew | Removed useless try/catch — error propagates to UI naturally |
| `collect_exploration` | Logged then rethrew | Removed useless try/catch — error propagates to UI naturally |

Genuine errors (`buy_prestige_upgrade`, `transcend`, `apotheosis`, `buy_echo_upgrade`, `craft_recipe`) are unchanged — they still fire telemetry.

Closes #73

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #79
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 16:01:22 -07:00
hikari a8a465f293 feat: display leaderboard update frequency in the UI (#78)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m14s
## Summary

Adds a note below the leaderboard subtitle informing players that rankings update when they prestige. This addresses a recurring community question from `tau.deusmortis` and `minjo70`.

Closes #63

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #78
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 15:44:14 -07:00
hikari 79c4b99e8a feat: add essence infusion upgrades as late-prestige essence sink (#77)
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 11s
CI / Lint, Build & Test (push) Successful in 1m2s
## Summary

Closes #62

Adds five **Essence Infusion** upgrades (I–V) to give essence an ongoing meaningful use deep into a prestige run, when gold upgrades are all purchased and essence reserves are in the trillions with nowhere to go:

| Upgrade | Cost | Multiplier |
|---|---|---|
| Essence Infusion I | 1T essence | ×2 global |
| Essence Infusion II | 5T essence | ×2 global |
| Essence Infusion III | 25T essence | ×2 global |
| Essence Infusion IV | 100T essence | ×3 global |
| Essence Infusion V | 500T essence | ×5 global |

All five start `unlocked: true` (no prerequisite boss or quest required) and cost zero gold and zero crystals — they are purely essence sinks. Combined, they provide a ×120 global income multiplier for players willing to pour their essence reserves into the guild. The About panel's Upgrades section is also updated to inform players these exist.

CDN art assets will need to be generated for IDs `essence_sink_1` through `essence_sink_5` in the `upgrades` folder.

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #77
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 15:39:04 -07:00
hikari 3d114f63d7 feat: post-prestige automation (auto-adventurer) (#76)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

Closes #61

- Adds the **Autonomous Recruitment** prestige upgrade (50 runestones) to both the API and web data files
- Adds `autoAdventurer?: boolean` to the `GameState` type for backwards-compatible saves
- Adds tick-loop logic in GameContext that automatically purchases the highest-tier unlocked adventurer the player can afford each frame when the toggle is enabled
- Adds `toggleAutoAdventurer` callback and exposes it through the context
- Adds toggle UI in the Prestige Shop (mirrors the existing Auto-Prestige toggle pattern)
- Updates the How to Play guide in the About panel to document the new automation feature

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #76
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 13:38:25 -07:00
hikari 911e089a9e feat: document upgrade stacking behaviour as multiplicative (#75)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

- Adds a `💡` stacking note directly in the upgrade panel below the progress counter so players see it without visiting the About page
- Updates the About panel's Upgrades how-to-play entry to replace the vague "compound with each other" with explicit multiplicative stacking language, including an example (two ×2 upgrades = ×4) and a note that global upgrades multiply on top of adventurer-specific ones

## Test plan

- [ ] Verify the stacking note appears in the upgrade panel below the progress counter
- [ ] Verify the About panel Upgrades entry reflects the updated wording
- [ ] Confirm lint, build, and tests all pass

Closes #60

Reviewed-on: #75
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 13:24:45 -07:00
hikari 14de87d765 feat: communicate exploration zone unlock conditions in-game (#74)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

- Locked exploration zones now show a `🔒 This zone is locked. Unlock exploration by:` hint above the area list, with the specific `⚔️ Defeat: {boss}` and `📜 Complete: {quest}` required
- Updated the About panel's Exploration how-to-play entry to document the zone unlock rule explicitly
- No new data required — unlock conditions are read directly from `zone.unlockBossId` and `zone.unlockQuestId` already in state

## Test plan

- [ ] Verify locked exploration zones display the correct boss and quest unlock hints
- [ ] Verify already-unlocked zones show no hint
- [ ] Verify starter zone (no unlock conditions) shows no hint
- [ ] Verify the About panel Exploration entry reflects the updated description
- [ ] Confirm lint, build, and tests all pass

Closes #59

Reviewed-on: #74
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 12:18:45 -07:00
hikari c4b4fba4c9 feat: display current party combat power as a persistent stat (#72)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Adds a `⚔️ Combat Power` entry to the always-visible resource bar
- Value is computed client-side as the sum of each adventurer's `combatPower × count`
- No new props required — computed directly from `state` via the existing `useGame()` hook
- Players can now see their combat strength at a glance before attempting boss fights or quests

## Test plan

- [ ] Verify the Combat Power stat appears in the resource bar
- [ ] Verify the value increases as more adventurers are recruited
- [ ] Verify the value displays correctly with `formatNumber` for large numbers
- [ ] Confirm lint, build, and tests all pass

Closes #58

Reviewed-on: #72
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 11:55:57 -07:00
hikari d723656743 feat: show progress toward unlock conditions on achievement cards (#71)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m13s
CI / Lint, Build & Test (push) Successful in 1m15s
## Summary

- Adds a `getCurrentProgress` helper that mirrors the tick engine's achievement-checking logic to compute the player's current progress for each condition type
- Locked achievement cards now display a `<progress>` bar and a numeric `{current} / {target}` label so players can see exactly how close they are to each achievement
- Unlocked achievements are unaffected — no progress bar shown once earned

## Test plan

- [ ] Verify locked achievement cards display a progress bar and numeric label
- [ ] Verify the progress values match what the tick engine uses for unlock checking
- [ ] Verify unlocked achievement cards show no progress bar
- [ ] Confirm lint, build, and tests all pass

Closes #57

Reviewed-on: #71
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 11:45:36 -07:00
hikari 7e10757e68 feat: show affected adventurer name on upgrade cards (#70)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m7s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

- Adds a `🗡️ Affects: {Name}` label to upgrade cards that target a specific adventurer
- Resolves player confusion caused by class-based language (e.g. "doubles cleric output") without specifying which adventurer tiers count as that class
- Label appears in all three card states: available, purchased, and locked

## Test plan

- [ ] Verify adventurer-targeted upgrade cards display the correct adventurer name
- [ ] Verify global, click, boss, and prestige upgrade cards show no affects label
- [ ] Confirm lint, build, and tests all pass

Closes #56

Reviewed-on: #70
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 11:29:29 -07:00
hikari ca2edb090e fix: correct equipment balance and sort items by stat power (#69)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

Two improvements to the equipment system in one PR:

### Balance fixes (closes #54)

Full equipment audit revealed 9 items with duplicated stats, regressions, or purchasable items weaker than free boss drops:

| Item | Change | Reason |
|---|---|---|
| Void Conduit | 4x → 7x combat | 100M essence sink was equal to a zone-6 boss drop |
| Void Edge | 2.75x → 3.25x combat | Purchasable was weaker than free Celestial Blade (3x) |
| Astral Robe | 2.25x → 2.75x gold | Boss drop was weaker than purchasable Titan's Aegis (2.5x) |
| Philosopher's Stone | 2x → 2.25x click | Duplicated Frost Crystal's click multiplier |
| Eternal Flame | 1.15x → 1.25x gold | Gold regressed vs Philosopher's Stone (1.25x) |
| Celestial Focus | 2.5x → 3x click | 20M essence sink was weaker than free Angel's Halo (2.75x click + 1.3x gold) |
| Abyssal Tome | 3x → 3.75x gold | 50M essence sink was equal to free Heaven's Mantle (3x) |
| Crystal Matrix | 4x → 4.75x gold | 20M crystal sink was equal to free Sinslayer Aegis (4x) |
| Infernal Gem | 3.5x → 4x click | 5M crystal sink was identical to free Prism Eye |

### Equipment sorting (closes #55)

Equipment cards within each slot now render in ascending order of combined bonus power — the sum of all multiplier bonuses — so stronger items always appear further down the list. Hybrid items such as Volcanic Plate sort correctly without needing a per-slot primary stat.

## Test plan

- [ ] All purchasable weapons/armour/trinkets now exceed the stats of the highest free boss drop at their tier
- [ ] No duplicate stat values between adjacent items in the same progression track
- [ ] Equipment cards within each slot render weakest → strongest
- [ ] Hybrid multi-stat items sort sensibly alongside single-stat items
- [ ] Full pipeline green (lint + build + tests at 100% coverage)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #69
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 08:51:08 -07:00
hikari cfcf763ce3 fix: use server-computed endsAt for exploration timer to prevent clock drift (#68)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Exploration timers showed more time than the area's stated duration when the server clock was ahead of the client's
- The timer was derived from `startedAt = endsAt - durationMs`, then computed as `durationSeconds - (clientNow - startedAt) / 1000` — any server/client clock skew directly inflated the result
- Now stores `endsAt` (the server-computed completion timestamp) directly in `ExplorationAreaState` and computes the timer as `(endsAt - Date.now()) / 1000`, which is immune to clock drift
- Old saves without `endsAt` fall back gracefully to the previous `startedAt`-based calculation

## Test plan

- [ ] Start a new exploration — timer should show exactly the area's stated duration (no more "1h area shows 1h15m")
- [ ] Refresh the page mid-exploration — timer should resume from the correct remaining time (using server-anchored `endsAt`)
- [ ] Old saves with `startedAt` but no `endsAt` should still display a timer via the fallback path

Closes #53

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #68
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-18 17:09:48 -07:00
hikari aede55a13d fix: preserve runestone bounty flag for legacy defeated bosses (#67)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

- Bosses defeated before `bountyRunestonesClaimed` was introduced had `status: "defeated"` but the field `undefined`
- After prestige, the preservation check (`=== true`) missed these bosses, so the first-kill bounty was re-awarded on the next run
- Now also treats `status === "defeated"` as proof the bounty was already earned, covering the migration case

## Test plan

- [ ] Existing test: `preserves bountyRunestonesClaimed flag on bosses across prestige` — still passes
- [ ] New test: `sets bountyRunestonesClaimed on bosses defeated before the flag was introduced` — covers the legacy save migration path
- [ ] Full coverage maintained at 100%

Closes #52

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #67
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-18 13:50:20 -07:00
hikari 744cbf121f fix: preserve autoQuest and autoBoss settings across prestige (#66)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m9s
## Summary

- `buildPostPrestigeState` was constructing the post-prestige `GameState` from `initialGameState`, which hard-codes `autoQuest` and `autoBoss` to `false`
- Neither flag was being carried forward, so both automation settings silently reset after every prestige
- Now both values are explicitly preserved from `currentState` (with `?? false` fallback for safety)

Closes #51

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #66
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-18 13:26:18 -07:00
hikari 03b6c847b3 feat: debug panel with force unlocks and hard reset (#65)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary
- Adds a new **Debug** tab to the game UI with two self-service tools for players with broken save state
- **Force Unlocks**: scans the player's save and grants any zones, quests, bosses, and exploration areas they've earned but that are still locked — shows a breakdown of what was unlocked (or reports nothing needed fixing)
- **Hard Reset**: wipes progress back to a fresh save (preserving lifetime stats), guarded behind a confirmation modal to prevent accidental clicks

## Files added
- `apps/api/src/routes/debug.ts` — two POST endpoints (`/force-unlocks`, `/hard-reset`)
- `apps/web/src/components/game/debugPanel.tsx` — the Debug tab UI
- `apps/web/src/components/ui/confirmationModal.tsx` — reusable confirmation modal

## Files modified
- `apps/api/src/index.ts` — registers the debug router
- `packages/types/src/interfaces/api.ts` — adds `ForceUnlocksResponse` type
- `packages/types/src/index.ts` — exports the new type
- `apps/web/src/api/client.ts` — adds `forceUnlocks()` and `debugHardReset()` API calls
- `apps/web/src/context/gameContext.tsx` — wires both functions into game context
- `apps/web/src/components/game/gameLayout.tsx` — adds the Debug tab
- `apps/web/src/styles.css` — styles for action buttons, cards, result messages, and confirmation modal

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #65
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-18 12:37:06 -07:00
hikari 219d299e9f fix: force sync before boss fight and surface challenge errors to UI (#64)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m8s
Closes #50

## Summary
- Calls `forceSync()` before every boss challenge so the server always fights against the player's live state (equipped items, upgrades, etc.) rather than a potentially stale snapshot
- Adds a `bossError` state that captures and displays error messages from failed manual boss challenges in the boss panel, matching the existing `autoBossError` display pattern

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #64
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-18 11:26:12 -07:00
minori 9e5b8ed972 deps: update typescript to 5.9.3 (#32)
CI / Lint, Build & Test (push) Successful in 1m13s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m12s
## Dependency Update

Updates **typescript** from `5.8.2` to `5.9.3`.

### Type
devDependencies

### Changelog
## Changelog

### v5.9.3

Note: this tag was recreated to point at the correct commit. The npm package contained the correct content.

For release notes, check out the [release announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-5-9/)

* [fixed issues query for Typescript 5.9.0 (Beta)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.0%22+is%3Aclosed+).
* [fixed issues query for Typescript 5.9.1 (RC)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.1%22+is%3Aclosed+).
* *No specific changes for TypeScript 5.9.2 (Stable)*
* [fixed issues query for Typescript 5.9.3 (Stable)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.3%22+is%3Aclosed+).

Downloads are available on:
* [npm](https://www.npmjs.com/package/typescript)

### v5.9.2

Note: this tag was recreated to point at the correct commit. The npm package contained the correct content.

For release notes, check out the [release announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-5-9/)

* [fixed issues query for Typescript 5.9.0 (Beta)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.0%22+is%3Aclosed+).
* [fixed issues query for Typescript 5.9.1 (RC)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.1%22+is%3Aclosed+).
* *No specific changes for TypeScript 5.9.2 (Stable)*

Downloads are available on:
* [npm](https://www.npmjs.com/package/typescript)

### v5.9-rc

Note: this tag was recreated to point at the correct commit. The npm package contained the correct content.

For release notes, check out the [release announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-5-9-rc/)

* [fixed issues query for Typescript 5.9.0 (Beta)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.0%22+is%3Aclosed+).
* [fixed issues query for Typescript 5.9.1 (RC)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.1%22+is%3Aclosed+).

Downloads are available on:
* [npm](https://www.npmjs.com/package/typescript)

### v5.9-beta

Note: this tag was recreated to point at the correct commit. The npm package contained the correct content.

For release notes, check out the [release announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-5-9-beta/).

* [fixed issues query for Typescript 5.9.0 (Beta)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.9.0%22+is%3Aclosed+).

Downloads are available on:
* [npm](https://www.npmjs.com/package/typescript)

### v5.8.3

Note: this tag was recreated to point at the correct commit. The npm package contained the correct content.

For release notes, check out the [release announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-5-8/).

* [fixed issues query for Typescript 5.8.0 (Beta)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.8.0%22+is%3Aclosed+).
* [fixed issues query for Typescript 5.8.1 (RC)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.8.1%22+is%3Aclosed+).
* [fixed issues query for Typescript 5.8.2 (Stable)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.8.2%22+is%3Aclosed+).
* [fixed issues query for Typescript 5.8.3 (Stable)](https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&q=milestone%3A%22TypeScript+5.8.3%22+is%3Aclosed+).

Downloads are available on:
* [npm](https://www.npmjs.com/package/typescript)

---
 This PR was created by Minori, your friendly dependency updater! 🌸

Reviewed-on: #32
Co-authored-by: Minori <minori@nhcarrigan.com>
Co-committed-by: Minori <minori@nhcarrigan.com>
2026-03-18 11:21:07 -07:00
naomi a20cf3ef87 release: v0.1.2
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m10s
2026-03-09 22:26:15 -07:00
48 changed files with 3823 additions and 515 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/
### Process ### Process
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style 1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg` 2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
3. Upload to R2 with: `AWS_ACCESS_KEY_ID=dd0a3d73969143ada84d50f8940cc5e2 AWS_SECRET_ACCESS_KEY=f73e9907da1b2297e93e17f786d6446d33d4ac60e185879578a0d5020899b18e aws s3 sync img/ s3://nhcarrigan-cdn/elysium/ --endpoint-url https://751c386661d378cc032093493cfb0869.r2.cloudflarestorage.com` 3. Upload to R2 with the AWS CLI — credentials are in the global `~/.claude/CLAUDE.md` (never commit them here)
4. Delete the local `img/` directory before committing (images live on CDN only) 4. Delete the local `img/` directory before committing (images live on CDN only)
### CDN URL Helper ### CDN URL Helper
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.1.1", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+7 -7
View File
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
{ {
condition: { amount: 18, type: "bossesDefeated" }, condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat all 18 bosses, including the Devourer of Worlds.", description: "Defeat all 18 bosses across the first six zones.",
icon: "🌟", icon: "🌟",
id: "devourer_slayer", id: "devourer_slayer",
name: "World Saver", name: "World Saver",
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 40, type: "equipmentOwned" }, condition: { amount: 65, type: "equipmentOwned" },
description: "Own 40 pieces of equipment.", description: "Own all 65 pieces of equipment.",
icon: "🛡️", icon: "🛡️",
id: "fully_equipped", id: "fully_equipped",
name: "Fully Equipped", name: "Fully Equipped",
@@ -289,8 +289,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 72, type: "questsCompleted" }, condition: { amount: 95, type: "questsCompleted" },
description: "Complete all 72 quests across the known multiverse.", description: "Complete all 95 quests across the known multiverse.",
icon: "🌌", icon: "🌌",
id: "quest_eternal", id: "quest_eternal",
name: "Quest Eternal", name: "Quest Eternal",
@@ -317,8 +317,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 60, type: "bossesDefeated" }, condition: { amount: 72, type: "bossesDefeated" },
description: "Defeat all 60 bosses across every plane of existence.", description: "Defeat all 72 bosses across every plane of existence.",
icon: "💀", icon: "💀",
id: "boss_eternal", id: "boss_eternal",
name: "Eternal Vanquisher", name: "Eternal Vanquisher",
+45 -33
View File
@@ -129,27 +129,39 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 4_000_000_000, baseCost: 2_600_000_000,
class: "rogue", class: "mage",
combatPower: 18_000, combatPower: 13_000,
count: 0, count: 0,
essencePerSecond: 6, essencePerSecond: 6,
goldPerSecond: 5000, goldPerSecond: 4500,
id: "shadow_assassin", id: "arcane_scholar",
level: 11, level: 11,
name: "Arcane Scholar",
unlocked: false,
},
{
baseCost: 11_000_000_000,
class: "rogue",
combatPower: 28_000,
count: 0,
essencePerSecond: 11,
goldPerSecond: 9500,
id: "shadow_assassin",
level: 12,
name: "Shadow Assassin", name: "Shadow Assassin",
unlocked: false, unlocked: false,
}, },
{ {
baseCost: 28_000_000_000, baseCost: 47_000_000_000,
class: "mage", class: "paladin",
combatPower: 45_000, combatPower: 60_000,
count: 0, count: 0,
essencePerSecond: 15, essencePerSecond: 20,
goldPerSecond: 14_000, goldPerSecond: 20_000,
id: "arcane_scholar", id: "dark_templar",
level: 12, level: 13,
name: "Arcane Scholar", name: "Dark Templar",
unlocked: false, unlocked: false,
}, },
{ {
@@ -160,7 +172,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35, essencePerSecond: 35,
goldPerSecond: 40_000, goldPerSecond: 40_000,
id: "void_walker", id: "void_walker",
level: 13, level: 14,
name: "Void Walker", name: "Void Walker",
unlocked: false, unlocked: false,
}, },
@@ -172,7 +184,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100, essencePerSecond: 100,
goldPerSecond: 120_000, goldPerSecond: 120_000,
id: "celestial_guard", id: "celestial_guard",
level: 14, level: 15,
name: "Celestial Guard", name: "Celestial Guard",
unlocked: false, unlocked: false,
}, },
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300, essencePerSecond: 300,
goldPerSecond: 400_000, goldPerSecond: 400_000,
id: "divine_champion", id: "divine_champion",
level: 15, level: 16,
name: "Divine Champion", name: "Divine Champion",
unlocked: false, unlocked: false,
}, },
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 800, essencePerSecond: 800,
goldPerSecond: 1_200_000, goldPerSecond: 1_200_000,
id: "seraph_knight", id: "seraph_knight",
level: 16, level: 17,
name: "Seraph Knight", name: "Seraph Knight",
unlocked: false, unlocked: false,
}, },
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2000, essencePerSecond: 2000,
goldPerSecond: 3_500_000, goldPerSecond: 3_500_000,
id: "abyss_diver", id: "abyss_diver",
level: 17, level: 18,
name: "Abyss Diver", name: "Abyss Diver",
unlocked: false, unlocked: false,
}, },
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 5000, essencePerSecond: 5000,
goldPerSecond: 10_000_000, goldPerSecond: 10_000_000,
id: "infernal_warden", id: "infernal_warden",
level: 18, level: 19,
name: "Infernal Warden", name: "Infernal Warden",
unlocked: false, unlocked: false,
}, },
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000, essencePerSecond: 12_000,
goldPerSecond: 30_000_000, goldPerSecond: 30_000_000,
id: "crystal_sage", id: "crystal_sage",
level: 19, level: 20,
name: "Crystal Sage", name: "Crystal Sage",
unlocked: false, unlocked: false,
}, },
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 30_000, essencePerSecond: 30_000,
goldPerSecond: 90_000_000, goldPerSecond: 90_000_000,
id: "void_sentinel", id: "void_sentinel",
level: 20, level: 21,
name: "Void Sentinel", name: "Void Sentinel",
unlocked: false, unlocked: false,
}, },
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 80_000, essencePerSecond: 80_000,
goldPerSecond: 270_000_000, goldPerSecond: 270_000_000,
id: "eternal_champion", id: "eternal_champion",
level: 21, level: 22,
name: "Eternal Champion", name: "Eternal Champion",
unlocked: false, unlocked: false,
}, },
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 220_000, essencePerSecond: 220_000,
goldPerSecond: 800_000_000, goldPerSecond: 800_000_000,
id: "aether_weaver", id: "aether_weaver",
level: 22, level: 23,
name: "Aether Weaver", name: "Aether Weaver",
unlocked: false, unlocked: false,
}, },
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 600_000, essencePerSecond: 600_000,
goldPerSecond: 2_500_000_000, goldPerSecond: 2_500_000_000,
id: "titan_warrior", id: "titan_warrior",
level: 23, level: 24,
name: "Titan Warrior", name: "Titan Warrior",
unlocked: false, unlocked: false,
}, },
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 1_600_000, essencePerSecond: 1_600_000,
goldPerSecond: 7_500_000_000, goldPerSecond: 7_500_000_000,
id: "nexus_sage", id: "nexus_sage",
level: 24, level: 25,
name: "Nexus Sage", name: "Nexus Sage",
unlocked: false, unlocked: false,
}, },
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 4_500_000, essencePerSecond: 4_500_000,
goldPerSecond: 22_000_000_000, goldPerSecond: 22_000_000_000,
id: "cosmos_knight", id: "cosmos_knight",
level: 25, level: 26,
name: "Cosmos Knight", name: "Cosmos Knight",
unlocked: false, unlocked: false,
}, },
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000_000, essencePerSecond: 12_000_000,
goldPerSecond: 65_000_000_000, goldPerSecond: 65_000_000_000,
id: "astral_sovereign", id: "astral_sovereign",
level: 26, level: 27,
name: "Astral Sovereign", name: "Astral Sovereign",
unlocked: false, unlocked: false,
}, },
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35_000_000, essencePerSecond: 35_000_000,
goldPerSecond: 200_000_000_000, goldPerSecond: 200_000_000_000,
id: "primordial_mage", id: "primordial_mage",
level: 27, level: 28,
name: "Primordial Mage", name: "Primordial Mage",
unlocked: false, unlocked: false,
}, },
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100_000_000, essencePerSecond: 100_000_000,
goldPerSecond: 600_000_000_000, goldPerSecond: 600_000_000_000,
id: "reality_warden", id: "reality_warden",
level: 28, level: 29,
name: "Reality Warden", name: "Reality Warden",
unlocked: false, unlocked: false,
}, },
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300_000_000, essencePerSecond: 300_000_000,
goldPerSecond: 1_800_000_000_000, goldPerSecond: 1_800_000_000_000,
id: "infinity_ranger", id: "infinity_ranger",
level: 29, level: 30,
name: "Infinity Ranger", name: "Infinity Ranger",
unlocked: false, unlocked: false,
}, },
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 850_000_000, essencePerSecond: 850_000_000,
goldPerSecond: 5_500_000_000_000, goldPerSecond: 5_500_000_000_000,
id: "oblivion_paladin", id: "oblivion_paladin",
level: 30, level: 31,
name: "Oblivion Paladin", name: "Oblivion Paladin",
unlocked: false, unlocked: false,
}, },
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2_500_000_000, essencePerSecond: 2_500_000_000,
goldPerSecond: 16_000_000_000_000, goldPerSecond: 16_000_000_000_000,
id: "transcendent_rogue", id: "transcendent_rogue",
level: 31, level: 32,
name: "Transcendent Rogue", name: "Transcendent Rogue",
unlocked: false, unlocked: false,
}, },
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 7_000_000_000, essencePerSecond: 7_000_000_000,
goldPerSecond: 50_000_000_000_000, goldPerSecond: 50_000_000_000_000,
id: "omniversal_champion", id: "omniversal_champion",
level: 32, level: 33,
name: "Omniversal Champion", name: "Omniversal Champion",
unlocked: false, unlocked: false,
}, },
+46 -46
View File
@@ -121,17 +121,17 @@ export const defaultBosses: Array<Boss> = [
}, },
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
bountyRunestones: 5, bountyRunestones: 20,
crystalReward: 30, crystalReward: 700,
currentHp: 80_000, currentHp: 6_000_000,
damagePerSecond: 80, damagePerSecond: 1200,
description: description:
"She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.", "She has hexed villages for three centuries from her hut on the black water. Her curse-weaving is second to none — but so is the bounty on her head.",
equipmentRewards: [], equipmentRewards: [],
essenceReward: 800, essenceReward: 30_000,
goldReward: 800_000, goldReward: 80_000_000,
id: "swamp_witch", id: "swamp_witch",
maxHp: 80_000, maxHp: 6_000_000,
name: "Morgantha the Swamp Witch", name: "Morgantha the Swamp Witch",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -139,17 +139,17 @@ export const defaultBosses: Array<Boss> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
bountyRunestones: 8, bountyRunestones: 25,
crystalReward: 80, crystalReward: 1500,
currentHp: 300_000, currentHp: 12_000_000,
damagePerSecond: 180, damagePerSecond: 2400,
description: description:
"A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.", "A bloated, rotting horror that spreads pestilence wherever it walks. Entire kingdoms have fallen to the disease it carries. Yours will not.",
equipmentRewards: [ "runestone_amulet" ], equipmentRewards: [ "runestone_amulet" ],
essenceReward: 2000, essenceReward: 60_000,
goldReward: 3_000_000, goldReward: 180_000_000,
id: "plague_lord", id: "plague_lord",
maxHp: 300_000, maxHp: 12_000_000,
name: "The Plague Lord", name: "The Plague Lord",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -157,17 +157,17 @@ export const defaultBosses: Array<Boss> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
bountyRunestones: 10, bountyRunestones: 30,
crystalReward: 150, crystalReward: 3000,
currentHp: 800_000, currentHp: 20_000_000,
damagePerSecond: 350, damagePerSecond: 4000,
description: description:
"An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.", "An eldritch leviathan that lurks in the deepest part of the marshes, older than the gods themselves. Its tentacles have dragged ships — and armies — into the mire.",
equipmentRewards: [ "crystal_shard" ], equipmentRewards: [ "crystal_shard" ],
essenceReward: 4000, essenceReward: 100_000,
goldReward: 8_000_000, goldReward: 350_000_000,
id: "mud_kraken", id: "mud_kraken",
maxHp: 800_000, maxHp: 20_000_000,
name: "The Mud Kraken", name: "The Mud Kraken",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -231,53 +231,53 @@ export const defaultBosses: Array<Boss> = [
}, },
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
{ {
bountyRunestones: 12, bountyRunestones: 32,
crystalReward: 150, crystalReward: 4000,
currentHp: 1_000_000, currentHp: 25_000_000,
damagePerSecond: 400, damagePerSecond: 5000,
description: description:
"Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.", "Born from the first volcanic eruption the world ever knew. It exists purely to consume, and your guild looks like the finest kindling it has seen in millennia.",
equipmentRewards: [ "flame_lance" ], equipmentRewards: [ "flame_lance" ],
essenceReward: 6000, essenceReward: 150_000,
goldReward: 10_000_000, goldReward: 500_000_000,
id: "fire_elemental", id: "fire_elemental",
maxHp: 1_000_000, maxHp: 25_000_000,
name: "The Ancient Fire Elemental", name: "The Ancient Fire Elemental",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [ "celestial_guard_1" ], upgradeRewards: [ "dark_templar_1" ],
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
bountyRunestones: 18, bountyRunestones: 40,
crystalReward: 400, crystalReward: 8000,
currentHp: 4_000_000, currentHp: 60_000_000,
damagePerSecond: 1000, damagePerSecond: 12_000,
description: description:
"Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.", "Half-giant, half-living volcano, this colossus was created by the fire elementals to guard their greatest forge. Every strike from its fists sends shockwaves through the earth.",
equipmentRewards: [ "volcanic_plate" ], equipmentRewards: [ "volcanic_plate" ],
essenceReward: 15_000, essenceReward: 300_000,
goldReward: 40_000_000, goldReward: 1_000_000_000,
id: "magma_titan", id: "magma_titan",
maxHp: 4_000_000, maxHp: 60_000_000,
name: "The Magma Titan", name: "The Magma Titan",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [ "crystal_resonance" ], upgradeRewards: [ "crystal_resonance", "celestial_guard_1" ],
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
bountyRunestones: 25, bountyRunestones: 50,
crystalReward: 800, crystalReward: 15_000,
currentHp: 12_000_000, currentHp: 150_000_000,
damagePerSecond: 2500, damagePerSecond: 30_000,
description: description:
"The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.", "The apex predator of the volcanic chain — a being of pure flame that has died and reborn itself more times than recorded history. This time, it will not rise again.",
equipmentRewards: [ "eternal_flame" ], equipmentRewards: [ "eternal_flame" ],
essenceReward: 40_000, essenceReward: 600_000,
goldReward: 120_000_000, goldReward: 2_000_000_000,
id: "phoenix_lord", id: "phoenix_lord",
maxHp: 12_000_000, maxHp: 150_000_000,
name: "The Phoenix Lord", name: "The Phoenix Lord",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
@@ -1120,7 +1120,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Storm Colossus", name: "The Storm Colossus",
prestigeRequirement: 51, prestigeRequirement: 51,
status: "locked", status: "locked",
upgradeRewards: [ "cosmos_knight_1" ], upgradeRewards: [],
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
@@ -1318,7 +1318,7 @@ export const defaultBosses: Array<Boss> = [
id: "the_absolute_one", id: "the_absolute_one",
maxHp: 2e145, maxHp: 2e145,
name: "The Absolute One", name: "The Absolute One",
prestigeRequirement: 90, prestigeRequirement: 88,
status: "locked", status: "locked",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
+10 -10
View File
@@ -101,7 +101,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "weapon", type: "weapon",
}, },
{ {
bonus: { combatMultiplier: 2.75 }, bonus: { combatMultiplier: 3.25 },
cost: { crystals: 500, essence: 2000, gold: 0 }, cost: { crystals: 500, essence: 2000, gold: 0 },
description: description:
"A blade made of compressed nothingness. It does not cut — it simply unmakes.", "A blade made of compressed nothingness. It does not cut — it simply unmakes.",
@@ -204,7 +204,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour", type: "armour",
}, },
{ {
bonus: { goldMultiplier: 2.25 }, bonus: { goldMultiplier: 2.75 },
description: description:
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.", "Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
equipped: false, equipped: false,
@@ -305,7 +305,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 2, goldMultiplier: 1.25 }, bonus: { clickMultiplier: 2.25, goldMultiplier: 1.25 },
description: description:
"The legendary stone that grants mastery over gold and combat alike.", "The legendary stone that grants mastery over gold and combat alike.",
equipped: false, equipped: false,
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 }, bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
description: description:
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.", "A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
equipped: false, equipped: false,
@@ -697,7 +697,7 @@ export const defaultEquipment: Array<Equipment> = [
}, },
// ── Purchasable endgame sinks ───────────────────────────────────────────── // ── Purchasable endgame sinks ─────────────────────────────────────────────
{ {
bonus: { clickMultiplier: 2.5 }, bonus: { clickMultiplier: 3 },
cost: { crystals: 0, essence: 20_000_000, gold: 0 }, cost: { crystals: 0, essence: 20_000_000, gold: 0 },
description: description:
"A lens of compressed celestial light that sharpens every strike with divine precision.", "A lens of compressed celestial light that sharpens every strike with divine precision.",
@@ -709,7 +709,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { goldMultiplier: 3 }, bonus: { goldMultiplier: 3.75 },
cost: { crystals: 0, essence: 50_000_000, gold: 0 }, cost: { crystals: 0, essence: 50_000_000, gold: 0 },
description: description:
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.", "A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
@@ -721,7 +721,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour", type: "armour",
}, },
{ {
bonus: { combatMultiplier: 4 }, bonus: { combatMultiplier: 7 },
cost: { crystals: 0, essence: 100_000_000, gold: 0 }, cost: { crystals: 0, essence: 100_000_000, gold: 0 },
description: description:
"A weapon that channels void energy — the absence of resistance makes every strike devastating.", "A weapon that channels void energy — the absence of resistance makes every strike devastating.",
@@ -733,7 +733,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "weapon", type: "weapon",
}, },
{ {
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 }, bonus: { clickMultiplier: 4, goldMultiplier: 1.5 },
cost: { crystals: 5_000_000, essence: 0, gold: 0 }, cost: { crystals: 5_000_000, essence: 0, gold: 0 },
description: description:
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.", "A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
@@ -745,7 +745,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { goldMultiplier: 4 }, bonus: { goldMultiplier: 4.75 },
cost: { crystals: 20_000_000, essence: 0, gold: 0 }, cost: { crystals: 20_000_000, essence: 0, gold: 0 },
description: description:
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.", "Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
@@ -757,7 +757,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour", type: "armour",
}, },
{ {
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 }, bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
cost: { crystals: 100_000_000, essence: 0, gold: 0 }, cost: { crystals: 100_000_000, essence: 0, gold: 0 },
description: description:
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.", "An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
+13 -4
View File
@@ -92,18 +92,18 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
{ {
category: "income", category: "income",
description: description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.", "The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
id: "income_10", id: "income_10",
multiplier: 500, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 30_000, runestonesCost: 30_000,
}, },
{ {
category: "income", category: "income",
description: description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.", "Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
id: "income_11", id: "income_11",
multiplier: 1000, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 80_000, runestonesCost: 80_000,
}, },
@@ -210,6 +210,15 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
runestonesCost: 1200, runestonesCost: 1200,
}, },
// ── Utility Unlocks ─────────────────────────────────────────────────────── // ── Utility Unlocks ───────────────────────────────────────────────────────
{
category: "utility",
description:
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
id: "auto_adventurer",
multiplier: 1,
name: "Autonomous Recruitment",
runestonesCost: 50,
},
{ {
category: "utility", category: "utility",
description: description:
+152 -76
View File
@@ -33,6 +33,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 2000, type: "gold" }, { amount: 2000, type: "gold" },
{ amount: 5, type: "essence" }, { amount: 5, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" }, { targetId: "apprentice", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -64,7 +65,6 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [ "haunted_mine" ], prerequisiteIds: [ "haunted_mine" ],
rewards: [ rewards: [
{ amount: 50, type: "essence" }, { amount: 50, type: "essence" },
{ targetId: "click_2", type: "upgrade" },
{ targetId: "acolyte", type: "adventurer" }, { targetId: "acolyte", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -82,7 +82,8 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 15_000, type: "gold" }, { amount: 15_000, type: "gold" },
{ amount: 20, type: "essence" }, { amount: 20, type: "essence" },
{ targetId: "cleric_1", type: "upgrade" }, { targetId: "militia_1", type: "upgrade" },
{ targetId: "acolyte_1", type: "upgrade" },
{ targetId: "ranger", type: "adventurer" }, { targetId: "ranger", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -116,7 +117,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 300, type: "essence" }, { amount: 300, type: "essence" },
{ amount: 30, type: "crystals" }, { amount: 30, type: "crystals" },
{ targetId: "mage_1", type: "upgrade" }, { targetId: "apprentice_1", type: "upgrade" },
{ targetId: "archmage", type: "adventurer" }, { targetId: "archmage", type: "adventurer" },
], ],
status: "locked", status: "locked",
@@ -139,69 +140,6 @@ export const defaultQuests: Array<Quest> = [
status: "locked", status: "locked",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
}, },
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 150, type: "essence" },
{ targetId: "militia_1", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
],
status: "locked",
zoneId: "shadow_marshes",
},
// ── Frozen Peaks ────────────────────────────────────────────────────────── // ── Frozen Peaks ──────────────────────────────────────────────────────────
{ {
combatPowerRequired: 100_000, combatPowerRequired: 100_000,
@@ -246,14 +184,78 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 30_000_000, type: "gold" }, { amount: 30_000_000, type: "gold" },
{ amount: 10_000, type: "essence" }, { amount: 10_000, type: "essence" },
{ targetId: "peasant_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
}, },
// ── Shadow Marshes ────────────────────────────────────────────────────────
{
combatPowerRequired: 5_000_000,
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60,
id: "shadow_mere",
name: "The Shadow Mere",
prerequisiteIds: [],
rewards: [
{ amount: 150, type: "essence" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 20_000_000,
description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60,
id: "witch_coven",
name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ],
rewards: [
{ amount: 500, type: "essence" },
{ targetId: "shadow_assassin", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 80_000_000,
description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60,
id: "sunken_temple",
name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ],
rewards: [
{ amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" },
{ targetId: "peasant_2", type: "upgrade" },
],
status: "locked",
zoneId: "shadow_marshes",
},
{
combatPowerRequired: 300_000_000,
description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60,
id: "plague_ruins",
name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ],
rewards: [
{ amount: 8_000_000, type: "gold" },
{ amount: 2000, type: "essence" },
{ amount: 150, type: "crystals" },
{ targetId: "dark_templar", type: "adventurer" },
],
status: "locked",
zoneId: "shadow_marshes",
},
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
{ {
combatPowerRequired: 2_000_000, combatPowerRequired: 1_200_000_000,
description: description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -263,12 +265,13 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 15_000_000, type: "gold" }, { amount: 15_000_000, type: "gold" },
{ amount: 4000, type: "essence" }, { amount: 4000, type: "essence" },
{ targetId: "void_walker", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 8_000_000, combatPowerRequired: 4_800_000_000,
description: description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 5 * 60 * 60, durationSeconds: 5 * 60 * 60,
@@ -276,15 +279,16 @@ export const defaultQuests: Array<Quest> = [
name: "The Temple of the Flame", name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ], prerequisiteIds: [ "lava_flows" ],
rewards: [ rewards: [
{ amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" }, { amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" }, { amount: 300, type: "crystals" },
{ targetId: "void_walker", type: "adventurer" }, { targetId: "peasant_3", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 30_000_000, combatPowerRequired: 18_000_000_000,
description: description:
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.", "Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
durationSeconds: 7 * 60 * 60, durationSeconds: 7 * 60 * 60,
@@ -300,7 +304,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 120_000_000, combatPowerRequired: 72_000_000_000,
description: description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", "The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
durationSeconds: 10 * 60 * 60, durationSeconds: 10 * 60 * 60,
@@ -317,7 +321,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Astral Void ─────────────────────────────────────────────────────────── // ── Astral Void ───────────────────────────────────────────────────────────
{ {
combatPowerRequired: 50_000_000, combatPowerRequired: 300_000_000_000,
description: description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -332,7 +336,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 200_000_000, combatPowerRequired: 1_200_000_000_000,
description: description:
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.", "A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -348,7 +352,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 800_000_000, combatPowerRequired: 4_800_000_000_000,
description: description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", "The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -364,7 +368,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 3_000_000_000, combatPowerRequired: 18_000_000_000_000,
description: description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", "There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
durationSeconds: 24 * 60 * 60, durationSeconds: 24 * 60 * 60,
@@ -381,6 +385,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Celestial Reaches ───────────────────────────────────────────────────── // ── Celestial Reaches ─────────────────────────────────────────────────────
{ {
combatPowerRequired: 7.2e13,
description: description:
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.", "The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
durationSeconds: Math.round(1.5 * 60 * 60), durationSeconds: Math.round(1.5 * 60 * 60),
@@ -396,6 +401,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 3e14,
description: description:
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.", "A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -410,6 +416,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 1.2e15,
description: description:
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.", "Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
durationSeconds: 5 * 60 * 60, durationSeconds: 5 * 60 * 60,
@@ -425,6 +432,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 4.8e15,
description: description:
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.", "A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -440,6 +448,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 1.8e16,
description: description:
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.", "The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -456,6 +465,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
}, },
{ {
combatPowerRequired: 7.2e16,
description: description:
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.", "The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -472,6 +482,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Abyssal Trench ──────────────────────────────────────────────────────── // ── Abyssal Trench ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 3e17,
description: description:
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.", "The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
durationSeconds: 2 * 60 * 60, durationSeconds: 2 * 60 * 60,
@@ -487,6 +498,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 1.2e18,
description: description:
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.", "The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -502,6 +514,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 4.8e18,
description: description:
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.", "Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
durationSeconds: 7 * 60 * 60, durationSeconds: 7 * 60 * 60,
@@ -517,6 +530,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 1.8e19,
description: description:
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.", "Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -532,6 +546,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 7.2e19,
description: description:
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.", "A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
durationSeconds: 18 * 60 * 60, durationSeconds: 18 * 60 * 60,
@@ -548,6 +563,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
}, },
{ {
combatPowerRequired: 3e20,
description: description:
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.", "The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
durationSeconds: 30 * 60 * 60, durationSeconds: 30 * 60 * 60,
@@ -564,6 +580,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Infernal Court ──────────────────────────────────────────────────────── // ── Infernal Court ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.2e21,
description: description:
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.", "The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -579,6 +596,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 4.8e21,
description: description:
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.", "The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
durationSeconds: 6 * 60 * 60, durationSeconds: 6 * 60 * 60,
@@ -594,6 +612,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 1.8e22,
description: description:
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.", "The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
durationSeconds: 10 * 60 * 60, durationSeconds: 10 * 60 * 60,
@@ -609,6 +628,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 7.2e22,
description: description:
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.", "Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
durationSeconds: 16 * 60 * 60, durationSeconds: 16 * 60 * 60,
@@ -624,6 +644,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 3e23,
description: description:
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.", "The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
durationSeconds: 24 * 60 * 60, durationSeconds: 24 * 60 * 60,
@@ -640,6 +661,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infernal_court", zoneId: "infernal_court",
}, },
{ {
combatPowerRequired: 1.2e24,
description: description:
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.", "The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
durationSeconds: 40 * 60 * 60, durationSeconds: 40 * 60 * 60,
@@ -656,6 +678,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Crystalline Spire ───────────────────────────────────────────────────── // ── Crystalline Spire ─────────────────────────────────────────────────────
{ {
combatPowerRequired: 4.8e24,
description: description:
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.", "The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -671,6 +694,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 1.8e25,
description: description:
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.", "A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -686,6 +710,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 7.2e25,
description: description:
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.", "A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
durationSeconds: 14 * 60 * 60, durationSeconds: 14 * 60 * 60,
@@ -701,6 +726,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 3e26,
description: description:
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.", "The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -716,6 +742,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 1.2e27,
description: description:
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.", "The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
durationSeconds: 32 * 60 * 60, durationSeconds: 32 * 60 * 60,
@@ -732,6 +759,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
}, },
{ {
combatPowerRequired: 4.8e27,
description: description:
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.", "The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
durationSeconds: 50 * 60 * 60, durationSeconds: 50 * 60 * 60,
@@ -748,6 +776,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Void Sanctum ────────────────────────────────────────────────────────── // ── Void Sanctum ──────────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.8e28,
description: description:
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.", "The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
durationSeconds: 6 * 60 * 60, durationSeconds: 6 * 60 * 60,
@@ -763,6 +792,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 7.2e28,
description: description:
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.", "Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -778,6 +808,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 3e29,
description: description:
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.", "The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -793,6 +824,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 1.2e30,
description: description:
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.", "Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
durationSeconds: 30 * 60 * 60, durationSeconds: 30 * 60 * 60,
@@ -808,6 +840,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 4.8e30,
description: description:
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.", "The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
durationSeconds: 48 * 60 * 60, durationSeconds: 48 * 60 * 60,
@@ -824,6 +857,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "void_sanctum", zoneId: "void_sanctum",
}, },
{ {
combatPowerRequired: 1.8e31,
description: description:
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.", "The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
durationSeconds: 72 * 60 * 60, durationSeconds: 72 * 60 * 60,
@@ -840,6 +874,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Eternal Throne ──────────────────────────────────────────────────────── // ── Eternal Throne ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 7.2e31,
description: description:
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.", "The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -855,6 +890,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 3e32,
description: description:
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.", "A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
durationSeconds: 16 * 60 * 60, durationSeconds: 16 * 60 * 60,
@@ -870,6 +906,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 1.2e33,
description: description:
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.", "The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
durationSeconds: 28 * 60 * 60, durationSeconds: 28 * 60 * 60,
@@ -885,6 +922,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 4.8e33,
description: description:
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.", "The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
durationSeconds: 40 * 60 * 60, durationSeconds: 40 * 60 * 60,
@@ -901,6 +939,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 1.8e34,
description: description:
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.", "The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
durationSeconds: 60 * 60 * 60, durationSeconds: 60 * 60 * 60,
@@ -916,6 +955,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "eternal_throne", zoneId: "eternal_throne",
}, },
{ {
combatPowerRequired: 7.2e34,
description: description:
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.", "The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
durationSeconds: 96 * 60 * 60, durationSeconds: 96 * 60 * 60,
@@ -932,6 +972,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Primordial Chaos ────────────────────────────────────────────────────── // ── Primordial Chaos ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 3e35,
description: description:
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.", "Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
durationSeconds: 10 * 60 * 60, durationSeconds: 10 * 60 * 60,
@@ -947,6 +988,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 1.2e36,
description: description:
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.", "Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
durationSeconds: 18 * 60 * 60, durationSeconds: 18 * 60 * 60,
@@ -962,6 +1004,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 4.8e36,
description: description:
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.", "A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
durationSeconds: 30 * 60 * 60, durationSeconds: 30 * 60 * 60,
@@ -978,6 +1021,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 1.8e37,
description: description:
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.", "Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
durationSeconds: 45 * 60 * 60, durationSeconds: 45 * 60 * 60,
@@ -993,6 +1037,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 7.2e37,
description: description:
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.", "The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
durationSeconds: 65 * 60 * 60, durationSeconds: 65 * 60 * 60,
@@ -1009,6 +1054,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
}, },
{ {
combatPowerRequired: 3e38,
description: description:
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.", "The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
durationSeconds: 90 * 60 * 60, durationSeconds: 90 * 60 * 60,
@@ -1025,6 +1071,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Infinite Expanse ────────────────────────────────────────────────────── // ── Infinite Expanse ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.2e39,
description: description:
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.", "The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -1040,6 +1087,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 4.8e39,
description: description:
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.", "An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
durationSeconds: 22 * 60 * 60, durationSeconds: 22 * 60 * 60,
@@ -1055,6 +1103,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 1.8e40,
description: description:
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.", "Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
durationSeconds: 36 * 60 * 60, durationSeconds: 36 * 60 * 60,
@@ -1071,6 +1120,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 7.2e40,
description: description:
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.", "A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
durationSeconds: 55 * 60 * 60, durationSeconds: 55 * 60 * 60,
@@ -1087,6 +1137,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 3e41,
description: description:
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.", "A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
durationSeconds: 80 * 60 * 60, durationSeconds: 80 * 60 * 60,
@@ -1102,6 +1153,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
}, },
{ {
combatPowerRequired: 1.2e42,
description: description:
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.", "The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
durationSeconds: 110 * 60 * 60, durationSeconds: 110 * 60 * 60,
@@ -1118,6 +1170,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Reality Forge ───────────────────────────────────────────────────────── // ── Reality Forge ─────────────────────────────────────────────────────────
{ {
combatPowerRequired: 4.8e42,
description: description:
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.", "The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
durationSeconds: 14 * 60 * 60, durationSeconds: 14 * 60 * 60,
@@ -1133,6 +1186,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 1.8e43,
description: description:
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.", "The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
durationSeconds: 25 * 60 * 60, durationSeconds: 25 * 60 * 60,
@@ -1148,6 +1202,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 7.2e43,
description: description:
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.", "The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
durationSeconds: 40 * 60 * 60, durationSeconds: 40 * 60 * 60,
@@ -1164,6 +1219,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 3e44,
description: description:
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.", "The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
durationSeconds: 60 * 60 * 60, durationSeconds: 60 * 60 * 60,
@@ -1180,6 +1236,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 1.2e45,
description: description:
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.", "The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
durationSeconds: 85 * 60 * 60, durationSeconds: 85 * 60 * 60,
@@ -1195,6 +1252,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
combatPowerRequired: 4.8e45,
description: description:
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.", "The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
durationSeconds: 120 * 60 * 60, durationSeconds: 120 * 60 * 60,
@@ -1211,6 +1269,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Cosmic Maelstrom ────────────────────────────────────────────────────── // ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 1.8e46,
description: description:
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.", "The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
durationSeconds: 16 * 60 * 60, durationSeconds: 16 * 60 * 60,
@@ -1226,6 +1285,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 7.2e46,
description: description:
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.", "The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
durationSeconds: 28 * 60 * 60, durationSeconds: 28 * 60 * 60,
@@ -1241,6 +1301,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 3e47,
description: description:
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.", "A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
durationSeconds: 45 * 60 * 60, durationSeconds: 45 * 60 * 60,
@@ -1257,6 +1318,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 1.2e48,
description: description:
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.", "Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
durationSeconds: 65 * 60 * 60, durationSeconds: 65 * 60 * 60,
@@ -1273,6 +1335,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 4.8e48,
description: description:
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.", "The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
durationSeconds: 90 * 60 * 60, durationSeconds: 90 * 60 * 60,
@@ -1288,6 +1351,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
}, },
{ {
combatPowerRequired: 1.8e49,
description: description:
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.", "The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
durationSeconds: 130 * 60 * 60, durationSeconds: 130 * 60 * 60,
@@ -1304,6 +1368,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Primeval Sanctum ────────────────────────────────────────────────────── // ── Primeval Sanctum ──────────────────────────────────────────────────────
{ {
combatPowerRequired: 7.2e49,
description: description:
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.", "The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
durationSeconds: 18 * 60 * 60, durationSeconds: 18 * 60 * 60,
@@ -1319,6 +1384,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 3e50,
description: description:
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.", "The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
durationSeconds: 32 * 60 * 60, durationSeconds: 32 * 60 * 60,
@@ -1334,6 +1400,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 1.2e51,
description: description:
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.", "The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
durationSeconds: 50 * 60 * 60, durationSeconds: 50 * 60 * 60,
@@ -1350,6 +1417,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 4.8e51,
description: description:
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.", "The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
durationSeconds: 72 * 60 * 60, durationSeconds: 72 * 60 * 60,
@@ -1366,6 +1434,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 1.8e52,
description: description:
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.", "A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
durationSeconds: 100 * 60 * 60, durationSeconds: 100 * 60 * 60,
@@ -1381,6 +1450,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
combatPowerRequired: 7.2e52,
description: description:
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.", "The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
durationSeconds: 144 * 60 * 60, durationSeconds: 144 * 60 * 60,
@@ -1397,6 +1467,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── The Absolute ────────────────────────────────────────────────────────── // ── The Absolute ──────────────────────────────────────────────────────────
{ {
combatPowerRequired: 3e53,
description: description:
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.", "The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
durationSeconds: 20 * 60 * 60, durationSeconds: 20 * 60 * 60,
@@ -1412,6 +1483,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 1.2e54,
description: description:
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.", "Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
durationSeconds: 36 * 60 * 60, durationSeconds: 36 * 60 * 60,
@@ -1427,6 +1499,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 4.8e54,
description: description:
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.", "A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
durationSeconds: 56 * 60 * 60, durationSeconds: 56 * 60 * 60,
@@ -1443,6 +1516,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 1.8e55,
description: description:
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.", "Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
durationSeconds: 80 * 60 * 60, durationSeconds: 80 * 60 * 60,
@@ -1458,6 +1532,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 7.2e55,
description: description:
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.", "The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
durationSeconds: 120 * 60 * 60, durationSeconds: 120 * 60 * 60,
@@ -1474,6 +1549,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
{ {
combatPowerRequired: 3e56,
description: description:
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.", "This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
durationSeconds: 168 * 60 * 60, durationSeconds: 168 * 60 * 60,
+56
View File
@@ -451,6 +451,62 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
// ── Cross-zone recipes ─────────────────────────────────────────────────────
{
bonus: { type: "gold_income", value: 1.28 },
description:
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
id: "verdant_pyre_seal",
name: "Verdant Pyre Seal",
requiredMaterials: [
{ materialId: "verdant_sap", quantity: 8 },
{ materialId: "ember_crystal", quantity: 6 },
{ materialId: "legendary_ore", quantity: 2 },
],
zoneId: "volcanic_depths",
},
{
bonus: { type: "click_power", value: 1.22 },
description:
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
id: "voidfrost_weave",
name: "Voidfrost Weave",
requiredMaterials: [
{ materialId: "glacial_ice", quantity: 8 },
{ materialId: "void_shard", quantity: 3 },
{ materialId: "shadow_essence", quantity: 5 },
],
zoneId: "shadow_marshes",
},
{
bonus: { type: "essence_income", value: 1.28 },
description:
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
id: "choir_of_the_deep",
name: "Choir of the Deep",
requiredMaterials: [
{ materialId: "celestial_dust", quantity: 8 },
{ materialId: "choir_shard", quantity: 2 },
{ materialId: "ancient_tooth", quantity: 2 },
{ materialId: "pressure_gem", quantity: 5 },
],
zoneId: "abyssal_trench",
},
{
bonus: { type: "combat_power", value: 1.4 },
description:
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
id: "eternal_omega",
name: "Eternal Omega",
requiredMaterials: [
{ materialId: "crown_fragment", quantity: 6 },
{ materialId: "eternity_splinter", quantity: 2 },
{ materialId: "boundary_shard", quantity: 4 },
{ materialId: "omega_crystal", quantity: 2 },
],
zoneId: "the_absolute",
},
// Zone 18: the_absolute // Zone 18: the_absolute
{ {
bonus: { type: "gold_income", value: 1.3 }, bonus: { type: "gold_income", value: 1.3 },
+3 -3
View File
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ─────────────────────────────────────────────────── // ── Echo meta multipliers ───────────────────────────────────────────────────
{ {
category: "echo_meta", category: "echo_meta",
cost: 10, cost: 50,
description: description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.", "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1", id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 25, cost: 150,
description: description:
"Each loop of existence makes the next more powerful — future echo yields +50%.", "Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2", id: "echo_meta_2",
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 50, cost: 400,
description: description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.", "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3", id: "echo_meta_3",
+127 -19
View File
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
{
adventurerId: "peasant",
costCrystals: 0,
costEssence: 20,
costGold: 0,
description:
"Organised labour guilds and proper scheduling make peasants ten times more productive.",
id: "peasant_2",
multiplier: 10,
name: "Guild Organisation",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "peasant",
costCrystals: 50,
costEssence: 0,
costGold: 0,
description:
"Magical augmentation through crystalline resonance supercharges even the humblest worker.",
id: "peasant_3",
multiplier: 50,
name: "Crystal Augmentation",
purchased: false,
target: "adventurer",
unlocked: false,
},
{ {
adventurerId: "militia", adventurerId: "militia",
costCrystals: 0, costCrystals: 0,
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 2, costEssence: 2,
costGold: 5000, costGold: 5000,
description: "Ancient books of magic double mage output.", description: "Ancient books of magic double mage output.",
id: "mage_1", id: "apprentice_1",
multiplier: 2, multiplier: 2,
name: "Arcane Tomes", name: "Arcane Tomes",
purchased: false, purchased: false,
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 3, costEssence: 3,
costGold: 8000, costGold: 8000,
description: "Sacred ceremonies double the output of your clerics.", description: "Sacred ceremonies double the output of your clerics.",
id: "cleric_1", id: "acolyte_1",
multiplier: 2, multiplier: 2,
name: "Holy Rites", name: "Holy Rites",
purchased: false, purchased: false,
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
{
adventurerId: "shadow_assassin",
costCrystals: 0,
costEssence: 50,
costGold: 0,
description: "Mastery of the shadow arts doubles assassin effectiveness.",
id: "shadow_assassin_1",
multiplier: 2,
name: "Shadow Arts",
purchased: false,
target: "adventurer",
unlocked: false,
},
{ {
adventurerId: "arcane_scholar", adventurerId: "arcane_scholar",
costCrystals: 0, costCrystals: 0,
costEssence: 150, costEssence: 1000,
costGold: 0, costGold: 0,
description: "Access to forbidden libraries doubles scholar output.", description: "Access to forbidden libraries doubles scholar output.",
id: "arcane_scholar_1", id: "arcane_scholar_1",
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
{
adventurerId: "shadow_assassin",
costCrystals: 0,
costEssence: 5000,
costGold: 0,
description: "Mastery of the shadow arts doubles assassin effectiveness.",
id: "shadow_assassin_1",
multiplier: 2,
name: "Shadow Arts",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "dark_templar",
costCrystals: 0,
costEssence: 25_000,
costGold: 0,
description:
"A sworn oath to the darkness of the marshes doubles templar output.",
id: "dark_templar_1",
multiplier: 2,
name: "Templar's Oath",
purchased: false,
target: "adventurer",
unlocked: false,
},
{ {
adventurerId: "void_walker", adventurerId: "void_walker",
costCrystals: 0, costCrystals: 0,
costEssence: 300, costEssence: 100_000,
costGold: 0, costGold: 0,
description: description:
"Walking through the void itself doubles the output of your void walkers.", "Walking through the void itself doubles the output of your void walkers.",
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{ {
adventurerId: "celestial_guard", adventurerId: "celestial_guard",
costCrystals: 0, costCrystals: 0,
costEssence: 750, costEssence: 500_000,
costGold: 0, costGold: 0,
description: description:
"A blessing from the celestials themselves doubles guard output.", "A blessing from the celestials themselves doubles guard output.",
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{ {
adventurerId: "divine_champion", adventurerId: "divine_champion",
costCrystals: 0, costCrystals: 0,
costEssence: 2000, costEssence: 2_000_000,
costGold: 0, costGold: 0,
description: "An unbreakable oath to the divine doubles champion output.", description: "An unbreakable oath to the divine doubles champion output.",
id: "divine_champion_1", id: "divine_champion_1",
@@ -767,4 +809,70 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer", target: "adventurer",
unlocked: false, unlocked: false,
}, },
// ── Essence Sinks ─────────────────────────────────────────────────────────
{
costCrystals: 0,
costEssence: 1e12,
costGold: 0,
description:
"Channel a vast reservoir of essence into the guild's core — all production ×2.",
id: "essence_sink_1",
multiplier: 2,
name: "Essence Infusion I",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 0,
costEssence: 5e12,
costGold: 0,
description:
"A deeper infusion saturates every operation with raw essence — all production ×2.",
id: "essence_sink_2",
multiplier: 2,
name: "Essence Infusion II",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 0,
costEssence: 2.5e13,
costGold: 0,
description:
"Essence floods the ley-lines binding your guild — all production ×2.",
id: "essence_sink_3",
multiplier: 2,
name: "Essence Infusion III",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 0,
costEssence: 1e14,
costGold: 0,
description:
"The guild breathes essence as its very lifeblood — all production ×3.",
id: "essence_sink_4",
multiplier: 3,
name: "Essence Infusion IV",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 0,
costEssence: 5e14,
costGold: 0,
description:
"Essence transcends material form and reshapes reality itself — all production ×5.",
id: "essence_sink_5",
multiplier: 5,
name: "Essence Infusion V",
purchased: false,
target: "global",
unlocked: true,
},
]; ];
+2
View File
@@ -13,6 +13,7 @@ import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js"; import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js"; import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js"; import { craftRouter } from "./routes/craft.js";
import { debugRouter } from "./routes/debug.js";
import { exploreRouter } from "./routes/explore.js"; import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js"; import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js"; import { gameRouter } from "./routes/game.js";
@@ -35,6 +36,7 @@ app.use(
); );
app.route("/about", aboutRouter); app.route("/about", aboutRouter);
app.route("/debug", debugRouter);
app.route("/fe", frontendRouter); app.route("/fe", frontendRouter);
app.route("/auth", authRouter); app.route("/auth", authRouter);
app.route("/game", gameRouter); app.route("/game", gameRouter);
+799
View File
@@ -0,0 +1,799 @@
/**
* @file Debug routes for administrative player state corrections.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* 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 { 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";
/**
* Computes the HMAC-SHA256 of data using the given secret.
* @param data - The data string to sign.
* @param secret - The HMAC secret key.
* @returns The hex-encoded HMAC digest.
*/
const computeHmac = (data: string, secret: string): string => {
return createHmac("sha256", secret).update(data).
digest("hex");
};
/**
* Unlocks any zones whose required boss and quest conditions are satisfied.
* @param state - The player's current game state (mutated directly).
* @returns The number of zones that were unlocked.
*/
const applyZoneUnlocks = (state: GameState): number => {
let count = 0;
for (const zoneDefinition of defaultZones) {
const zoneInState = state.zones.find((z) => {
return z.id === zoneDefinition.id;
});
if (!zoneInState || zoneInState.status !== "locked") {
continue;
}
const requiredBossDefeated
= zoneDefinition.unlockBossId === null
|| state.bosses.some((b) => {
return b.id === zoneDefinition.unlockBossId && b.status === "defeated";
});
const requiredQuestCompleted
= zoneDefinition.unlockQuestId === null
|| state.quests.some((q) => {
return (
q.id === zoneDefinition.unlockQuestId && q.status === "completed"
);
});
if (requiredBossDefeated && requiredQuestCompleted) {
zoneInState.status = "unlocked";
count = count + 1;
}
}
return count;
};
interface QuestUnlockCheck {
questId: string;
zoneId: string;
prerequisiteIds: Array<string>;
state: GameState;
completedQuestIds: Set<string>;
}
/**
* Determines whether a quest should be made available given the current state.
* @param options - The options for the quest unlock check.
* @param options.questId - The ID of the quest to check.
* @param options.zoneId - The zone the quest belongs to.
* @param options.prerequisiteIds - The quest IDs that must be completed first.
* @param options.state - The current game state.
* @param options.completedQuestIds - Set of already-completed quest IDs.
* @returns True when the quest should be unlocked.
*/
const shouldUnlockQuest = ({
questId,
zoneId,
prerequisiteIds,
state,
completedQuestIds,
}: QuestUnlockCheck): boolean => {
const questInState = state.quests.find((q) => {
return q.id === questId;
});
if (!questInState || questInState.status !== "locked") {
return false;
}
const zoneInState = state.zones.find((z) => {
return z.id === zoneId;
});
if (!zoneInState || zoneInState.status === "locked") {
return false;
}
return prerequisiteIds.every((id) => {
return completedQuestIds.has(id);
});
};
/**
* Makes available any quests whose zone is unlocked and prerequisites are met.
* @param state - The player's current game state (mutated directly).
* @returns The number of quests that were made available.
*/
const applyQuestUnlocks = (state: GameState): number => {
let count = 0;
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
for (const questDefinition of defaultQuests) {
if (
!shouldUnlockQuest({
completedQuestIds: completedQuestIds,
prerequisiteIds: questDefinition.prerequisiteIds,
questId: questDefinition.id,
state: state,
zoneId: questDefinition.zoneId,
})
) {
continue;
}
const questInState = state.quests.find((q) => {
return q.id === questDefinition.id;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 4 -- @preserve */
if (questInState) {
questInState.status = "available";
count = count + 1;
}
}
return count;
};
interface BossUnlockCheck {
bossId: string;
previousBossId: string | undefined;
isFirstInZone: boolean;
prestigeRequirement: number;
state: GameState;
prestigeCount: number;
}
/**
* Determines whether a boss should be made available given the current state.
* @param options - The options for the boss unlock check.
* @param options.bossId - The ID of the boss to check.
* @param options.previousBossId - The ID of the previous boss in the zone.
* @param options.isFirstInZone - Whether this boss is the first in its zone.
* @param options.prestigeRequirement - The prestige level required for this boss.
* @param options.state - The current game state.
* @param options.prestigeCount - The player's current prestige count.
* @returns True when the boss should be made available.
*/
const shouldUnlockBoss = ({
bossId,
previousBossId,
isFirstInZone,
prestigeRequirement,
state,
prestigeCount,
}: BossUnlockCheck): boolean => {
const bossInState = state.bosses.find((b) => {
return b.id === bossId;
});
if (!bossInState || bossInState.status !== "locked") {
return false;
}
if (prestigeRequirement > prestigeCount) {
return false;
}
if (isFirstInZone) {
return true;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (previousBossId === undefined) {
return false;
}
const previousBossInState = state.bosses.find((b) => {
return b.id === previousBossId;
});
return previousBossInState?.status === "defeated";
};
/**
* Makes available any bosses that should be accessible based on zone status
* and sequential defeat order within each zone.
* @param state - The player's current game state (mutated directly).
* @returns The number of bosses that were made available.
*/
const applyBossUnlocks = (state: GameState): number => {
let count = 0;
const prestigeCount = state.prestige.count;
for (const zoneDefinition of defaultZones) {
const zoneInState = state.zones.find((z) => {
return z.id === zoneDefinition.id;
});
if (!zoneInState || zoneInState.status === "locked") {
continue;
}
const bossesInZone = defaultBosses.filter((b) => {
return b.zoneId === zoneDefinition.id;
});
for (let index = 0; index < bossesInZone.length; index = index + 1) {
const bossDefinition = bossesInZone[index];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!bossDefinition) {
continue;
}
const previousBossDefinition = bossesInZone[index - 1];
const unlock = shouldUnlockBoss({
bossId: bossDefinition.id,
isFirstInZone: index === 0,
prestigeCount: prestigeCount,
prestigeRequirement: bossDefinition.prestigeRequirement,
previousBossId: previousBossDefinition?.id,
state: state,
});
if (unlock) {
const bossInState = state.bosses.find((b) => {
return b.id === bossDefinition.id;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 4 -- @preserve */
if (bossInState) {
bossInState.status = "available";
count = count + 1;
}
}
}
}
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).
* @returns The number of exploration areas that were made available.
*/
const applyExplorationUnlocks = (state: GameState): number => {
if (state.exploration === undefined) {
return 0;
}
let count = 0;
const unlockedZoneIds = new Set(
state.zones.
filter((z) => {
return z.status === "unlocked";
}).
map((z) => {
return z.id;
}),
);
for (const areaDefinition of defaultExplorations) {
if (!unlockedZoneIds.has(areaDefinition.zoneId)) {
continue;
}
const areaInState = state.exploration.areas.find((a) => {
return a.id === areaDefinition.id;
});
if (areaInState && areaInState.status === "locked") {
areaInState.status = "available";
count = count + 1;
}
}
return count;
};
/**
* Applies all missing unlock corrections to a game state in-place.
* Delegates to per-category helpers and aggregates the results.
* @param state - The player's current game state (mutated directly).
* @returns Counts of each entity type that was corrected.
*/
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);
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;
};
/* 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.
* @param state - The player's current game state (mutated in place).
* @returns Counts of how many entries were added per content type.
*/
const syncNewContent = (
state: GameState,
): {
achievementsAdded: number;
adventurersAdded: number;
bossesAdded: number;
equipmentAdded: number;
explorationAreasAdded: number;
questsAdded: number;
upgradesAdded: number;
zonesAdded: number;
} => {
return {
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
explorationAreasAdded: injectMissingExplorationAreas(state),
questsAdded: injectMissingEntries(state.quests, defaultQuests),
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
zonesAdded: injectMissingEntries(state.zones, defaultZones),
};
};
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
const debugRouter = new Hono<HonoEnvironment>();
debugRouter.use(authMiddleware);
debugRouter.post("/force-unlocks", 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 {
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
} = applyForceUnlocks(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({
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
signature,
state,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
});
} catch (error) {
void logger.error(
"debug_force_unlocks",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
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,
adventurersAdded,
bossesAdded,
equipmentAdded,
explorationAreasAdded,
questsAdded,
upgradesAdded,
zonesAdded,
} = 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,
adventurersAdded,
bossesAdded,
equipmentAdded,
explorationAreasAdded,
questsAdded,
signature,
state,
upgradesAdded,
zonesAdded,
});
} 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");
const playerRecord = await prisma.player.findUnique({
where: { discordId },
});
if (!playerRecord) {
return context.json({ error: "No player found" }, 404);
}
const freshState = initialGameState(
{
avatar: playerRecord.avatar,
characterName: playerRecord.characterName,
createdAt: playerRecord.createdAt,
discordId: playerRecord.discordId,
discriminator: playerRecord.discriminator,
lastSavedAt: Date.now(),
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated,
lifetimeClicks: playerRecord.lifetimeClicks,
lifetimeGoldEarned: playerRecord.lifetimeGoldEarned,
lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted,
totalClicks: 0,
totalGoldEarned: 0,
username: playerRecord.username,
},
playerRecord.characterName,
);
const createdAt = Date.now();
await prisma.gameState.upsert({
create: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
state: freshState as object,
updatedAt: createdAt,
},
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
update: { state: freshState as object, updatedAt: createdAt },
where: { discordId },
});
const secret = process.env.ANTI_CHEAT_SECRET;
const signature
= secret === undefined
? undefined
: computeHmac(JSON.stringify(freshState), secret);
return context.json({
currentSchemaVersion: currentSchemaVersion,
loginBonus: null,
loginStreak: playerRecord.loginStreak,
offlineEssence: 0,
offlineGold: 0,
offlineSeconds: 0,
schemaOutdated: false,
signature: signature,
state: freshState,
});
} catch (error) {
void logger.error(
"debug_hard_reset",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { debugRouter };
+28 -3
View File
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
import { fetchDiscordUserById } from "../services/discord.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js";
import { import {
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
try { try {
const discordId = context.get("discordId"); const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([ const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }), Promise.all([
prisma.player.findUnique({ where: { discordId } }), prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]),
fetchDiscordUserById(discordId),
]); ]);
// Refresh avatar in DB when Discord returns an updated hash
if (
freshDiscordUser !== null
&& playerRecord !== null
&& freshDiscordUser.avatar !== playerRecord.avatar
) {
playerRecord.avatar = freshDiscordUser.avatar;
void prisma.player.update({
data: { avatar: freshDiscordUser.avatar },
where: { discordId },
}).catch((error: unknown) => {
void logger.error(
"avatar_refresh",
error instanceof Error
? error
: new Error(String(error)),
);
});
}
if (!record) { if (!record) {
// No save found — create a fresh state (handles nuked DB or first-time load race) // No save found — create a fresh state (handles nuked DB or first-time load race)
if (!playerRecord) { if (!playerRecord) {
@@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => {
*/ */
if (playerRecord !== null) { if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName; state.player.characterName = playerRecord.characterName;
state.player.avatar = playerRecord.avatar;
} }
const now = Date.now(); const now = Date.now();
+35 -1
View File
@@ -106,6 +106,40 @@ 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. * Builds the Discord OAuth authorisation URL.
* @returns The full OAuth URL to redirect the user to. * @returns The full OAuth URL to redirect the user to.
@@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => {
}; };
export type { DiscordTokenResponse, DiscordUser }; export type { DiscordTokenResponse, DiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser }; export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
+16 -3
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */ /* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js"; import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import type { import type {
@@ -214,7 +215,10 @@ const buildPostPrestigeState = (
const currentBoss = currentState.bosses.find((candidate) => { const currentBoss = currentState.bosses.find((candidate) => {
return candidate.id === freshBoss.id; return candidate.id === freshBoss.id;
}); });
if (currentBoss?.bountyRunestonesClaimed === true) { if (
currentBoss?.bountyRunestonesClaimed === true
|| currentBoss?.status === "defeated"
) {
return { ...freshBoss, bountyRunestonesClaimed: true }; return { ...freshBoss, bountyRunestonesClaimed: true };
} }
return freshBoss; return freshBoss;
@@ -239,11 +243,20 @@ const buildPostPrestigeState = (
const prestigeState: GameState = { const prestigeState: GameState = {
...freshState, ...freshState,
// Achievements are permanent — earned achievements survive all prestiges // Achievements are permanent — earned achievements survive all prestiges
achievements: currentState.achievements, achievements: currentState.achievements,
/*
* Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset.
*/
autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved // Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed, bosses: bossesWithBountyClaimed,
lastTickAt: Date.now(), lastTickAt: Date.now(),
/* /*
* Fold current-run totals into lifetime stats so the GameState reflects * Fold current-run totals into lifetime stats so the GameState reflects
+605
View File
@@ -0,0 +1,605 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() },
player: { findUnique: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
log: vi.fn().mockResolvedValue(undefined),
},
}));
const DISCORD_ID = "test_discord_id";
const makeExploration = (areas: GameState["exploration"]["areas"] = []): GameState["exploration"] => ({
areas: areas,
craftedCombatMultiplier: 1,
craftedClickMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedGoldMultiplier: 1,
craftedRecipeIds: [],
materials: [],
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
achievements: [],
adventurers: [],
baseClickPower: 1,
bosses: [],
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
equipment: [],
exploration: makeExploration(),
lastTickAt: 0,
player: { avatar: null, characterName: "T", discordId: DISCORD_ID, discriminator: "0", totalClicks: 0, totalGoldEarned: 0, username: "u" },
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [],
resources: { crystals: 0, essence: 0, gold: 0, runestones: 0 },
schemaVersion: 1,
upgrades: [],
zones: [],
...overrides,
} as GameState);
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
avatar: null,
characterName: "TestChar",
createdAt: 0,
discordId: DISCORD_ID,
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
loginStreak: 1,
username: "test_user",
...overrides,
});
describe("debug route", () => {
let app: Hono;
let prisma: {
gameState: {
findUnique: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
};
player: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
vi.clearAllMocks();
const { debugRouter } = await import("../../src/routes/debug.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/debug", debugRouter);
});
const forceUnlocks = () =>
app.fetch(new Request("http://localhost/debug/force-unlocks", { method: "POST" }));
const hardReset = () =>
app.fetch(new Request("http://localhost/debug/hard-reset", { method: "POST" }));
describe("POST /force-unlocks", () => {
it("returns 404 when no game state found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await forceUnlocks();
expect(res.status).toBe(404);
});
it("returns 200 with all zeros when no stale locks exist", async () => {
const state = makeState({
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
expect(res.status).toBe(200);
const body = await res.json() as {
bossesUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
zonesUnlocked: number;
};
expect(body.zonesUnlocked).toBe(0);
expect(body.explorationUnlocked).toBe(0);
});
it("unlocks verdant_vale when it is locked and has no requirements", async () => {
const state = makeState({
zones: [{ id: "verdant_vale", status: "locked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
expect(res.status).toBe(200);
const body = await res.json() as { zonesUnlocked: number };
expect(body.zonesUnlocked).toBe(1);
});
it("does not unlock zone when boss condition is not met", async () => {
const state = makeState({
bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"],
quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"],
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { zonesUnlocked: number };
expect(body.zonesUnlocked).toBe(0);
});
it("does not unlock zone when quest condition is not met", async () => {
const state = makeState({
bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"],
quests: [{ id: "ancient_ruins", status: "active" }] as GameState["quests"],
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { zonesUnlocked: number };
expect(body.zonesUnlocked).toBe(0);
});
it("unlocks zone when both boss and quest conditions are met", async () => {
const state = makeState({
bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"],
quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"],
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { zonesUnlocked: number };
expect(body.zonesUnlocked).toBe(1);
});
it("unlocks a quest when zone is unlocked and prerequisites are met", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"],
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { questsUnlocked: number };
expect(body.questsUnlocked).toBe(1);
});
it("does not unlock quest when zone is locked", async () => {
/*
* Use shattered_ruins (requires forest_giant defeated) so applyZoneUnlocks
* cannot auto-unlock it, keeping it locked when applyQuestUnlocks runs.
*/
const state = makeState({
bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"],
quests: [{ id: "necromancer_tower", status: "locked" }] as GameState["quests"],
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { questsUnlocked: number };
expect(body.questsUnlocked).toBe(0);
});
it("does not unlock quest when zone is not in state", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"],
zones: [] as GameState["zones"],
});
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 { questsUnlocked: number };
expect(body.questsUnlocked).toBe(0);
});
it("does not unlock quest when it is already available", async () => {
const state = makeState({
quests: [{ id: "first_steps", status: "available" }] as GameState["quests"],
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { questsUnlocked: number };
expect(body.questsUnlocked).toBe(0);
});
it("does not unlock quest when prerequisites are not completed", async () => {
const state = makeState({
quests: [{ id: "goblin_camp", status: "locked" }] as GameState["quests"],
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { questsUnlocked: number };
expect(body.questsUnlocked).toBe(0);
});
it("unlocks the first boss in a zone when the zone is unlocked", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "locked" }] as GameState["bosses"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(1);
});
it("does not unlock boss when prestige requirement is not met", async () => {
const state = makeState({
bosses: [{ id: "the_first_light", status: "locked" }] as GameState["bosses"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
zones: [{ id: "celestial_reaches", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("does not unlock boss when previous boss is not defeated", async () => {
const state = makeState({
bosses: [
{ id: "troll_king", status: "available" },
{ id: "lich_queen", status: "locked" },
] as GameState["bosses"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("does not unlock boss when previous boss is not in state", async () => {
const state = makeState({
bosses: [{ id: "lich_queen", status: "locked" }] as GameState["bosses"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("unlocks next boss when previous boss is defeated", async () => {
const state = makeState({
bosses: [
{ id: "troll_king", status: "defeated" },
{ id: "lich_queen", status: "locked" },
] as GameState["bosses"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(1);
});
it("returns explorationUnlocked=0 when exploration is undefined", async () => {
const state = makeState({
exploration: undefined as unknown as GameState["exploration"],
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { explorationUnlocked: number };
expect(body.explorationUnlocked).toBe(0);
});
it("unlocks exploration area when its zone is unlocked", async () => {
const state = makeState({
exploration: makeExploration([
{ id: "verdant_meadow", status: "locked" } as GameState["exploration"]["areas"][0],
]),
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { explorationUnlocked: number };
expect(body.explorationUnlocked).toBe(1);
});
it("does not unlock exploration area when zone is not unlocked", async () => {
const state = makeState({
exploration: makeExploration([
{ id: "vm_e1", status: "locked" } as GameState["exploration"]["areas"][0],
]),
zones: [] as GameState["zones"],
});
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 { explorationUnlocked: number };
expect(body.explorationUnlocked).toBe(0);
});
it("does not unlock exploration area when it is already available", async () => {
const state = makeState({
exploration: makeExploration([
{ id: "verdant_meadow", status: "available" } as GameState["exploration"]["areas"][0],
]),
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
});
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 { explorationUnlocked: number };
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();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("omits signature when ANTI_CHEAT_SECRET is not set", async () => {
delete process.env.ANTI_CHEAT_SECRET;
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeUndefined();
});
it("returns 500 when DB throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await forceUnlocks();
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 forceUnlocks();
expect(res.status).toBe(500);
});
});
describe("POST /hard-reset", () => {
it("returns 404 when no player found", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
const res = await hardReset();
expect(res.status).toBe(404);
});
it("returns 200 with a fresh state on success", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await hardReset();
expect(res.status).toBe(200);
const body = await res.json() as {
loginBonus: null;
loginStreak: number;
schemaOutdated: boolean;
};
expect(body.loginBonus).toBeNull();
expect(body.schemaOutdated).toBe(false);
expect(body.loginStreak).toBe(1);
});
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await hardReset();
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.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await hardReset();
expect(res.status).toBe(500);
});
it("returns 500 when DB throws a non-Error value", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw error");
const res = await hardReset();
expect(res.status).toBe(500);
});
});
});
+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 DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1; const CURRENT_SCHEMA_VERSION = 1;
@@ -200,6 +204,75 @@ describe("game route", () => {
expect(body.offlineGold).toBeGreaterThan(0); expect(body.offlineGold).toBeGreaterThan(0);
expect(body.offlineEssence).toBeGreaterThan(0); expect(body.offlineEssence).toBeGreaterThan(0);
}); });
it("syncs updated avatar from Discord into the returned state", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("new_hash");
});
it("continues loading when the avatar DB update fails", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("keeps stored avatar when Discord returns null", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("stored_hash");
});
}); });
describe("POST /save", () => { describe("POST /save", () => {
+49
View File
@@ -104,4 +104,53 @@ describe("discord service", () => {
await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); 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" });
});
});
}); });
+26
View File
@@ -319,6 +319,32 @@ describe("buildPostPrestigeState", () => {
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true); expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
}); });
it("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
const legacyDefeatedBoss = {
bountyRunestones: 5,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "troll_king",
maxHp: 100,
name: "Troll King",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "verdant_vale",
};
const state = makeMinimalState({ bosses: [ legacyDefeatedBoss ] as GameState["bosses"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
const matchingBoss = prestigeState.bosses.find((boss) => {
return boss.id === "troll_king";
});
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
});
it("accumulates completed quests into lifetime total", () => { it("accumulates completed quests into lifetime total", () => {
const quest = { const quest = {
id: "q_1", id: "q_1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.1.1", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+33
View File
@@ -21,12 +21,14 @@ import type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
LoadResponse, LoadResponse,
PrestigeRequest, PrestigeRequest,
PrestigeResponse, PrestigeResponse,
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,
@@ -256,6 +258,34 @@ const craftRecipe = async(
}); });
}; };
/**
* Sends a request to fix any missing unlocks in the player's game state.
* @returns The corrected game state and counts of what was unlocked.
*/
const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
return await fetchJson<ForceUnlocksResponse>("/debug/force-unlocks", {
method: "POST",
});
};
/**
* Syncs any content added after the player's save was created into their save.
* @returns The updated game state and counts of what was added per content type.
*/
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
method: "POST",
});
};
/**
* Performs a complete hard reset of the player's game state via the debug endpoint.
* @returns The fresh game state as a LoadResponse.
*/
const debugHardReset = async(): Promise<LoadResponse> => {
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
};
/** /**
* Fetches a public player profile by Discord ID. * Fetches a public player profile by Discord ID.
* @param discordId - The Discord ID of the player to look up. * @param discordId - The Discord ID of the player to look up.
@@ -288,6 +318,9 @@ export {
challengeBoss, challengeBoss,
collectExploration, collectExploration,
craftRecipe, craftRecipe,
debugHardReset,
forceUnlocks,
syncNewContent,
getAbout, getAbout,
getAuthUrl, getAuthUrl,
getPublicProfile, getPublicProfile,
+39 -19
View File
@@ -31,14 +31,24 @@ const howToPlay = [
body: body:
"Purchase upgrades to multiply the gold and essence output of specific" "Purchase upgrades to multiply the gold and essence output of specific"
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent" + " adventurer tiers, or boost your whole guild. Upgrades are permanent"
+ " for the current run and compound with each other.", + " for the current run and stack multiplicatively — two ×2 upgrades"
+ " targeting the same adventurer combine to give ×4, not ×3. Global"
+ " upgrades multiply on top of adventurer-specific ones, so stacking"
+ " both types compounds the effect significantly. Late in a run, look"
+ " for the Essence Infusion upgrades — five powerful global multipliers"
+ " purchasable purely with essence, giving that resource an ongoing"
+ " use when gold upgrades are all bought.",
title: "🔧 Upgrades", title: "🔧 Upgrades",
}, },
{ {
body: body:
"Send your guild on quests that complete over time and reward gold," "Send your guild on quests that complete over time and reward gold,"
+ " essence, crystals, equipment, and upgrades. Multiple quests can run" + " essence, crystals, equipment, and upgrades. Multiple quests can run"
+ " simultaneously. Completing quests also unlocks new zones.", + " simultaneously. Completing quests also unlocks new zones."
+ " Each quest has a failure chance that increases in later zones"
+ " (from 10% in the starting zone up to 40% in the hardest zones)."
+ " If a quest fails, no rewards are granted and the quest resets —"
+ " your party must be sent again to retry it.",
title: "📜 Quests", title: "📜 Quests",
}, },
{ {
@@ -59,10 +69,12 @@ const howToPlay = [
{ {
body: body:
"Earn equipment from boss drops and quest rewards. Each piece provides" "Earn equipment from boss drops and quest rewards. Each piece provides"
+ " bonuses to gold income, click power, or combat. Rarer equipment" + " bonuses to gold income, click power, or boss combat DPS. Rarer"
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a" + " equipment provides stronger bonuses. Note: combat bonuses only"
+ " named set) to unlock escalating set bonuses shown at the top of the" + " affect boss fights — quest combat power is determined solely by"
+ " Equipment panel.", + " your adventurers. Equip matching set pieces (2 or 3 of a named set)"
+ " to unlock escalating set bonuses shown at the top of the Equipment"
+ " panel.",
title: "🗡️ Equipment & Sets", title: "🗡️ Equipment & Sets",
}, },
{ {
@@ -111,7 +123,11 @@ const howToPlay = [
+ " real-time and reward gold, essence, and crafting materials when" + " real-time and reward gold, essence, and crafting materials when"
+ " collected. Each area has a set duration — short explorations are" + " collected. Each area has a set duration — short explorations are"
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas" + " faster but longer ones offer rarer finds. A 📖 icon marks areas"
+ " you've collected from at least once, unlocking a Codex entry.", + " you've collected from at least once, unlocking a Codex entry."
+ " Exploration zones are locked until the corresponding main-game"
+ " zone is unlocked — which requires defeating that zone's final boss"
+ " and completing its final quest. The Exploration tab shows the"
+ " specific boss and quest required for each locked zone.",
title: "🗺️ Exploration", title: "🗺️ Exploration",
}, },
{ {
@@ -154,10 +170,12 @@ const howToPlay = [
{ {
body: body:
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets." "Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
+ " Each item provides bonuses to gold income, combat power, or click" + " Each item provides bonuses to gold income, boss combat DPS, or click"
+ " power. Only one item per slot can be equipped at a time — visit the" + " power. Combat bonuses only affect boss fights — quest combat power"
+ " Equipment panel to manage your loadout. Your currently equipped" + " is determined solely by your adventurers. Only one item per slot"
+ " items are displayed on your character sheet and public profile.", + " can be equipped at a time — visit the Equipment panel to manage"
+ " your loadout. Your currently equipped items are displayed on your"
+ " character sheet and public profile.",
title: "🗡️ Equipment", title: "🗡️ Equipment",
}, },
{ {
@@ -181,14 +199,16 @@ const howToPlay = [
}, },
{ {
body: body:
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest" "Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
+ " automatically sends your party on the highest-zone available quest" + " panels! Auto-Quest automatically sends your party on the"
+ " as soon as one completes, skipping quests whose combat power" + " highest-zone available quest as soon as one completes, skipping"
+ " requirement isn't met. Auto-Boss automatically challenges the" + " quests whose combat power requirement isn't met. Auto-Boss"
+ " highest available boss as soon as one is ready. Both can be toggled" + " automatically challenges the highest available boss as soon as one"
+ " on or off at any time using the 🤖 Auto button in each panel" + " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
+ " header.", + " runestones) automatically purchases the highest-tier adventurer you"
title: "🤖 Auto-Quest & Auto-Boss", + " can currently afford each tick, keeping your income growing after a"
+ " prestige without any manual clicks.",
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
}, },
{ {
body: body:
@@ -9,7 +9,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement } from "@elysium/types"; import type { Achievement, GameState } from "@elysium/types";
/** /**
* Returns the plural form of a word based on a count. * Returns the plural form of a word based on a count.
@@ -54,9 +54,50 @@ const conditionDescription = (
} }
}; };
/**
* Returns the player's current progress value toward an achievement's unlock condition,
* mirroring the logic used by the tick engine's checkAchievements function.
* @param achievement - The achievement to evaluate progress for.
* @param state - The current game state.
* @returns The current numeric progress toward the achievement condition.
*/
const getCurrentProgress = (
achievement: Achievement,
state: GameState,
): number => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
return state.player.totalGoldEarned;
case "totalClicks":
return state.player.totalClicks;
case "bossesDefeated":
return state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
case "questsCompleted":
return state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
case "adventurerTotal":
return state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.owned;
}).length;
default:
return 0;
}
};
interface AchievementCardProperties { interface AchievementCardProperties {
readonly achievement: Achievement; readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
readonly progressValue: number;
} }
/** /**
@@ -64,14 +105,18 @@ interface AchievementCardProperties {
* @param props - The achievement card properties. * @param props - The achievement card properties.
* @param props.achievement - The achievement to display. * @param props.achievement - The achievement to display.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @param props.progressValue - The player's current progress toward the unlock condition.
* @returns The JSX element. * @returns The JSX element.
*/ */
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
const AchievementCard = ({ const AchievementCard = ({
achievement, achievement,
formatNumber, formatNumber,
progressValue,
}: AchievementCardProperties): JSX.Element => { }: AchievementCardProperties): JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null; const isUnlocked = achievement.unlockedAt !== null;
const crystals = achievement.reward?.crystals; const crystals = achievement.reward?.crystals;
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
return ( return (
<div className={`achievement-card ${isUnlocked <div className={`achievement-card ${isUnlocked
@@ -88,6 +133,19 @@ const AchievementCard = ({
<p className="achievement-condition"> <p className="achievement-condition">
{conditionDescription(achievement, formatNumber)} {conditionDescription(achievement, formatNumber)}
</p> </p>
{!isUnlocked
&& <div className="achievement-progress">
<progress
max={achievement.condition.amount}
value={cappedProgress}
/>
<span className="achievement-progress-label">
{formatNumber(progressValue)}
{" / "}
{formatNumber(achievement.condition.amount)}
</span>
</div>
}
{crystals !== undefined {crystals !== undefined
&& <p className="achievement-reward"> && <p className="achievement-reward">
{"💎 +"} {"💎 +"}
@@ -163,6 +221,7 @@ const AchievementPanel = (): JSX.Element => {
achievement={achievement} achievement={achievement}
formatNumber={formatNumber} formatNumber={formatNumber}
key={achievement.id} key={achievement.id}
progressValue={getCurrentProgress(achievement, state)}
/> />
); );
})} })}
@@ -143,6 +143,10 @@ const AdventurerCard = ({
{" essence/s each"} {" essence/s each"}
</p> </p>
} }
<p>
{formatNumber(adventurer.combatPower)}
{" combat power each"}
</p>
</div> </div>
<div className="adventurer-count"> <div className="adventurer-count">
{"×"} {"×"}
@@ -171,7 +175,7 @@ const AdventurerCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const AdventurerPanel = (): JSX.Element => { const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, toggleAutoAdventurer } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => { const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size")); 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 { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -213,11 +222,34 @@ const AdventurerPanel = (): JSX.Element => {
<section className="panel adventurer-panel"> <section className="panel adventurer-panel">
<div className="panel-header"> <div className="panel-header">
<h2>{"Adventurers"}</h2> <h2>{"Adventurers"}</h2>
<LockToggle <div className="panel-header-controls">
lockedCount={locked.length} {autoAdventurerUnlocked
onToggle={handleToggle} ? <button
showLocked={showLocked} className={`auto-toggle-btn ${
/> autoAdventurerOn
? "auto-toggle-on"
: "auto-toggle-off"
}`}
onClick={toggleAutoAdventurer}
title={
"Automatically purchase the highest-tier"
+ " affordable adventurer"
}
type="button"
>
{"🤖 Auto: "}
{autoAdventurerOn
? "ON"
: "OFF"}
</button>
: null
}
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
</div> </div>
<div className="batch-selector"> <div className="batch-selector">
{batchOptions.map((option) => { {batchOptions.map((option) => {
@@ -235,6 +235,7 @@ const BossPanel = (): JSX.Element => {
toggleAutoBoss, toggleAutoBoss,
autoBossLastResult, autoBossLastResult,
autoBossError, autoBossError,
bossError,
} = useGame(); } = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>( const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null, null,
@@ -266,6 +267,23 @@ const BossPanel = (): JSX.Element => {
} }
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneBosses = bosses.filter((boss) => { const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === activeZoneId; return boss.zoneId === activeZoneId;
}); });
@@ -362,6 +380,13 @@ const BossPanel = (): JSX.Element => {
</div> </div>
</div> </div>
{bossError === null
? null
: <p className="auto-boss-error">
{"⚠️ "}
{bossError}
</p>
}
{autoBossError === null {autoBossError === null
? null ? null
: <p className="auto-boss-error"> : <p className="auto-boss-error">
@@ -385,6 +410,27 @@ const BossPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats"> <div className="party-combat-stats">
<div className="combat-stat"> <div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span> <span className="stat-label">{"⚔️ Party DPS"}</span>
+254
View File
@@ -0,0 +1,254 @@
/**
* @file Debug panel component with administrative tools for correcting player state.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Panel has multiple async handlers and conditional renders */
/* eslint-disable stylistic/max-len -- Debug descriptions require full explanatory text */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
interface SyncNewContentResult {
achievementsAdded: number;
adventurersAdded: number;
bossesAdded: number;
equipmentAdded: number;
explorationAreasAdded: number;
questsAdded: number;
upgradesAdded: number;
zonesAdded: number;
}
/**
* 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 ]> = [
[ result.zonesAdded, "zone(s)" ],
[ result.questsAdded, "quest(s)" ],
[ result.bossesAdded, "boss(es)" ],
[ result.explorationAreasAdded, "exploration area(s)" ],
[ result.adventurersAdded, "adventurer tier(s)" ],
[ result.upgradesAdded, "upgrade(s)" ],
[ result.equipmentAdded, "equipment item(s)" ],
[ result.achievementsAdded, "achievement(s)" ],
];
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 `Added ${String(total)} new item(s) to your save: ${parts.join(", ")}.`;
};
interface ForceUnlocksResult {
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
}
/**
* 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 ]> = [
[ result.zonesUnlocked, "zone(s)" ],
[ result.questsUnlocked, "quest(s)" ],
[ result.bossesUnlocked, "boss(es)" ],
[ result.explorationUnlocked, "exploration area(s)" ],
[ result.adventurersUnlocked, "adventurer tier(s)" ],
[ result.upgradesUnlocked, "upgrade(s)" ],
[ result.equipmentUnlocked, "equipment item(s)" ],
[ result.storyUnlocked, "story chapter(s)" ],
];
const parts = entries.
filter(([ count ]) => {
return count > 0;
}).
map(([ count, label ]) => {
return `${String(count)} ${label}`;
});
if (parts.length === 0) {
return "Everything looks correct — no missing unlocks were found.";
}
const total = entries.reduce((sum, [ count ]) => {
return sum + count;
}, 0);
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
};
/**
* Renders the debug panel with tools for fixing stuck game state.
* @returns The JSX element.
*/
const DebugPanel = (): JSX.Element => {
const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
function handleOpenForceUnlocks(): void {
setForceUnlocksResult(null);
setActiveModal("force-unlocks");
}
function handleOpenSyncNewContent(): void {
setSyncNewContentResult(null);
setActiveModal("sync-new-content");
}
function handleOpenHardReset(): void {
setActiveModal("hard-reset");
}
function handleCancel(): void {
setActiveModal(null);
}
function handleConfirmForceUnlocks(): void {
setActiveModal(null);
void (async(): Promise<void> => {
const result = await forceUnlocks();
setForceUnlocksResult(buildForceUnlocksMessage(result));
})();
}
function handleConfirmSyncNewContent(): void {
setActiveModal(null);
void (async(): Promise<void> => {
const result = await syncNewContent();
setSyncNewContentResult(buildSyncNewContentMessage(result));
})();
}
function handleConfirmHardReset(): void {
setActiveModal(null);
void debugHardReset();
}
return (
<div className="panel">
<h2>{"🔧 Debug Tools"}</h2>
<p className="panel-description">
{
"These tools are intended to fix broken game state. Use them with care — some operations are irreversible."
}
</p>
<div className="debug-actions">
<div className="debug-action-card">
<h3>{"🔓 Force Unlocks"}</h3>
<p>
{
"Scans your game state and unlocks any zones, quests, and bosses that you have earned but that are still incorrectly locked."
}
</p>
<button
className="action-button"
disabled={isLoading}
onClick={handleOpenForceUnlocks}
type="button"
>
{"Force Unlocks"}
</button>
{forceUnlocksResult !== null
&& <p className="debug-result-message">{forceUnlocksResult}</p>
}
</div>
<div className="debug-action-card">
<h3>{"🔄 Sync New Content"}</h3>
<p>
{
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
}
</p>
<button
className="action-button"
disabled={isLoading}
onClick={handleOpenSyncNewContent}
type="button"
>
{"Sync New Content"}
</button>
{syncNewContentResult !== null
&& <p className="debug-result-message">{syncNewContentResult}</p>
}
</div>
<div className="debug-action-card">
<h3>{"💀 Hard Reset"}</h3>
<p>
{
"Completely wipes all progress and resets your account to a brand-new state. This cannot be undone."
}
</p>
<button
className="action-button action-button-danger"
disabled={isLoading}
onClick={handleOpenHardReset}
type="button"
>
{"Hard Reset"}
</button>
</div>
</div>
{activeModal === "force-unlocks"
&& <ConfirmationModal
confirmLabel="Yes, Force Unlocks"
description="This will scan your save data and grant access to any zones, quests, and bosses that you have already earned but are incorrectly locked. This operation is safe and non-destructive."
isLoading={isLoading}
onCancel={handleCancel}
onConfirm={handleConfirmForceUnlocks}
title="Force Unlocks"
/>
}
{activeModal === "sync-new-content"
&& <ConfirmationModal
confirmLabel="Yes, Sync New Content"
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
isLoading={isLoading}
onCancel={handleCancel}
onConfirm={handleConfirmSyncNewContent}
title="Sync New Content"
/>
}
{activeModal === "hard-reset"
&& <ConfirmationModal
confirmLabel="Yes, Wipe Everything"
description="This will permanently delete all of your current progress — gold, adventurers, upgrades, bosses, quests, and zones — and reset your account to a brand-new state. Lifetime stats are preserved, but everything else will be gone forever."
isLoading={isLoading}
onCancel={handleCancel}
onConfirm={handleConfirmHardReset}
title="⚠️ Hard Reset — This Cannot Be Undone"
/>
}
</div>
);
};
export { DebugPanel };
@@ -7,6 +7,7 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js"; import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
@@ -30,7 +31,7 @@ const bonusDescription = (item: Equipment): string => {
const parts: Array<string> = []; const parts: Array<string> = [];
if (item.bonus.combatMultiplier !== undefined) { if (item.bonus.combatMultiplier !== undefined) {
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100); const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Combat`); parts.push(`+${String(pct)}% Boss Combat`);
} }
if (item.bonus.goldMultiplier !== undefined) { if (item.bonus.goldMultiplier !== undefined) {
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100); const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
@@ -188,6 +189,20 @@ const EquipmentCard = ({
); );
}; };
/**
* Computes a combined power score for sorting — sum of all bonus multipliers.
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
* @param item - The equipment piece whose bonus multipliers are summed.
* @returns The combined bonus value.
*/
const equipmentPower = (item: Equipment): number => {
return (
(item.bonus.combatMultiplier ?? 1)
+ (item.bonus.goldMultiplier ?? 1)
+ (item.bonus.clickMultiplier ?? 1)
);
};
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ]; const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
const slotLabel: Record<EquipmentType, string> = { const slotLabel: Record<EquipmentType, string> = {
armour: "🛡️ Armour", armour: "🛡️ Armour",
@@ -261,7 +276,7 @@ const EquipmentPanel = (): JSX.Element => {
} }
if (bonus.combatMultiplier !== undefined) { if (bonus.combatMultiplier !== undefined) {
const pct = Math.round((bonus.combatMultiplier - 1) * 100); const pct = Math.round((bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`); parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
} }
if (bonus.clickMultiplier !== undefined) { if (bonus.clickMultiplier !== undefined) {
const pct = Math.round((bonus.clickMultiplier - 1) * 100); const pct = Math.round((bonus.clickMultiplier - 1) * 100);
@@ -320,6 +335,8 @@ const EquipmentPanel = (): JSX.Element => {
{slotOrder.map((slotType) => { {slotOrder.map((slotType) => {
const items = equipment.filter((item) => { const items = equipment.filter((item) => {
return item.type === slotType && (showLocked || item.owned); return item.type === slotType && (showLocked || item.owned);
}).sort((a, b) => {
return equipmentPower(a) - equipmentPower(b);
}); });
return ( return (
<div className="equipment-slot-section" key={slotType}> <div className="equipment-slot-section" key={slotType}>
@@ -6,6 +6,7 @@
*/ */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js"; import { EXPLORATION_AREAS } from "../../data/explorations.js";
@@ -46,11 +47,21 @@ const formatDuration = (seconds: number): string => {
/** /**
* Computes the time remaining for an exploration in progress. * Computes the time remaining for an exploration in progress.
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
* Falls back to startedAt + durationSeconds for saves predating the endsAt field.
* @param endsAt - The server-computed completion timestamp, if available.
* @param startedAt - The timestamp when exploration started. * @param startedAt - The timestamp when exploration started.
* @param durationSeconds - The total duration in seconds. * @param durationSeconds - The total duration in seconds.
* @returns The remaining seconds. * @returns The remaining seconds.
*/ */
const timeRemaining = (startedAt: number, durationSeconds: number): number => { const timeRemaining = (
endsAt: number | undefined,
startedAt: number,
durationSeconds: number,
): number => {
if (endsAt !== undefined) {
return Math.max(0, (endsAt - Date.now()) / 1000);
}
const elapsed = (Date.now() - startedAt) / 1000; const elapsed = (Date.now() - startedAt) / 1000;
return Math.max(0, durationSeconds - elapsed); return Math.max(0, durationSeconds - elapsed);
}; };
@@ -81,7 +92,24 @@ const ExplorationPanel = (): JSX.Element => {
); );
} }
const { zones, exploration: explorationState } = state; const { zones, exploration: explorationState, bosses, quests } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneAreas = EXPLORATION_AREAS.filter((area) => { const zoneAreas = EXPLORATION_AREAS.filter((area) => {
return area.zoneId === activeZoneId; return area.zoneId === activeZoneId;
@@ -210,6 +238,27 @@ const ExplorationPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock exploration by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="exploration-list"> <div className="exploration-list">
{zoneAreas.map((area) => { {zoneAreas.map((area) => {
const areaState = explorationState?.areas.find((explorationArea) => { const areaState = explorationState?.areas.find((explorationArea) => {
@@ -217,9 +266,10 @@ const ExplorationPanel = (): JSX.Element => {
}); });
const status = areaState?.status ?? "locked"; const status = areaState?.status ?? "locked";
const startedAt = areaState?.startedAt ?? 0; const startedAt = areaState?.startedAt ?? 0;
const endsAt = areaState?.endsAt;
const isReady const isReady
= status === "in_progress" = status === "in_progress"
&& timeRemaining(startedAt, area.durationSeconds) <= 0; && timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
const isPending = pendingAreaId === area.id; const isPending = pendingAreaId === area.id;
function handleStartClick(): void { function handleStartClick(): void {
@@ -276,9 +326,8 @@ const ExplorationPanel = (): JSX.Element => {
{status === "in_progress" && !isReady {status === "in_progress" && !isReady
&& <span className="quest-badge active"> && <span className="quest-badge active">
{"⏳ "} {"⏳ "}
{formatDuration( {/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
Math.ceil(timeRemaining(startedAt, area.durationSeconds)), {formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
)}
{" remaining"} {" remaining"}
</span> </span>
} }
+5 -3
View File
@@ -23,6 +23,7 @@ import { CodexToast } from "./codexToast.js";
import { CompanionPanel } from "./companionPanel.js"; import { CompanionPanel } from "./companionPanel.js";
import { CraftingPanel } from "./craftingPanel.js"; import { CraftingPanel } from "./craftingPanel.js";
import { DailyChallengePanel } from "./dailyChallengePanel.js"; import { DailyChallengePanel } from "./dailyChallengePanel.js";
import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js"; import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
@@ -57,7 +58,8 @@ type Tab =
| "crafting" | "crafting"
| "character" | "character"
| "companions" | "companions"
| "story"; | "story"
| "debug";
const baseTabs: Array<{ id: Tab; label: string }> = [ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "adventurers", label: "⚔️ Adventurers" }, { id: "adventurers", label: "⚔️ Adventurers" },
@@ -78,6 +80,7 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "story", label: "📖 Story" }, { id: "story", label: "📖 Story" },
{ id: "codex", label: "🗺️ Codex" }, { id: "codex", label: "🗺️ Codex" },
{ id: "about", label: "️ About" }, { id: "about", label: "️ About" },
{ id: "debug", label: "🔧 Debug" },
]; ];
/** /**
@@ -132,7 +135,6 @@ const GameLayout = (): JSX.Element => {
); );
} }
const profileUrl = `/profile/${state.player.discordId}`;
const codexBadgeCount = pendingCodexEntryIds.length; const codexBadgeCount = pendingCodexEntryIds.length;
const storyBadgeCount = pendingStoryChapterIds.length; const storyBadgeCount = pendingStoryChapterIds.length;
@@ -157,7 +159,6 @@ const GameLayout = (): JSX.Element => {
onEditProfile={handleOpenEditProfile} onEditProfile={handleOpenEditProfile}
onForceSync={forceSync} onForceSync={forceSync}
prestigeCount={state.prestige.count} prestigeCount={state.prestige.count}
profileUrl={profileUrl}
resources={state.resources} resources={state.resources}
runestones={state.prestige.runestones} runestones={state.prestige.runestones}
transcendenceCount={state.transcendence?.count ?? 0} transcendenceCount={state.transcendence?.count ?? 0}
@@ -242,6 +243,7 @@ const GameLayout = (): JSX.Element => {
{activeTab === "story" && <StoryPanel />} {activeTab === "story" && <StoryPanel />}
{activeTab === "codex" && <CodexPanel />} {activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />} {activeTab === "about" && <AboutPanel />}
{activeTab === "debug" && <DebugPanel />}
</div> </div>
</main> </main>
</div> </div>
@@ -156,6 +156,9 @@ const LeaderboardPage = (): JSX.Element => {
<p className="leaderboard-subtitle"> <p className="leaderboard-subtitle">
{"The mightiest adventurers in Elysium"} {"The mightiest adventurers in Elysium"}
</p> </p>
<p className="leaderboard-update-note">
{"🔄 Rankings update when you prestige."}
</p>
</div> </div>
<div className="leaderboard-tabs"> <div className="leaderboard-tabs">
+24 -1
View File
@@ -89,6 +89,7 @@ const PrestigePanel = (): JSX.Element => {
buyPrestigeUpgrade, buyPrestigeUpgrade,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
toggleAutoAdventurer,
toggleAutoPrestige, toggleAutoPrestige,
triggerPrestigeToast, triggerPrestigeToast,
} = useGame(); } = useGame();
@@ -110,7 +111,7 @@ const PrestigePanel = (): JSX.Element => {
); );
} }
const { prestige: prestigeData, player } = state; const { autoAdventurer, prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count); const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold; const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = calculateRunestonePreview( const runestonePreview = calculateRunestonePreview(
@@ -173,6 +174,10 @@ const PrestigePanel = (): JSX.Element => {
void handlePrestige(); void handlePrestige();
} }
function handleAutoAdventurerToggle(): void {
toggleAutoAdventurer();
}
function handleAutoPrestigeToggle(): void { function handleAutoPrestigeToggle(): void {
toggleAutoPrestige(); toggleAutoPrestige();
} }
@@ -347,6 +352,9 @@ const PrestigePanel = (): JSX.Element => {
= prestigeData.runestones >= upgrade.runestonesCost; = prestigeData.runestones >= upgrade.runestonesCost;
const isLoading = buyingId === upgrade.id; const isLoading = buyingId === upgrade.id;
const isAutoAdventurerToggle
= upgrade.id === "auto_adventurer" && purchased;
const autoAdventurerEnabled = autoAdventurer ?? false;
const isAutoPrestigeToggle const isAutoPrestigeToggle
= upgrade.id === "auto_prestige" && purchased; = upgrade.id === "auto_prestige" && purchased;
const autoPrestigeEnabled const autoPrestigeEnabled
@@ -381,6 +389,21 @@ const PrestigePanel = (): JSX.Element => {
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`} : `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
</p> </p>
</div> </div>
{isAutoAdventurerToggle
? <button
className={`auto-prestige-toggle ${
autoAdventurerEnabled
? "enabled"
: "disabled"
}`}
onClick={handleAutoAdventurerToggle}
type="button"
>
{autoAdventurerEnabled
? "⚡ Auto ON"
: "⏸ Auto OFF"}
</button>
: null}
{isAutoPrestigeToggle {isAutoPrestigeToggle
? <button ? <button
className={`auto-prestige-toggle ${ className={`auto-prestige-toggle ${
+55 -2
View File
@@ -4,12 +4,14 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ /* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Many local variables needed for quest state */ /* eslint-disable max-statements -- Many local variables needed for quest state */
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { zoneFailureChance } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
@@ -143,8 +145,17 @@ const QuestCard = ({
: null} : null}
</> </>
} }
{quest.status === "available"
&& <p className="quest-failure-chance">
{"🎲 "}
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
{"% failure chance"}
</p>
}
{quest.status === "available" && quest.lastFailedAt !== undefined {quest.status === "available" && quest.lastFailedAt !== undefined
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p> && <p className="quest-failed-hint">
{"⚠️ Last attempt failed — no rewards were granted."}
</p>
} }
{quest.status === "available" {quest.status === "available"
&& <button && <button
@@ -197,7 +208,24 @@ const QuestPanel = (): JSX.Element => {
); );
} }
const { adventurers, autoQuest, quests, zones } = state; const { adventurers, autoQuest, bosses, quests, zones } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
let partyCombatPower = 0; let partyCombatPower = 0;
for (const adventurer of adventurers) { for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count; const contribution = adventurer.combatPower * adventurer.count;
@@ -296,6 +324,31 @@ const QuestPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<p className="quest-failure-note">
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
</p>
<div className="quest-list"> <div className="quest-list">
{visibleQuests.map((quest) => { {visibleQuests.map((quest) => {
return ( return (
+38 -2
View File
@@ -11,7 +11,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Upgrade } from "@elysium/types"; import type { Adventurer, Upgrade } from "@elysium/types";
interface UpgradeCardProperties { interface UpgradeCardProperties {
readonly upgrade: Upgrade; readonly upgrade: Upgrade;
@@ -20,6 +20,7 @@ interface UpgradeCardProperties {
readonly currentCrystals: number; readonly currentCrystals: number;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
readonly adventurers: ReadonlyArray<Adventurer>;
} }
/** /**
@@ -31,6 +32,7 @@ interface UpgradeCardProperties {
* @param props.currentCrystals - The current crystals amount. * @param props.currentCrystals - The current crystals amount.
* @param props.unlockHint - Optional hint for how to unlock this upgrade. * @param props.unlockHint - Optional hint for how to unlock this upgrade.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @param props.adventurers - The list of adventurers, used to resolve the affected adventurer name.
* @returns The JSX element. * @returns The JSX element.
*/ */
const UpgradeCard = ({ const UpgradeCard = ({
@@ -40,8 +42,14 @@ const UpgradeCard = ({
currentCrystals, currentCrystals,
unlockHint, unlockHint,
formatNumber, formatNumber,
adventurers,
}: UpgradeCardProperties): JSX.Element => { }: UpgradeCardProperties): JSX.Element => {
const { buyUpgrade } = useGame(); const { buyUpgrade } = useGame();
const adventurerName = upgrade.adventurerId === undefined
? undefined
: adventurers.find((adventurer) => {
return adventurer.id === upgrade.adventurerId;
})?.name;
const canAfford const canAfford
= currentGold >= upgrade.costGold = currentGold >= upgrade.costGold
&& currentEssence >= upgrade.costEssence && currentEssence >= upgrade.costEssence
@@ -64,6 +72,13 @@ const UpgradeCard = ({
{upgrade.name} {upgrade.name}
</span> </span>
<span className="upgrade-desc">{upgrade.description}</span> <span className="upgrade-desc">{upgrade.description}</span>
{adventurerName === undefined
? null
: <span className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</span>
}
</div> </div>
); );
} }
@@ -79,6 +94,13 @@ const UpgradeCard = ({
<div className="upgrade-info"> <div className="upgrade-info">
<h3>{upgrade.name}</h3> <h3>{upgrade.name}</h3>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier"> <p className="upgrade-multiplier">
{"×"} {"×"}
{upgrade.multiplier} {upgrade.multiplier}
@@ -130,6 +152,13 @@ const UpgradeCard = ({
{upgrade.name} {upgrade.name}
</h3> </h3>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier"> <p className="upgrade-multiplier">
{"×"} {"×"}
{upgrade.multiplier} {upgrade.multiplier}
@@ -181,7 +210,7 @@ const UpgradePanel = (): JSX.Element => {
); );
} }
const { bosses, quests, upgrades, resources } = state; const { adventurers, bosses, quests, upgrades, resources } = state;
const purchased = upgrades.filter((upgrade) => { const purchased = upgrades.filter((upgrade) => {
return upgrade.purchased; return upgrade.purchased;
}); });
@@ -232,6 +261,10 @@ const UpgradePanel = (): JSX.Element => {
{upgrades.length} {upgrades.length}
{" purchased"} {" purchased"}
</p> </p>
<p className="upgrade-stacking-note">
{"💡 Upgrade multipliers stack multiplicatively — two ×2 upgrades"
+ " combine to give ×4, not ×3."}
</p>
{upgrades.length === 0 {upgrades.length === 0
? <p className="empty-state"> ? <p className="empty-state">
{"No upgrades available yet — keep adventuring!"} {"No upgrades available yet — keep adventuring!"}
@@ -240,6 +273,7 @@ const UpgradePanel = (): JSX.Element => {
{available.map((upgrade) => { {available.map((upgrade) => {
return ( return (
<UpgradeCard <UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals} currentCrystals={resources.crystals}
currentEssence={resources.essence} currentEssence={resources.essence}
currentGold={resources.gold} currentGold={resources.gold}
@@ -253,6 +287,7 @@ const UpgradePanel = (): JSX.Element => {
{purchased.map((upgrade) => { {purchased.map((upgrade) => {
return ( return (
<UpgradeCard <UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals} currentCrystals={resources.crystals}
currentEssence={resources.essence} currentEssence={resources.essence}
currentGold={resources.gold} currentGold={resources.gold}
@@ -267,6 +302,7 @@ const UpgradePanel = (): JSX.Element => {
? locked.map((upgrade) => { ? locked.map((upgrade) => {
return ( return (
<UpgradeCard <UpgradeCard
adventurers={adventurers}
currentCrystals={resources.crystals} currentCrystals={resources.crystals}
currentEssence={resources.essence} currentEssence={resources.essence}
currentGold={resources.gold} currentGold={resources.gold}
@@ -0,0 +1,68 @@
/**
* @file Reusable confirmation modal component for destructive operations.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { JSX } from "react";
interface ConfirmationModalProperties {
readonly title: string;
readonly description: string;
readonly confirmLabel: string;
readonly onConfirm: ()=> void;
readonly onCancel: ()=> void;
readonly isLoading: boolean;
}
/**
* Renders a confirmation modal for destructive operations.
* @param props - The modal properties.
* @param props.title - The modal heading.
* @param props.description - Warning text explaining what the operation does.
* @param props.confirmLabel - Label for the confirm button.
* @param props.onConfirm - Callback fired when the player confirms.
* @param props.onCancel - Callback fired when the player cancels.
* @param props.isLoading - Whether the operation is currently in progress.
* @returns The JSX element.
*/
const ConfirmationModal = ({
title,
description,
confirmLabel,
onConfirm,
onCancel,
isLoading,
}: ConfirmationModalProperties): JSX.Element => {
return (
<div className="modal-overlay">
<div className="modal">
<h2>{title}</h2>
<p>{description}</p>
<p className="modal-note">{"Are you sure you want to do this?"}</p>
<div className="modal-actions">
<button
className="modal-close-button modal-button-danger"
disabled={isLoading}
onClick={onConfirm}
type="button"
>
{isLoading
? "Working..."
: confirmLabel}
</button>
<button
className="modal-close-button"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{"Cancel"}
</button>
</div>
</div>
</div>
);
};
export { ConfirmationModal };
+211 -94
View File
@@ -4,12 +4,14 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- Resource bar has many resource and action elements */
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */ /* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
/* eslint-disable max-statements -- Resource bar requires many local computations and handlers */
/* eslint-disable complexity -- Many conditional resource and badge render paths */ /* eslint-disable complexity -- Many conditional resource and badge render paths */
import { useState, type FocusEvent, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { RESOURCE_CAP } from "../../engine/tick.js"; import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
import type { JSX } from "react";
interface ResourceBarProperties { interface ResourceBarProperties {
readonly resources: Resource; readonly resources: Resource;
@@ -17,7 +19,6 @@ interface ResourceBarProperties {
readonly prestigeCount: number; readonly prestigeCount: number;
readonly transcendenceCount: number; readonly transcendenceCount: number;
readonly apotheosisCount: number; readonly apotheosisCount: number;
readonly profileUrl: string;
readonly onEditProfile: ()=> void; readonly onEditProfile: ()=> void;
readonly lastSavedAt: number | null; readonly lastSavedAt: number | null;
readonly isSyncing: boolean; readonly isSyncing: boolean;
@@ -58,7 +59,6 @@ const resourceFullTooltip = [
* @param props.prestigeCount - The number of prestiges completed. * @param props.prestigeCount - The number of prestiges completed.
* @param props.transcendenceCount - The number of transcendences completed. * @param props.transcendenceCount - The number of transcendences completed.
* @param props.apotheosisCount - The number of apotheoses completed. * @param props.apotheosisCount - The number of apotheoses completed.
* @param props.profileUrl - The URL of the player's public profile.
* @param props.onEditProfile - Callback to open the edit profile modal. * @param props.onEditProfile - Callback to open the edit profile modal.
* @param props.lastSavedAt - Timestamp of the last cloud save. * @param props.lastSavedAt - Timestamp of the last cloud save.
* @param props.isSyncing - Whether a sync is currently in progress. * @param props.isSyncing - Whether a sync is currently in progress.
@@ -71,70 +71,168 @@ const ResourceBar = ({
prestigeCount, prestigeCount,
transcendenceCount, transcendenceCount,
apotheosisCount, apotheosisCount,
profileUrl,
onEditProfile, onEditProfile,
lastSavedAt, lastSavedAt,
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProperties): JSX.Element => { }: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError } = useGame(); const { formatNumber, syncError, state } = useGame();
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
const { gold, essence, crystals } = resources; const { gold, essence, crystals } = resources;
const resourceValues = [ gold, essence, crystals ]; let partyCombatPower = 0;
const anyFull = resourceValues.some((v) => { let goldPerSecond = 0;
return v >= RESOURCE_CAP; if (state !== null) {
}); for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
goldPerSecond = computeGoldPerSecond(state);
}
let avatarUrl: string | null = null;
if (state !== null) {
avatarUrl = state.player.avatar === null
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(state.player.discordId, 10) % 5)}.png`
: `https://cdn.discordapp.com/avatars/${state.player.discordId}/${state.player.avatar}.png?size=64`;
}
const profileUrl = state === null
? "#"
: `/profile/${state.player.discordId}`;
const goldFull = gold >= RESOURCE_CAP; const goldFull = gold >= RESOURCE_CAP;
const essenceFull = essence >= RESOURCE_CAP; const essenceFull = essence >= RESOURCE_CAP;
const crystalsFull = crystals >= RESOURCE_CAP; const crystalsFull = crystals >= RESOURCE_CAP;
const anyFull = goldFull || essenceFull || crystalsFull;
const hiddenResourcesFull = essenceFull || crystalsFull;
function handleForceSync(): void { function handleForceSync(): void {
void onForceSync(); void onForceSync();
} }
function handleToggleResources(): void {
setIsResourcesOpen((previous) => {
return !previous;
});
}
function handleResourceBlur(event: FocusEvent<HTMLDivElement>): void {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsResourcesOpen(false);
}
}
function handleToggleProfile(): void {
setIsProfileOpen((previous) => {
return !previous;
});
}
function handleProfileBlur(event: FocusEvent<HTMLDivElement>): void {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsProfileOpen(false);
}
}
function handleEditProfile(): void {
setIsProfileOpen(false);
onEditProfile();
}
return ( return (
<> <>
<header className="resource-bar"> <header className="resource-bar">
<div className={`resource${goldFull <div
? " resource-full" className="resource-menu"
: ""}`}> onBlur={handleResourceBlur}
<span className="resource-icon">{"🪙"}</span> >
<span className="resource-value">{formatNumber(gold)}</span> <button
<span className="resource-label">{"Gold"}</span> className={`resource resource-toggle${goldFull
{goldFull ? " resource-full"
? <span className="resource-cap-badge" title={resourceFullTooltip}> : ""}`}
{"FULL"} onClick={handleToggleResources}
</span> title="Click to see all resources"
type="button"
>
<span className="resource-icon">{"🪙"}</span>
<span className="resource-value">{formatNumber(gold)}</span>
<span className="resource-label">{"Gold"}</span>
{goldFull
? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"}
</span>
: null}
{hiddenResourcesFull
? <span
className="resource-alert-dot"
title={"One or more resources are full!"}
/>
: null}
</button>
{isResourcesOpen
? <div className="resources-dropdown">
<div className="resource">
<span className="resource-icon">{"📈"}</span>
<span className="resource-value">
{formatNumber(goldPerSecond)}
</span>
<span className="resource-label">{"Gold/s"}</span>
</div>
<div className={`resource${essenceFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"✨"}</span>
<span className="resource-value">
{formatNumber(essence)}
</span>
<span className="resource-label">{"Essence"}</span>
{essenceFull
? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"}
</span>
: null}
</div>
<div className={`resource${crystalsFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"💎"}</span>
<span className="resource-value">
{formatNumber(crystals)}
</span>
<span className="resource-label">{"Crystals"}</span>
{crystalsFull
? <span
className="resource-cap-badge"
title={resourceFullTooltip}
>
{"FULL"}
</span>
: null}
</div>
<div className="resource">
<span className="resource-icon">{"🔮"}</span>
<span className="resource-value">
{formatNumber(runestones)}
</span>
<span className="resource-label">{"Runestones"}</span>
</div>
<div className="resource">
<span className="resource-icon">{"⚔️"}</span>
<span className="resource-value">
{formatNumber(partyCombatPower)}
</span>
<span className="resource-label">{"Combat Power"}</span>
</div>
</div>
: null} : null}
</div> </div>
<div className={`resource${essenceFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"✨"}</span>
<span className="resource-value">{formatNumber(essence)}</span>
<span className="resource-label">{"Essence"}</span>
{essenceFull
? <span className="resource-cap-badge" title={resourceFullTooltip}>
{"FULL"}
</span>
: null}
</div>
<div className={`resource${crystalsFull
? " resource-full"
: ""}`}>
<span className="resource-icon">{"💎"}</span>
<span className="resource-value">{formatNumber(crystals)}</span>
<span className="resource-label">{"Crystals"}</span>
{crystalsFull
? <span className="resource-cap-badge" title={resourceFullTooltip}>
{"FULL"}
</span>
: null}
</div>
<div className="resource">
<span className="resource-icon">{"🔮"}</span>
<span className="resource-value">{formatNumber(runestones)}</span>
<span className="resource-label">{"Runestones"}</span>
</div>
{apotheosisCount > 0 {apotheosisCount > 0
&& <div className="apotheosis-badge"> && <div className="apotheosis-badge">
{"✨ Apotheosis "} {"✨ Apotheosis "}
@@ -153,34 +251,7 @@ const ResourceBar = ({
{prestigeCount} {prestigeCount}
</div> </div>
} }
<div className="profile-buttons"> <div className="resource-bar-actions">
<a
className="profile-link-button"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Support the developer"
>
{"💜"} <span className="btn-label">{"Donate"}</span>
</a>
<a
className="profile-link-button"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Join our Discord"
>
{"💬"} <span className="btn-label">{"Discord"}</span>
</a>
<a
className="profile-link-button"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
title="Get support on our forum"
>
{"🆘"} <span className="btn-label">{"Support"}</span>
</a>
{syncError === null {syncError === null
? null ? null
: <span className="save-status save-error" title={syncError}> : <span className="save-status save-error" title={syncError}>
@@ -207,23 +278,69 @@ const ResourceBar = ({
? "⏳" ? "⏳"
: "💾"} : "💾"}
</button> </button>
<a {avatarUrl === null
className="profile-link-button" ? null
href={profileUrl} : <div
rel="noreferrer" className="profile-menu"
target="_blank" onBlur={handleProfileBlur}
title="View your public profile" >
> <button
{"👤"} <span className="btn-label">{"Profile"}</span> className="profile-avatar-button"
</a> onClick={handleToggleProfile}
<button title="Account"
className="profile-edit-button" type="button"
onClick={onEditProfile} >
title="Edit your profile" <img
type="button" alt="Profile"
> className="profile-avatar-img"
{"✏️"} src={avatarUrl}
</button> />
</button>
{isProfileOpen
? <div className="profile-dropdown">
<a
className="profile-dropdown-item"
href={profileUrl}
rel="noreferrer"
target="_blank"
>
{"👤 View Profile"}
</a>
<button
className="profile-dropdown-item"
onClick={handleEditProfile}
type="button"
>
{"✏️ Edit Profile"}
</button>
<hr className="profile-dropdown-divider" />
<a
className="profile-dropdown-item"
href="https://donate.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💜 Donate"}
</a>
<a
className="profile-dropdown-item"
href="https://chat.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"💬 Discord"}
</a>
<a
className="profile-dropdown-item"
href="https://support.nhcarrigan.com"
rel="noreferrer"
target="_blank"
>
{"🆘 Support"}
</a>
</div>
: null}
</div>}
</div> </div>
</header> </header>
{anyFull {anyFull
+349 -115
View File
@@ -42,6 +42,9 @@ import {
challengeBoss as challengeBossApi, challengeBoss as challengeBossApi,
collectExploration as collectExplorationApi, collectExploration as collectExplorationApi,
craftRecipe as craftRecipeApi, craftRecipe as craftRecipeApi,
debugHardReset as debugHardResetApi,
forceUnlocks as forceUnlocksApi,
syncNewContent as syncNewContentApi,
loadGame, loadGame,
prestige as prestigeApi, prestige as prestigeApi,
resetProgress as resetProgressApi, resetProgress as resetProgressApi,
@@ -50,7 +53,6 @@ import {
transcend as transcendApi, transcend as transcendApi,
} from "../api/client.js"; } from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js"; import { CODEX_ENTRIES } from "../data/codex.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { RECIPES } from "../data/recipes.js"; import { RECIPES } from "../data/recipes.js";
import { import {
RESOURCE_CAP, RESOURCE_CAP,
@@ -446,6 +448,11 @@ interface GameContextValue {
*/ */
toggleAutoBoss: ()=> void; toggleAutoBoss: ()=> void;
/**
* Toggle the auto-adventurer setting on/off (requires auto_adventurer prestige upgrade).
*/
toggleAutoAdventurer: ()=> void;
/** /**
* Queue of newly unlocked codex entry IDs (for toast notifications). * Queue of newly unlocked codex entry IDs (for toast notifications).
*/ */
@@ -546,6 +553,43 @@ interface GameContextValue {
*/ */
resetProgress: ()=> Promise<void>; resetProgress: ()=> Promise<void>;
/**
* Force-unlock any zones, quests, and bosses the player has earned but that
* are still incorrectly locked due to a state bug.
* @returns Counts of what was corrected.
*/
forceUnlocks: ()=> Promise<{
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
}>;
/**
* Completely wipe the player's progress back to a brand-new save via the
* debug endpoint.
*/
debugHardReset: ()=> Promise<void>;
/**
* Syncs any content added to the game after the player's save was created.
* @returns Counts of what was added per content type.
*/
syncNewContent: ()=> Promise<{
achievementsAdded: number;
adventurersAdded: number;
bossesAdded: number;
equipmentAdded: number;
explorationAreasAdded: number;
questsAdded: number;
upgradesAdded: number;
zonesAdded: number;
}>;
/** /**
* Last auto-boss fight result — null until the first auto fight completes or * Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off. * when auto-boss is toggled off.
@@ -557,6 +601,12 @@ interface GameContextValue {
* when no error). Cleared automatically when the player re-enables auto-boss. * when no error). Cleared automatically when the player re-enables auto-boss.
*/ */
autoBossError: string | null; autoBossError: string | null;
/**
* Error message from the most recent manual boss challenge (null when no
* error). Cleared automatically when a new challenge is initiated.
*/
bossError: string | null;
} }
export interface BattleResult { export interface BattleResult {
@@ -606,6 +656,7 @@ export const GameProvider = ({
at: number; at: number;
} | null>(null); } | null>(null);
const [ autoBossError, setAutoBossError ] = useState<string | null>(null); const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
const [ bossError, setBossError ] = useState<string | null>(null);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>( const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
); );
@@ -1047,6 +1098,38 @@ export const GameProvider = ({
} }
} }
// Auto-adventurer: buy one of the highest-tier affordable unlocked adventurer per tick
if (
next.autoAdventurer === true
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
) {
const [ bestAdventurer ] = next.adventurers.
filter((adventurer) => {
const cost
= adventurer.baseCost * Math.pow(1.15, adventurer.count);
return adventurer.unlocked && next.resources.gold >= cost;
}).
sort((adventurerA, adventurerB) => {
return adventurerB.combatPower - adventurerA.combatPower;
});
if (bestAdventurer !== undefined) {
const purchaseCost
= bestAdventurer.baseCost * Math.pow(1.15, bestAdventurer.count);
next = {
...next,
adventurers: next.adventurers.map((adventurer) => {
return adventurer.id === bestAdventurer.id
? { ...adventurer, count: adventurer.count + 1 }
: adventurer;
}),
resources: {
...next.resources,
gold: next.resources.gold - purchaseCost,
},
};
}
}
// Detect newly unlocked achievements // Detect newly unlocked achievements
unlockedAchievementsReference.current = next.achievements.filter( unlockedAchievementsReference.current = next.achievements.filter(
(a, index) => { (a, index) => {
@@ -1077,14 +1160,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; return next;
}); });
@@ -1157,9 +1232,12 @@ export const GameProvider = ({
) { ) {
signatureReference.current = null; signatureReference.current = null;
localStorage.removeItem("elysium_save_signature"); localStorage.removeItem("elysium_save_signature");
} else {
logError("auto_save", error_);
} }
/*
* Network failures during background auto-save are expected on
* flaky connections — the next tick will retry, so no telemetry needed
*/
}); });
} }
} }
@@ -1187,10 +1265,9 @@ export const GameProvider = ({
} }
await reloadReference.current(); await reloadReference.current();
}). }).
catch((error_: unknown) => { catch(() => {
logError("auto_prestige", error_);
/* Silently ignore — will retry next tick */ /* Silently ignore — eligibility is re-checked every tick */
}). }).
finally(() => { finally(() => {
isAutoPrestigingReference.current = false; isAutoPrestigingReference.current = false;
@@ -1220,7 +1297,26 @@ export const GameProvider = ({
if (availableBoss !== undefined) { if (availableBoss !== undefined) {
const { id: bossId, name: bossName } = availableBoss; const { id: bossId, name: bossName } = availableBoss;
isAutoBossingReference.current = true; isAutoBossingReference.current = true;
void challengeBossApi({ bossId }). const syncBeforeBoss
= stateReference.current !== null && !isSyncingReference.current
? saveGame({
state: stateReference.current,
...signatureReference.current === null
? {}
: { signature: signatureReference.current },
}).then((response) => {
if (response.signature !== undefined) {
signatureReference.current = response.signature;
localStorage.setItem(
"elysium_save_signature",
response.signature,
);
}
})
: Promise.resolve();
void syncBeforeBoss.then(async() => {
return await challengeBossApi({ bossId });
}).
then((result) => { then((result) => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -1240,11 +1336,20 @@ export const GameProvider = ({
}); });
}). }).
catch((error_: unknown) => { catch((error_: unknown) => {
logError("auto_boss", error_);
const message const message
= error_ instanceof Error = error_ instanceof Error
? error_.message ? error_.message
: String(error_); : String(error_);
/*
* "Boss is not currently available" is an expected race condition
* when the client is ahead of the server save — silently skip and
* let the next tick retry rather than halting automation.
*/
if (message === "Boss is not currently available") {
return;
}
logError("auto_boss", error_);
setAutoBossError(message); setAutoBossError(message);
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -1642,118 +1747,104 @@ export const GameProvider = ({
}, []); }, []);
const startExploration = useCallback(async(areaId: string) => { const startExploration = useCallback(async(areaId: string) => {
try { const response = await startExplorationApi({ areaId });
const response = await startExplorationApi({ areaId }); setState((previous) => {
const areaData = EXPLORATION_AREAS.find((a) => { if (previous?.exploration === undefined) {
return a.id === areaId; return previous;
});
if (areaData === undefined) {
return;
} }
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear return {
const startedAt = response.endsAt - areaData.durationSeconds * 1000; ...previous,
exploration: {
...previous.exploration,
areas: previous.exploration.areas.map((a) => {
return a.id === areaId
? {
...a,
endsAt: response.endsAt,
status: "in_progress" as const,
}
: a;
}),
},
};
});
}, []);
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
const result = await collectExplorationApi({ areaId });
setState((previous) => { setState((previous) => {
if (previous?.exploration === undefined) { if (previous?.exploration === undefined) {
return previous; return previous;
} }
let materials = [ ...previous.exploration.materials ];
// Apply material drops from the random loot roll
for (const drop of result.materialsFound) {
const existing = materials.find((mat) => {
return mat.materialId === drop.materialId;
});
if (existing === undefined) {
materials = [
...materials,
{ materialId: drop.materialId, quantity: drop.quantity },
];
} else {
materials = materials.map((mat) => {
return mat.materialId === drop.materialId
? { ...mat, quantity: mat.quantity + drop.quantity }
: mat;
});
}
}
// Apply material from event (if any)
const materialGained = result.event?.materialGained;
if (materialGained !== null && materialGained !== undefined) {
const { materialId, quantity } = materialGained;
const existing = materials.find((mat) => {
return mat.materialId === materialId;
});
if (existing === undefined) {
materials = [ ...materials, { materialId, quantity } ];
} else {
materials = materials.map((mat) => {
return mat.materialId === materialId
? { ...mat, quantity: mat.quantity + quantity }
: mat;
});
}
}
return { return {
...previous, ...previous,
exploration: { exploration: {
...previous.exploration, ...previous.exploration,
areas: previous.exploration.areas.map((a) => { areas: previous.exploration.areas.map((a) => {
return a.id === areaId return a.id === areaId
? { ...a, startedAt: startedAt, status: "in_progress" as const } ? { ...a, completedOnce: true, status: "available" as const }
: a; : a;
}), }),
materials: materials,
},
player: {
...previous.player,
totalGoldEarned:
previous.player.totalGoldEarned
+ Math.max(0, result.event?.goldChange ?? 0),
},
resources: {
...previous.resources,
essence:
previous.resources.essence + (result.event?.essenceChange ?? 0),
gold: Math.max(
0,
previous.resources.gold + (result.event?.goldChange ?? 0),
),
}, },
}; };
}); });
} catch (error_: unknown) { return result;
logError("start_exploration", error_);
throw error_;
}
}, []);
const collectExploration = useCallback(
async(areaId: string): Promise<ExploreCollectResponse> => {
try {
const result = await collectExplorationApi({ areaId });
setState((previous) => {
if (previous?.exploration === undefined) {
return previous;
}
let materials = [ ...previous.exploration.materials ];
// Apply material drops from the random loot roll
for (const drop of result.materialsFound) {
const existing = materials.find((mat) => {
return mat.materialId === drop.materialId;
});
if (existing === undefined) {
materials = [
...materials,
{ materialId: drop.materialId, quantity: drop.quantity },
];
} else {
materials = materials.map((mat) => {
return mat.materialId === drop.materialId
? { ...mat, quantity: mat.quantity + drop.quantity }
: mat;
});
}
}
// Apply material from event (if any)
const materialGained = result.event?.materialGained;
if (materialGained !== null && materialGained !== undefined) {
const { materialId, quantity } = materialGained;
const existing = materials.find((mat) => {
return mat.materialId === materialId;
});
if (existing === undefined) {
materials = [ ...materials, { materialId, quantity } ];
} else {
materials = materials.map((mat) => {
return mat.materialId === materialId
? { ...mat, quantity: mat.quantity + quantity }
: mat;
});
}
}
return {
...previous,
exploration: {
...previous.exploration,
areas: previous.exploration.areas.map((a) => {
return a.id === areaId
? { ...a, completedOnce: true, status: "available" as const }
: a;
}),
materials: materials,
},
player: {
...previous.player,
totalGoldEarned:
previous.player.totalGoldEarned
+ Math.max(0, result.event?.goldChange ?? 0),
},
resources: {
...previous.resources,
essence:
previous.resources.essence + (result.event?.essenceChange ?? 0),
gold: Math.max(
0,
previous.resources.gold + (result.event?.goldChange ?? 0),
),
},
};
});
return result;
} catch (error_: unknown) {
logError("collect_exploration", error_);
throw error_;
}
}, },
[], [],
); );
@@ -1836,6 +1927,18 @@ export const GameProvider = ({
}); });
}, []); }, []);
const toggleAutoAdventurer = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return {
...previous,
autoAdventurer: previous.autoAdventurer !== true,
};
});
}, []);
const setActiveCompanion = useCallback((companionId: string | null) => { const setActiveCompanion = useCallback((companionId: string | null) => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -1867,6 +1970,14 @@ export const GameProvider = ({
return; return;
} }
setBossError(null);
/*
* Flush any pending state (e.g. newly equipped items) to the server before
* the fight so the server-side calculation uses the player's live stats.
*/
await forceSync();
try { try {
const result = await challengeBossApi({ bossId }); const result = await challengeBossApi({ bossId });
setState((previous) => { setState((previous) => {
@@ -1877,10 +1988,23 @@ export const GameProvider = ({
}); });
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
} catch (error_: unknown) { } catch (error_: unknown) {
logError("challenge_boss", error_); const bossErrorMessage
// Silently ignore — server errors shouldn't crash the UI = error_ instanceof Error
? error_.message
: "Failed to challenge boss";
/*
* "Boss is not currently available" is an expected server rejection
* (race condition between UI state and server state) — suppress telemetry
*/
if (bossErrorMessage !== "Boss is not currently available") {
logError("challenge_boss", error_);
}
setBossError(
bossErrorMessage,
);
} }
}, []); }, [ forceSync ]);
const dismissOfflineGold = useCallback(() => { const dismissOfflineGold = useCallback(() => {
setOfflineGold(0); setOfflineGold(0);
@@ -2006,6 +2130,106 @@ export const GameProvider = ({
} }
}, []); }, []);
const forceUnlocks = useCallback(async() => {
try {
const data = await forceUnlocksApi();
setState(data.state);
if (data.signature !== undefined) {
signatureReference.current = data.signature;
localStorage.setItem("elysium_save_signature", data.signature);
}
return {
adventurersUnlocked: data.adventurersUnlocked,
bossesUnlocked: data.bossesUnlocked,
equipmentUnlocked: data.equipmentUnlocked,
explorationUnlocked: data.explorationUnlocked,
questsUnlocked: data.questsUnlocked,
storyUnlocked: data.storyUnlocked,
upgradesUnlocked: data.upgradesUnlocked,
zonesUnlocked: data.zonesUnlocked,
};
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "Failed to force unlocks",
);
return {
adventurersUnlocked: 0,
bossesUnlocked: 0,
equipmentUnlocked: 0,
explorationUnlocked: 0,
questsUnlocked: 0,
storyUnlocked: 0,
upgradesUnlocked: 0,
zonesUnlocked: 0,
};
}
}, []);
const syncNewContent = useCallback(async() => {
try {
const data = await syncNewContentApi();
setState(data.state);
if (data.signature !== undefined) {
signatureReference.current = data.signature;
localStorage.setItem("elysium_save_signature", data.signature);
}
return {
achievementsAdded: data.achievementsAdded,
adventurersAdded: data.adventurersAdded,
bossesAdded: data.bossesAdded,
equipmentAdded: data.equipmentAdded,
explorationAreasAdded: data.explorationAreasAdded,
questsAdded: data.questsAdded,
upgradesAdded: data.upgradesAdded,
zonesAdded: data.zonesAdded,
};
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "Failed to sync new content",
);
return {
achievementsAdded: 0,
adventurersAdded: 0,
bossesAdded: 0,
equipmentAdded: 0,
explorationAreasAdded: 0,
questsAdded: 0,
upgradesAdded: 0,
zonesAdded: 0,
};
}
}, []);
const debugHardReset = useCallback(async() => {
setIsLoading(true);
setError(null);
try {
const data = await debugHardResetApi();
setState(data.state);
setLastSavedAt(data.state.player.lastSavedAt);
setSchemaOutdated(false);
setOfflineGold(0);
setOfflineEssence(0);
setLoginBonus(null);
if (data.signature !== undefined) {
signatureReference.current = data.signature;
localStorage.setItem("elysium_save_signature", data.signature);
}
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "Failed to reset progress",
);
} finally {
setIsLoading(false);
}
}, []);
const dismissLoginBonus = useCallback(() => { const dismissLoginBonus = useCallback(() => {
setLoginBonus(null); setLoginBonus(null);
}, []); }, []);
@@ -2023,6 +2247,7 @@ export const GameProvider = ({
autoBossError, autoBossError,
autoBossLastResult, autoBossLastResult,
battleResult, battleResult,
bossError,
buyAdventurer, buyAdventurer,
buyEchoUpgrade, buyEchoUpgrade,
buyEquipment, buyEquipment,
@@ -2034,6 +2259,7 @@ export const GameProvider = ({
completedQuestToasts, completedQuestToasts,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
debugHardReset,
dismissAchievement, dismissAchievement,
dismissApotheosisToast, dismissApotheosisToast,
dismissBattle, dismissBattle,
@@ -2052,6 +2278,7 @@ export const GameProvider = ({
failedQuestToasts, failedQuestToasts,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks,
formatNumber, formatNumber,
handleClick, handleClick,
isLoading, isLoading,
@@ -2077,6 +2304,8 @@ export const GameProvider = ({
startQuest, startQuest,
state, state,
syncError, syncError,
syncNewContent,
toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
@@ -2091,6 +2320,7 @@ export const GameProvider = ({
autoBossError, autoBossError,
autoBossLastResult, autoBossLastResult,
battleResult, battleResult,
bossError,
completedQuestToasts, completedQuestToasts,
failedQuestToasts, failedQuestToasts,
formatNumber, formatNumber,
@@ -2104,6 +2334,7 @@ export const GameProvider = ({
completeChapter, completeChapter,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
debugHardReset,
dismissAchievement, dismissAchievement,
dismissApotheosisToast, dismissApotheosisToast,
dismissBattle, dismissBattle,
@@ -2121,6 +2352,7 @@ export const GameProvider = ({
error, error,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks,
handleClick, handleClick,
isLoading, isLoading,
isSyncing, isSyncing,
@@ -2145,6 +2377,8 @@ export const GameProvider = ({
startQuest, startQuest,
state, state,
syncError, syncError,
syncNewContent,
toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
+4 -4
View File
@@ -2752,8 +2752,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
{ {
content: content:
"The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.", "The ancient books of magic acquired for the guild's mages contained techniques that their trainers had either not known or had chosen not to teach. The omission, in most cases, appeared to be deliberate — the techniques worked but produced results that the academies found uncomfortable to endorse. Your guild finds them extremely comfortable to have, and the mage output doubled from the application of knowledge that had been sitting in books waiting for someone to act on it.",
id: "upgrade_mage_1", id: "upgrade_apprentice_1",
sourceId: "mage_1", sourceId: "apprentice_1",
sourceType: "upgrade", sourceType: "upgrade",
title: "Arcane Tomes: The Written Knowledge", title: "Arcane Tomes: The Written Knowledge",
zoneId: "guild_library", zoneId: "guild_library",
@@ -2761,8 +2761,8 @@ export const CODEX_ENTRIES: Array<CodexEntry> = [
{ {
content: content:
"The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.", "The sacred ceremonies that your clerics now perform before and during operations were developed by your head cleric over six months of experimentation that their deity appears to have sanctioned, based on the results. The rites formalise the relationship between divine power and operational output into a repeatable process. Doubled cleric output is the result of making the exceptional ordinary through the discipline of ceremony.",
id: "upgrade_cleric_1", id: "upgrade_acolyte_1",
sourceId: "cleric_1", sourceId: "acolyte_1",
sourceType: "upgrade", sourceType: "upgrade",
title: "Holy Rites: The Sacred Routine", title: "Holy Rites: The Sacred Routine",
zoneId: "guild_library", zoneId: "guild_library",
+9
View File
@@ -212,6 +212,15 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
runestonesCost: 1200, runestonesCost: 1200,
}, },
// ── Utility Unlocks ─────────────────────────────────────────────────────── // ── Utility Unlocks ───────────────────────────────────────────────────────
{
category: "utility",
description:
"Unlock the Auto-Adventurer toggle. When enabled, the tick engine will automatically purchase the highest-tier adventurer you can currently afford.",
id: "auto_adventurer",
multiplier: 1,
name: "Autonomous Recruitment",
runestonesCost: 50,
},
{ {
category: "utility", category: "utility",
description: description:
+73 -1
View File
@@ -93,7 +93,7 @@ export const RESOURCE_CAP = 1e300;
* On failure the quest resets to "available" with no rewards; the player must wait the * On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt. * full duration again on their next attempt.
*/ */
const zoneFailureChance: Record<string, number> = { export const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24, abyssal_trench: 0.24,
astral_void: 0.2, astral_void: 0.2,
celestial_reaches: 0.22, celestial_reaches: 0.22,
@@ -123,6 +123,78 @@ const capResource = (value: number): number => {
return Math.min(value, RESOURCE_CAP); return Math.min(value, RESOURCE_CAP);
}; };
/**
* Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick.
* Returns a new GameState (does not mutate the original).
* @param state - The current game state.
* @param deltaSeconds - Time elapsed since last tick in seconds.
* @returns A new GameState with the tick applied.
*/
/**
* Computes the effective gold earned per second across all adventurers,
* including all active multipliers (upgrades, prestige, equipment, etc.).
* @param state - The current game state.
* @returns Gold per second as a number.
*/
export const computeGoldPerSecond = (state: GameState): number => {
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
return item.equipped;
});
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
return mult * (item.bonus.goldMultiplier ?? 1);
}, 1);
const setGoldMultiplier = computeSetBonuses(
equippedItems.map((item) => {
return item.id;
}),
EQUIPMENT_SETS,
).goldMultiplier;
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionGoldMult
= companionBonus?.type === "passiveGold"
? 1 + companionBonus.value
: 1;
let goldPerSecond = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const contribution
= adventurer.goldPerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesIncome
* echoIncome
* equipmentGoldMultiplier
* setGoldMultiplier
* craftedGoldMultiplier
* companionGoldMult;
goldPerSecond = goldPerSecond + contribution;
}
return goldPerSecond;
};
/** /**
* Pure function — applies one game tick to the state. * Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick. * DeltaSeconds: time elapsed since last tick.
+205 -43
View File
@@ -116,6 +116,66 @@ body::before {
text-align: center; text-align: center;
} }
/* ── Resource toggle + dropdown ─────────────────────────────────────────── */
.resource-menu {
position: relative;
}
.resource-toggle {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0.3rem 0.6rem;
position: relative;
transition: background 0.2s, border-color 0.2s;
}
.resource-toggle:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary);
}
.resource-alert-dot {
background: var(--colour-warning, #f59e0b);
border-radius: 50%;
height: 0.45rem;
position: absolute;
right: 0;
top: 0;
width: 0.45rem;
}
.resources-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 0.1rem;
left: 0;
padding: 0.4rem;
position: absolute;
top: calc(100% + 0.4rem);
z-index: 100;
}
.resources-dropdown .resource {
border-radius: 0.35rem;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
white-space: nowrap;
}
.resources-dropdown .resource:hover {
background: rgba(255, 255, 255, 0.04);
}
/* ===================== GAME LAYOUT ===================== */ /* ===================== GAME LAYOUT ===================== */
.game-layout { .game-layout {
display: flex; display: flex;
@@ -1492,57 +1552,87 @@ body::before {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ── Profile buttons in ResourceBar ────────────────────────────────────── */ /* ── Resource bar actions (save + profile menu) ─────────────────────────── */
.profile-buttons { .resource-bar-actions {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
margin-left: auto; margin-left: auto;
} }
.profile-link-button { .profile-menu {
align-items: center; position: relative;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 1rem;
color: var(--colour-text-muted);
display: flex;
font-size: 0.8rem;
gap: 0.3rem;
padding: 0.3rem 0.8rem;
text-decoration: none;
transition: all 0.2s;
white-space: nowrap;
} }
.profile-link-button:hover { .profile-avatar-button {
background: rgba(147, 51, 234, 0.2); background: none;
border-color: var(--colour-primary); border: 2px solid rgba(147, 51, 234, 0.4);
color: var(--colour-text);
}
.profile-edit-button {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 50%; border-radius: 50%;
color: var(--colour-text-muted);
cursor: pointer; cursor: pointer;
font-family: inherit; display: flex;
font-size: 0.85rem;
height: 2rem; height: 2rem;
line-height: 1; overflow: hidden;
padding: 0; padding: 0;
transition: all 0.2s; transition: border-color 0.2s;
width: 2rem; width: 2rem;
} }
.profile-edit-button:hover { .profile-avatar-button:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary); border-color: var(--colour-primary);
}
.profile-avatar-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.profile-dropdown {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
min-width: 10rem;
padding: 0.25rem;
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 100;
}
.profile-dropdown-item {
align-items: center;
background: none;
border: none;
border-radius: 0.35rem;
color: var(--colour-text-muted);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: 0.85rem;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
text-align: left;
text-decoration: none;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
width: 100%;
}
.profile-dropdown-item:hover {
background: rgba(147, 51, 234, 0.15);
color: var(--colour-text); color: var(--colour-text);
} }
.profile-dropdown-divider {
border: none;
border-top: 1px solid rgba(147, 51, 234, 0.2);
margin: 0.25rem 0;
}
.save-status { .save-status {
color: var(--colour-text-muted); color: var(--colour-text-muted);
font-size: 0.75rem; font-size: 0.75rem;
@@ -3167,10 +3257,10 @@ body::before {
display: none; display: none;
} }
/* Profile buttons fill their own row, aligned right */ /* Resource bar actions fill their own row, aligned right */
.profile-buttons { .resource-bar-actions {
margin-left: 0;
justify-content: flex-end; justify-content: flex-end;
margin-left: 0;
width: 100%; width: 100%;
} }
@@ -3240,15 +3330,6 @@ body::before {
/* --- Small mobile (≤ 480px) --------------------------- */ /* --- Small mobile (≤ 480px) --------------------------- */
@media (max-width: 480px) { @media (max-width: 480px) {
/* Icon-only profile link buttons to save horizontal space */
.btn-label {
display: none;
}
.profile-link-button {
padding: 0.3rem 0.5rem;
}
/* Slightly smaller tab buttons */ /* Slightly smaller tab buttons */
.tab-button { .tab-button {
font-size: 0.8rem; font-size: 0.8rem;
@@ -4515,3 +4596,84 @@ body::before {
object-fit: cover; object-fit: cover;
width: 80px; width: 80px;
} }
/* ===================== ACTION BUTTONS ===================== */
.action-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
margin-top: 0.5rem;
padding: 0.55rem 1.25rem;
transition: background 0.15s;
width: 100%;
}
.action-button:hover:not(:disabled) {
background: var(--colour-accent-light);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.action-button-danger {
background: var(--colour-error);
}
.action-button-danger:hover:not(:disabled) {
background: #f87171;
}
/* ===================== MODAL VARIANTS ===================== */
.modal-button-danger {
background: var(--colour-error);
}
.modal-button-danger:hover:not(:disabled) {
background: #f87171;
}
/* ===================== DEBUG PANEL ===================== */
.debug-actions {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
margin-top: 1rem;
}
.debug-action-card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
padding: 1.25rem;
}
.debug-action-card h3 {
color: var(--colour-accent-light);
font-size: 1rem;
margin: 0 0 0.5rem;
}
.debug-action-card > p {
color: var(--colour-text-muted);
flex: 1;
font-size: 0.875rem;
margin: 0;
}
.debug-result-message {
background: rgba(16, 185, 129, 0.1);
border: 1px solid var(--colour-success);
border-radius: var(--radius);
color: var(--colour-success);
font-size: 0.8rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.1.1", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,6 +11,6 @@
}, },
"devDependencies": { "devDependencies": {
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"typescript": "5.8.2" "typescript": "5.9.3"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.1.1", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+2
View File
@@ -60,6 +60,7 @@ export type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease, GiteaRelease,
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,
@@ -71,6 +72,7 @@ export type {
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,
+108
View File
@@ -398,6 +398,112 @@ interface CraftRecipeResponse {
craftedCombatMultiplier: number; craftedCombatMultiplier: number;
} }
interface ForceUnlocksResponse {
/**
* The corrected game state after applying all missing unlocks.
*/
state: GameState;
/**
* Number of zones that were unlocked by this operation.
*/
zonesUnlocked: number;
/**
* Number of quests that were made available by this operation.
*/
questsUnlocked: number;
/**
* Number of bosses that were made available by this operation.
*/
bossesUnlocked: number;
/**
* Number of exploration areas that were made available by this operation.
*/
explorationUnlocked: number;
/**
* Number of adventurer tiers that were unlocked by this operation.
*/
adventurersUnlocked: number;
/**
* Number of upgrades that were unlocked by this operation.
*/
upgradesUnlocked: number;
/**
* Number of equipment items that were marked as owned by this operation.
*/
equipmentUnlocked: number;
/**
* Number of story chapters that were unlocked by this operation.
*/
storyUnlocked: number;
/**
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
*/
signature?: string;
}
interface SyncNewContentResponse {
/**
* The updated game state after injecting all missing content entries.
*/
state: GameState;
/**
* Number of adventurer tiers added to the save.
*/
adventurersAdded: number;
/**
* Number of upgrades added to the save.
*/
upgradesAdded: number;
/**
* Number of quests added to the save.
*/
questsAdded: number;
/**
* Number of bosses added to the save.
*/
bossesAdded: 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;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
}
export type { export type {
AboutResponse, AboutResponse,
ApiError, ApiError,
@@ -417,6 +523,7 @@ export type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease, GiteaRelease,
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,
@@ -428,6 +535,7 @@ export type {
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,
@@ -72,6 +72,12 @@ interface ExplorationAreaState {
*/ */
startedAt?: number; startedAt?: number;
/**
* Unix timestamp when the exploration will complete (server-computed, used for
* accurate client-side countdown that is immune to client/server clock drift).
*/
endsAt?: number;
/** /**
* True after the first successful collect — used for codex unlock detection. * True after the first successful collect — used for codex unlock detection.
*/ */
@@ -79,6 +79,11 @@ interface GameState {
*/ */
autoBoss?: boolean; autoBoss?: boolean;
/**
* When true, the tick engine automatically purchases the highest-tier affordable adventurer.
*/
autoAdventurer?: boolean;
/** /**
* Companion unlock and active selection state — optional for backwards compatibility. * Companion unlock and active selection state — optional for backwards compatibility.
*/ */
+14 -3
View File
@@ -10,10 +10,10 @@ importers:
devDependencies: devDependencies:
'@nhcarrigan/typescript-config': '@nhcarrigan/typescript-config':
specifier: 4.0.0 specifier: 4.0.0
version: 4.0.0(typescript@5.8.2) version: 4.0.0(typescript@5.9.3)
typescript: typescript:
specifier: 5.8.2 specifier: 5.9.3
version: 5.8.2 version: 5.9.3
apps/api: apps/api:
dependencies: dependencies:
@@ -2833,6 +2833,11 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3502,6 +3507,10 @@ snapshots:
dependencies: dependencies:
typescript: 5.8.2 typescript: 5.8.2
'@nhcarrigan/typescript-config@4.0.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -6138,6 +6147,8 @@ snapshots:
typescript@5.8.2: {} typescript@5.8.2: {}
typescript@5.9.3: {}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4