29 Commits

Author SHA1 Message Date
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
hikari 9860a2cb1f feat: persist crafting zone selection in sessionStorage (#49)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Applies the same sticky-zone pattern from #48 to the crafting panel (`elysium_craft_zone` key in sessionStorage)
- Introduces a `handleZoneSelect` wrapper so sessionStorage is updated alongside React state on every zone change
- Gracefully falls back to `verdant_vale` if no stored value exists

## Test plan

- [x] Lint — zero errors, zero warnings
- [x] Build — all packages build cleanly
- [ ] Manual: select a non-default zone in the crafting panel, navigate away and back — zone should still be selected
- [ ] Manual: log out and back in — zone should reset to Verdant Vale

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #49
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 22:25:18 -07:00
hikari 404b31bd13 fix: persist UI preferences across navigation and sessions (#48)
CI / Lint, Build & Test (push) Successful in 1m10s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m11s
## Summary

- **#35** — Adventure multiplier selection is now persisted in `localStorage` (`"elysium_batch_size"`). The chosen batch size is restored automatically on the next visit, with a graceful fallback to `1` for missing or unrecognisable values.
- **#36** — Zone selection in the boss panel and quest panel is now persisted in `sessionStorage` (`"elysium_boss_zone"` / `"elysium_quest_zone"`). The selected zone survives navigation within a session and resets cleanly when the session ends, defaulting to Verdant Vale if no stored value exists.

## Test plan

- [x] Lint — zero errors, zero warnings
- [x] Build — all packages build cleanly
- [x] Tests — 415 tests passing, 100% coverage across all packages
- [ ] Manual: select a non-default batch size, refresh the page — multiplier should be restored
- [ ] Manual: switch to a non-default zone in the boss panel, navigate away and back — zone should still be selected
- [ ] Manual: repeat for the quest panel
- [ ] Manual: log out and back in — zone selection should reset to Verdant Vale

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #48
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 22:17:12 -07:00
hikari d0790890ee fix: preserve all-time stats, achievements, and boss first-kill across prestige (#47)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s
Resolves #37, resolves #38, and resolves #39 — three related bugs where prestige incorrectly reset data that should survive all prestige resets.

## Changes

### fix: preserve lifetime player stats across prestige (#37)
After prestige, `GameState.player.lifetime*` fields were stale — they reflected values from *before* the current run. The Prisma Player record was incremented correctly, but the GameState JSON saved to the DB had old values, so the UI showed wrong all-time totals on reload.

`buildPostPrestigeState` now computes the run-stat contributions (bosses defeated, quests completed, adventurers recruited, achievements unlocked, gold earned, clicks) and folds them into the fresh player object before writing the prestige state.

### fix: preserve achievements across prestige (#38)
`buildPostPrestigeState` was reconstructing achievements from `defaultAchievements` (via `initialGameState`), resetting all unlocked achievements on every prestige. Achievements are now carried forward from `currentState.achievements` instead.

### fix: preserve boss first-kill state across prestige (#39)
Added `bountyRunestonesClaimed?: boolean` to the `Boss` type. The boss challenge route now:
- Only awards the first-kill bounty runestones if `bountyRunestonesClaimed !== true`
- Sets `bountyRunestonesClaimed = true` on first defeat

`buildPostPrestigeState` maps the fresh boss list and carries the `bountyRunestonesClaimed` flag forward from the current state, so the bounty is never re-awarded in subsequent prestige runs. The boss panel badge is also hidden for bosses whose bounty is already claimed.

## Test Coverage
All three fixes include new tests covering the new behaviours. API coverage remains at 100%.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #47
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 21:53:58 -07:00
hikari 4d7e624358 fix: turn off auto-boss/auto-quest on failure and surface status (#46)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m7s
## Summary

- Auto-boss now turns itself **off** when a boss fight is **lost**, so the player can reassess rather than the system silently looping. A "🤖 Last fight: [Boss] —  Lost" status line appears in the boss panel.
- Auto-boss also turns off (with an ⚠️ error message) when the API call fails outright (e.g. party has no adventurers), replacing the previous behaviour of silently hammering the API every animation frame.
- Auto-quest now turns itself **off** whenever a quest fails the random-chance check, detected inside the tick's `setState` callback immediately after `applyTick`.
- `autoBoss: false` and `autoQuest: false` are now part of `initialGameState`, so these fields persist through save/load cycles from the very first session — preventing a race window where the boss-route DB write could strip them before the first auto-save.
- `toggleAutoBoss` clears both `autoBossLastResult` and `autoBossError` on each toggle so the panel always reflects the current session cleanly.

## Test plan

- [x] `pnpm lint` — 0 errors, 0 warnings
- [x] `pnpm build` — all packages clean
- [x] `pnpm test` — 100% coverage maintained across the board

Closes #40

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #46
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 21:12:03 -07:00
hikari ac94f67797 fix: send webhook milestone notifications silently (#45)
CI / Lint, Build & Test (push) Successful in 1m8s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
## Summary

- Adds `flags: 4096` (`MessageFlags.SUPPRESS_NOTIFICATIONS`) to the Discord webhook payload in `postMilestoneWebhook`
- Milestone announcements (prestige, transcendence, apotheosis) will now appear in the channel without triggering desktop or mobile push notifications
- Defines the magic number as a documented `suppressNotifications` constant for self-documentation
- Updates the webhook test to assert `flags: 4096` is present in the outgoing payload

Closes #41

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Tests pass with 100% coverage: `pnpm test`
- [ ] Trigger a prestige/transcendence/apotheosis in-game and verify the Discord webhook message arrives without pinging anyone

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #45
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 20:24:13 -07:00
hikari a36c8e72a5 feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
## Summary

- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 19:54:42 -07:00
hikari 11e97325cb feat: integrate art assets across all game panels (#43)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m6s
## Summary

- Adds `apps/web/src/utils/cdn.ts` with a `cdnImage(folder, id)` helper that builds URLs from `https://cdn.nhcarrigan.com/elysium/`
- Wires CDN art into all 13 game panels (bosses, quests, adventurers, companions, equipment, upgrades, prestige, transcendence, achievements, explorations, crafting, story, codex)
- Zone selector tabs now display 16:9 zone art thumbnails in place of emoji icons
- Adds a fixed background image at 15% opacity via `body::before`
- Documents the art generation and CDN upload process in `CLAUDE.md` for future expansions

Resolves #15

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #43
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 16:21:44 -07:00
hikari 7a1c57be9a feat: render changelog as markdown in about panel (#33)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m6s
## Summary

- Installs `react-markdown@10.1.0` in `apps/web`
- Replaces the `<pre>` tag in the changelog section with the `<Markdown>` component for proper rendering
- Updates CSS to style markdown elements (paragraphs, lists, headings, code blocks, links, bold text)

Closes #31

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #33
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 09:35:30 -07:00
87 changed files with 5973 additions and 1782 deletions
+35
View File
@@ -7,6 +7,41 @@
2. `pnpm build` — all packages build cleanly 2. `pnpm build` — all packages build cleanly
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types` 3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
## Art Assets
Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/image at 1K resolution) and hosted on the CDN at `https://cdn.nhcarrigan.com/elysium/`.
### Process
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
3. Upload to R2 with the AWS CLI — credentials are in the global `~/.claude/CLAUDE.md` (never commit them here)
4. Delete the local `img/` directory before committing (images live on CDN only)
### CDN URL Helper
`apps/web/src/utils/cdn.ts` exports `cdnImage(folder, id)``https://cdn.nhcarrigan.com/elysium/<folder>/<id>.jpg`
### Directory → Category Mapping
| Game entity | CDN folder |
|---|---|
| Zones | `zones` |
| Bosses | `bosses` |
| Quests | `quests` |
| Adventurers | `adventurers` |
| Companions | `companions` |
| Equipment | `equipment` |
| Upgrades | `upgrades` |
| Prestige upgrades | `prestige-upgrades` |
| Transcendence upgrades | `transcendence-upgrades` |
| Achievements | `achievements` |
| Explorations | `explorations` |
| Materials | `materials` |
| Recipes | `recipes` |
| Story chapter banners | `story-chapters` |
### API Rate Limits
- 250 images/day per API key — use a second key if quota is hit
- Free-tier keys cannot use `gemini-3-pro-image-preview`; key must be on a billing-linked project
## About Page ## About Page
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature. The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.1.1", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@elysium/types": "workspace:*", "@elysium/types": "workspace:*",
"@hono/node-server": "1.13.7", "@hono/node-server": "1.13.7",
"@nhcarrigan/logger": "1.1.1",
"@prisma/client": "6.5.0", "@prisma/client": "6.5.0",
"hono": "4.7.4", "hono": "4.7.4",
"prisma": "6.5.0" "prisma": "6.5.0"
+2 -1
View File
@@ -9,4 +9,5 @@ CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+9 -9
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, 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.",
+2
View File
@@ -76,6 +76,8 @@ const initialGameState = (
achievements: structuredClone(defaultAchievements), achievements: structuredClone(defaultAchievements),
adventurers: structuredClone(defaultAdventurers), adventurers: structuredClone(defaultAdventurers),
apotheosis: { ...initialApotheosis }, apotheosis: { ...initialApotheosis },
autoBoss: false,
autoQuest: false,
baseClickPower: 1, baseClickPower: 1,
bosses: structuredClone(defaultBosses), bosses: structuredClone(defaultBosses),
companions: { activeCompanionId: null, unlockedCompanionIds: [] }, companions: { activeCompanionId: null, unlockedCompanionIds: [] },
+9
View File
@@ -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:
+66
View File
@@ -767,4 +767,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,
},
]; ];
+29 -5
View File
@@ -7,22 +7,25 @@
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger as honoLogger } from "hono/logger";
import { aboutRouter } from "./routes/about.js"; import { aboutRouter } from "./routes/about.js";
import { apotheosisRouter } from "./routes/apotheosis.js"; import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js"; import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js"; import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js"; import { craftRouter } from "./routes/craft.js";
import { debugRouter } from "./routes/debug.js";
import { exploreRouter } from "./routes/explore.js"; import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js"; import { gameRouter } from "./routes/game.js";
import { leaderboardRouter } from "./routes/leaderboards.js"; import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js"; import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js"; import { profileRouter } from "./routes/profile.js";
import { transcendenceRouter } from "./routes/transcendence.js"; import { transcendenceRouter } from "./routes/transcendence.js";
import { logger } from "./services/logger.js";
const app = new Hono(); const app = new Hono();
app.use("*", logger()); app.use("*", honoLogger());
app.use( app.use(
"*", "*",
cors({ cors({
@@ -33,6 +36,8 @@ app.use(
); );
app.route("/about", aboutRouter); app.route("/about", aboutRouter);
app.route("/debug", debugRouter);
app.route("/fe", frontendRouter);
app.route("/auth", authRouter); app.route("/auth", authRouter);
app.route("/game", gameRouter); app.route("/game", gameRouter);
app.route("/boss", bossRouter); app.route("/boss", bossRouter);
@@ -48,8 +53,27 @@ app.get("/health", (context) => {
return context.json({ status: "ok" }); return context.json({ status: "ok" });
}); });
app.onError((error, context) => {
void logger.error(
"hono_unhandled_error",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
});
const port = Number(process.env.PORT ?? 3001); const port = Number(process.env.PORT ?? 3001);
serve({ fetch: app.fetch, port: port }, () => { try {
process.stdout.write(`Elysium API running on port ${String(port)}\n`); serve({ fetch: app.fetch, port: port }, () => {
}); process.stdout.write(`Elysium API running on port ${String(port)}\n`);
});
} catch (error) {
void logger.error(
"server_startup",
error instanceof Error
? error
: new Error(String(error)),
);
}
+8 -1
View File
@@ -6,6 +6,7 @@
*/ */
import { verifyToken } from "../services/jwt.js"; import { verifyToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
@@ -33,7 +34,13 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
try { try {
const payload = verifyToken(token); const payload = verifyToken(token);
context.set("discordId", payload.discordId); context.set("discordId", payload.discordId);
} catch { } catch (error) {
void logger.error(
"auth_middleware",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Invalid or expired token" }, 401); return context.json({ error: "Invalid or expired token" }, 401);
} }
+19 -6
View File
@@ -7,6 +7,7 @@
/* eslint-disable stylistic/max-len -- URL cannot be shortened */ /* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */ /* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono"; import { Hono } from "hono";
import { logger } from "../services/logger.js";
import type { AboutResponse, GiteaRelease } from "@elysium/types"; import type { AboutResponse, GiteaRelease } from "@elysium/types";
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
const aboutRouter = new Hono(); const aboutRouter = new Hono();
aboutRouter.get("/", async(context) => { aboutRouter.get("/", async(context) => {
const releases = await fetchReleases(); try {
const body: AboutResponse = { const releases = await fetchReleases();
apiVersion, const body: AboutResponse = {
releases, apiVersion,
}; releases,
return context.json(body); };
return context.json(body);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
} catch (error) {
void logger.error(
"about",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { aboutRouter }; export { aboutRouter };
+97 -82
View File
@@ -5,6 +5,8 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Route handler requires many steps */ /* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable max-statements -- Route handler requires many statements */
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */ /* eslint-disable stylistic/max-len -- Description string cannot be shortened */
import { Hono } from "hono"; import { Hono } from "hono";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
@@ -13,6 +15,7 @@ import {
buildPostApotheosisState, buildPostApotheosisState,
isEligibleForApotheosis, isEligibleForApotheosis,
} from "../services/apotheosis.js"; } from "../services/apotheosis.js";
import { logger } from "../services/logger.js";
import { import {
grantApotheosisRole, grantApotheosisRole,
postMilestoneWebhook, postMilestoneWebhook,
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
apotheosisRouter.use("*", authMiddleware); apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async(context) => { apotheosisRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
const rawState: unknown = record.state; const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState; const state = rawState as GameState;
if (!isEligibleForApotheosis(state)) { if (!isEligibleForApotheosis(state)) {
return context.json( return context.json(
{ {
error: error:
"Not eligible for Apotheosis — purchase all Transcendence upgrades first", "Not eligible for Apotheosis — purchase all Transcendence upgrades first",
}, },
400, 400,
); );
} }
// Capture current-run stats before the nuclear reset // Capture current-run stats before the nuclear reset
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
const runBossesDefeated = state.bosses.filter((b) => {
return b.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((q) => {
return q.status === "completed";
}).length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
return sum + a.count;
}, 0);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null;
}).length;
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state,
state.player.characterName,
);
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
void grantApotheosisRole(discordId);
void postMilestoneWebhook(discordId, "apotheosis", {
apotheosis: updatedApotheosisData.count,
prestige: updatedState.prestige.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next 9 -- @preserve */
transcendence: updatedState.transcendence?.count ?? 0, const runBossesDefeated = state.bosses.filter((b) => {
}); return b.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((q) => {
return q.status === "completed";
}).length;
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
return sum + a.count;
}, 0);
return context.json({ apotheosisCount: updatedApotheosisData.count }); // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null;
}).length;
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state,
state.player.characterName,
);
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
const apotheosisCount = updatedApotheosisData.count;
void logger.metric("apotheosis", 1, { apotheosisCount, discordId });
void grantApotheosisRole(discordId);
void postMilestoneWebhook(discordId, "apotheosis", {
apotheosis: updatedApotheosisData.count,
prestige: updatedState.prestige.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
transcendence: updatedState.transcendence?.count ?? 0,
});
return context.json({ apotheosisCount: updatedApotheosisData.count });
} catch (error) {
void logger.error(
"apotheosis",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { apotheosisRouter }; export { apotheosisRouter };
+12 -1
View File
@@ -15,6 +15,7 @@ import {
fetchDiscordUser, fetchDiscordUser,
} from "../services/discord.js"; } from "../services/discord.js";
import { signToken } from "../services/jwt.js"; import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { Player } from "@elysium/types"; import type { Player } from "@elysium/types";
const authRouter = new Hono(); const authRouter = new Hono();
@@ -92,6 +93,8 @@ authRouter.get("/callback", async(context) => {
}); });
const jwtToken = signToken(player.discordId); const jwtToken = signToken(player.discordId);
void logger.log("info", `New player registered: ${player.discordId}`);
void logger.metric("user_registered", 1, { discordId: player.discordId });
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
@@ -111,6 +114,8 @@ authRouter.get("/callback", async(context) => {
}); });
const jwtToken = signToken(updated.discordId); const jwtToken = signToken(updated.discordId);
void logger.log("info", `Player logged in: ${updated.discordId}`);
void logger.metric("user_login", 1, { discordId: updated.discordId });
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
@@ -118,7 +123,13 @@ authRouter.get("/callback", async(context) => {
return context.redirect( return context.redirect(
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`, `${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
); );
} catch { } catch (error) {
void logger.error(
"auth_callback",
error instanceof Error
? error
: new Error(String(error)),
);
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173"; const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
+253 -232
View File
@@ -8,6 +8,7 @@
/* eslint-disable max-statements -- Boss handler requires many statements */ /* eslint-disable max-statements -- Boss handler requires many statements */
/* eslint-disable complexity -- Boss handler has inherent complexity */ /* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */ /* eslint-disable stylistic/max-len -- Long lines in combat logic */
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
import { import {
computeSetBonuses, computeSetBonuses,
getActiveCompanionBonus, getActiveCompanionBonus,
@@ -20,6 +21,7 @@ import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
const bossRouter = new Hono<HonoEnvironment>(); const bossRouter = new Hono<HonoEnvironment>();
@@ -121,254 +123,273 @@ const calculatePartyStats = (
}; };
bossRouter.post("/challenge", async(context) => { bossRouter.post("/challenge", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<{ bossId: string }>(); const discordId = context.get("discordId");
const body = await context.req.json<{ bossId: string }>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.bossId) { if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400); return context.json({ error: "Invalid request body" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
const boss = state.bosses.find((b) => {
return b.id === body.bossId;
});
if (!boss) {
return context.json({ error: "Boss not found" }, 404);
}
if (boss.status !== "available" && boss.status !== "in_progress") {
return context.json({ error: "Boss is not currently available" }, 400);
}
if (boss.prestigeRequirement > state.prestige.count) {
return context.json({ error: "Prestige requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if (
partyDPS === 0
|| partyMaxHp === 0
|| !Number.isFinite(partyDPS)
|| !Number.isFinite(partyMaxHp)
) {
return context.json(
{ error: "Your party has no adventurers ready to fight" },
400,
);
}
const bossHpBefore = boss.currentHp;
const bossDPS = boss.damagePerSecond;
const timeToKillBoss = bossHpBefore / partyDPS;
const timeToKillParty = partyMaxHp / bossDPS;
const won = timeToKillBoss <= timeToKillParty;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let partyHpRemaining: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossHpAtBattleEnd: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossUpdatedHp: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let rewards: BossChallengeResponse["rewards"];
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let casualties: BossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossUpdatedHp = 0;
const bossDamageDealt = bossDPS * timeToKillBoss;
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
boss.status = "defeated";
boss.currentHp = 0;
state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = state.upgrades.find((u) => {
return u.id === upgradeId;
});
if (upgrade) {
upgrade.unlocked = true;
}
} }
// Grant equipment rewards — auto-equip if the slot is currently empty const record = await prisma.gameState.findUnique({ where: { discordId } });
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 14 -- @preserve */
for (const equipmentId of boss.equipmentRewards) {
const equipment = state.equipment.find((item) => {
return item.id === equipmentId;
});
if (equipment) {
equipment.owned = true;
const slotAlreadyEquipped = state.equipment.some((item) => { if (!record) {
return item.type === equipment.type && item.equipped; return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
const boss = state.bosses.find((b) => {
return b.id === body.bossId;
});
if (!boss) {
return context.json({ error: "Boss not found" }, 404);
}
if (boss.status !== "available" && boss.status !== "in_progress") {
return context.json({ error: "Boss is not currently available" }, 400);
}
if (boss.prestigeRequirement > state.prestige.count) {
return context.json({ error: "Prestige requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if (
partyDPS === 0
|| partyMaxHp === 0
|| !Number.isFinite(partyDPS)
|| !Number.isFinite(partyMaxHp)
) {
return context.json(
{ error: "Your party has no adventurers ready to fight" },
400,
);
}
const bossHpBefore = boss.currentHp;
const bossDPS = boss.damagePerSecond;
const timeToKillBoss = bossHpBefore / partyDPS;
const timeToKillParty = partyMaxHp / bossDPS;
const won = timeToKillBoss <= timeToKillParty;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let partyHpRemaining: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossHpAtBattleEnd: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
let bossUpdatedHp: number;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let rewards: BossChallengeResponse["rewards"];
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let casualties: BossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossUpdatedHp = 0;
const bossDamageDealt = bossDPS * timeToKillBoss;
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
boss.status = "defeated";
boss.currentHp = 0;
state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = state.upgrades.find((u) => {
return u.id === upgradeId;
}); });
if (!slotAlreadyEquipped) { if (upgrade) {
equipment.equipped = true; upgrade.unlocked = true;
}
}
// Grant equipment rewards — auto-equip if the slot is currently empty
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 14 -- @preserve */
for (const equipmentId of boss.equipmentRewards) {
const equipment = state.equipment.find((item) => {
return item.id === equipmentId;
});
if (equipment) {
equipment.owned = true;
const slotAlreadyEquipped = state.equipment.some((item) => {
return item.type === equipment.type && item.equipped;
});
if (!slotAlreadyEquipped) {
equipment.equipped = true;
}
}
}
// Unlock next boss in the same zone (zone-based sequential progression)
const zoneBosses = state.bosses.filter((b) => {
return b.zoneId === boss.zoneId;
});
const zoneIndex = zoneBosses.findIndex((b) => {
return b.id === body.bossId;
});
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
if (
nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
) {
const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id;
});
if (nextBossInState) {
nextBossInState.status = "available";
}
}
/*
* Unlock any zone whose unlock conditions are now both satisfied
* (final boss defeated AND final quest completed)
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
for (const zone of state.zones) {
if (zone.status === "unlocked") {
continue;
}
if (zone.unlockBossId !== body.bossId) {
continue;
}
// Boss condition just became satisfied — check the quest condition too
const questSatisfied
= zone.unlockQuestId === null
|| state.quests.some((q) => {
return q.id === zone.unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
continue;
}
zone.status = "unlocked";
const updatedZoneBosses = state.bosses.filter((b) => {
return b.zoneId === zone.id;
});
const [ firstUpdatedBoss ] = updatedZoneBosses;
if (
firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
) {
firstUpdatedBoss.status = "available";
}
}
// Update daily boss challenge progress
if (state.dailyChallenges) {
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
state.dailyChallenges,
"bossesDefeated",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
// First-kill bounty — only awarded once across all prestiges
const staticBoss = defaultBosses.find((b) => {
return b.id === body.bossId;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const bountyRunestones
= boss.bountyRunestonesClaimed === true
? 0
: staticBoss?.bountyRunestones ?? 0;
if (bountyRunestones > 0) {
boss.bountyRunestonesClaimed = true;
}
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
rewards = {
bountyRunestones: bountyRunestones,
crystals: boss.crystalReward,
equipmentIds: boss.equipmentRewards,
essence: boss.essenceReward,
gold: boss.goldReward,
upgradeIds: boss.upgradeRewards,
};
} else {
const partyDamageDealt = partyDPS * timeToKillParty;
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
bossUpdatedHp = boss.maxHp;
partyHpRemaining = 0;
boss.status = "available";
boss.currentHp = boss.maxHp;
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
const casualtyFraction = (1 - victoryProgress) * 0.6;
casualties = [];
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
const killed = Math.floor(adventurer.count * casualtyFraction);
if (killed > 0) {
adventurer.count = Math.max(1, adventurer.count - killed);
casualties.push({ adventurerId: adventurer.id, killed: killed });
} }
} }
} }
// Unlock next boss in the same zone (zone-based sequential progression) const now = Date.now();
const zoneBosses = state.bosses.filter((b) => { await prisma.gameState.update({
return b.zoneId === boss.zoneId; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
}); data: { state: state as object, updatedAt: now },
const zoneIndex = zoneBosses.findIndex((b) => { where: { discordId },
return b.id === body.bossId;
});
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
if (
nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
) {
const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id;
});
if (nextBossInState) {
nextBossInState.status = "available";
}
}
/*
* Unlock any zone whose unlock conditions are now both satisfied
* (final boss defeated AND final quest completed)
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
for (const zone of state.zones) {
if (zone.status === "unlocked") {
continue;
}
if (zone.unlockBossId !== body.bossId) {
continue;
}
// Boss condition just became satisfied — check the quest condition too
const questSatisfied
= zone.unlockQuestId === null
|| state.quests.some((q) => {
return q.id === zone.unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
continue;
}
zone.status = "unlocked";
const updatedZoneBosses = state.bosses.filter((b) => {
return b.zoneId === zone.id;
});
const [ firstUpdatedBoss ] = updatedZoneBosses;
if (
firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
) {
firstUpdatedBoss.status = "available";
}
}
// Update daily boss challenge progress
if (state.dailyChallenges) {
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
state.dailyChallenges,
"bossesDefeated",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
// First-kill bounty — look up authoritative bounty from static data
const staticBoss = defaultBosses.find((b) => {
return b.id === body.bossId;
}); });
// eslint-disable-next-line capitalized-comments -- v8 ignore const { bossId } = body;
/* v8 ignore next -- @preserve */ void logger.metric("boss_challenge", 1, { bossId, discordId, won });
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
rewards = { const bossMaxHp = boss.maxHp;
bountyRunestones: bountyRunestones, const bossNewHp = bossUpdatedHp;
crystals: boss.crystalReward, const response: BossChallengeResponse = {
equipmentIds: boss.equipmentRewards, bossDPS,
essence: boss.essenceReward, bossHpAtBattleEnd,
gold: boss.goldReward, bossHpBefore,
upgradeIds: boss.upgradeRewards, bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
}; };
} else { if (rewards !== undefined) {
const partyDamageDealt = partyDPS * timeToKillParty; response.rewards = rewards;
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt); }
bossUpdatedHp = boss.maxHp; if (casualties !== undefined) {
partyHpRemaining = 0; response.casualties = casualties;
boss.status = "available";
boss.currentHp = boss.maxHp;
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
const casualtyFraction = (1 - victoryProgress) * 0.6;
casualties = [];
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) {
continue;
}
const killed = Math.floor(adventurer.count * casualtyFraction);
if (killed > 0) {
adventurer.count = Math.max(1, adventurer.count - killed);
casualties.push({ adventurerId: adventurer.id, killed: killed });
}
} }
}
const now = Date.now(); return context.json(response);
await prisma.gameState.update({ } catch (error) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ void logger.error(
data: { state: state as object, updatedAt: now }, "boss_challenge",
where: { discordId }, error instanceof Error
}); ? error
: new Error(String(error)),
const bossMaxHp = boss.maxHp; );
const bossNewHp = bossUpdatedHp; return context.json({ error: "Internal server error" }, 500);
const response: BossChallengeResponse = {
bossDPS,
bossHpAtBattleEnd,
bossHpBefore,
bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
};
if (rewards !== undefined) {
response.rewards = rewards;
} }
if (casualties !== undefined) {
response.casualties = casualties;
}
return context.json(response);
}); });
export { bossRouter }; export { bossRouter };
+95 -82
View File
@@ -11,6 +11,7 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js"; import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
CraftRecipeRequest, CraftRecipeRequest,
@@ -63,94 +64,106 @@ const recomputeCraftedMultipliers = (
}; };
craftRouter.post("/", async(context) => { craftRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<CraftRecipeRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<CraftRecipeRequest>();
const { recipeId } = body; const { recipeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!recipeId) { if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400); return context.json({ error: "recipeId is required" }, 400);
}
const recipe = defaultRecipes.find((r) => {
return r.id === recipeId;
});
if (!recipe) {
return context.json({ error: "Unknown recipe" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
return context.json({ error: "Recipe already crafted" }, 400);
}
// Verify the player has all required materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
const quantity = material?.quantity ?? 0;
if (quantity < requirement.quantity) {
return context.json(
{
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
},
400,
);
} }
}
// Deduct materials const recipe = defaultRecipes.find((r) => {
for (const requirement of recipe.requiredMaterials) { return r.id === recipeId;
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
}); });
if (material) { if (!recipe) {
material.quantity = material.quantity - requirement.quantity; return context.json({ error: "Unknown recipe" }, 404);
} }
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
return context.json({ error: "Recipe already crafted" }, 400);
}
// Verify the player has all required materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
const quantity = material?.quantity ?? 0;
if (quantity < requirement.quantity) {
return context.json(
{
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
},
400,
);
}
}
// Deduct materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
});
if (material) {
material.quantity = material.quantity - requirement.quantity;
}
}
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const updatedMultipliers = recomputeCraftedMultipliers(
state.exploration.craftedRecipeIds,
);
state.exploration.craftedGoldMultiplier
= updatedMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier
= updatedMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier
= updatedMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() },
where: { discordId },
});
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
recipeId,
...updatedMultipliers,
};
return context.json(response);
} catch (error) {
void logger.error(
"craft",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
} }
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const updatedMultipliers = recomputeCraftedMultipliers(
state.exploration.craftedRecipeIds,
);
state.exploration.craftedGoldMultiplier
= updatedMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier
= updatedMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier
= updatedMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() },
where: { discordId },
});
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
recipeId,
...updatedMultipliers,
};
return context.json(response);
}); });
export { craftRouter }; export { craftRouter };
+441
View File
@@ -0,0 +1,441 @@
/**
* @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 { Hono } from "hono";
import { defaultBosses } from "../data/bosses.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 { defaultZones } from "../data/zones.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
/**
* Computes the HMAC-SHA256 of data using the given secret.
* @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;
};
/**
* 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,
): {
bossesUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
zonesUnlocked: number;
} => {
const zonesUnlocked = applyZoneUnlocks(state);
const questsUnlocked = applyQuestUnlocks(state);
const bossesUnlocked = applyBossUnlocks(state);
const explorationUnlocked = applyExplorationUnlocks(state);
return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked };
};
const 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 { bossesUnlocked, explorationUnlocked, questsUnlocked, 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({
bossesUnlocked,
explorationUnlocked,
questsUnlocked,
signature,
state,
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("/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 };
+280 -254
View File
@@ -12,6 +12,7 @@ import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js"; import { initialExploration } from "../data/initialState.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
ExploreCollectEventResult, ExploreCollectEventResult,
@@ -49,280 +50,233 @@ const pickNothingMessage = (): string => {
}; };
exploreRouter.post("/start", async(context) => { exploreRouter.post("/start", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<ExploreStartRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<ExploreStartRequest>();
const { areaId } = body; const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) { if (!areaId) {
return context.json({ error: "areaId is required" }, 400); return context.json({ error: "areaId is required" }, 400);
} }
const explorationArea = defaultExplorations.find((a) => { const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId; return a.id === areaId;
}); });
if (!explorationArea) { if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404); return context.json({ error: "Unknown exploration area" }, 404);
} }
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
const rawState: unknown = record.state; const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState; const state = rawState as GameState;
// Backfill exploration state for old saves that predate this feature // Backfill exploration state for old saves that predate this feature
if (!state.exploration) { if (!state.exploration) {
state.exploration = structuredClone(initialExploration); state.exploration = structuredClone(initialExploration);
// Unlock areas for zones already unlocked in this save // Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) { for (const area of state.exploration.areas) {
const areaData = defaultExplorations.find((areaItem) => { const areaData = defaultExplorations.find((areaItem) => {
return areaItem.id === area.id; return areaItem.id === area.id;
}); });
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 3 -- @preserve */
if (!areaData) { if (!areaData) {
continue; continue;
} }
const zone = state.zones.find((z) => { const zone = state.zones.find((z) => {
return z.id === areaData.zoneId; return z.id === areaData.zoneId;
}); });
if (zone?.status === "unlocked") { if (zone?.status === "unlocked") {
area.status = "available"; area.status = "available";
}
} }
} }
}
const zone = state.zones.find((z) => { const zone = state.zones.find((z) => {
return z.id === explorationArea.zoneId; return z.id === explorationArea.zoneId;
}); });
if (!zone || zone.status !== "unlocked") { if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400); return context.json({ error: "Zone is not unlocked" }, 400);
} }
const area = state.exploration.areas.find((a) => { const area = state.exploration.areas.find((a) => {
return a.id === areaId; return a.id === areaId;
}); });
if (!area) { if (!area) {
return context.json({ error: "Exploration area not found in state" }, 404); return context.json(
} { error: "Exploration area not found in state" },
404,
);
}
const anyInProgress = state.exploration.areas.some((a) => { const anyInProgress = state.exploration.areas.some((a) => {
return a.status === "in_progress"; return a.status === "in_progress";
}); });
if (anyInProgress) { if (anyInProgress) {
return context.json( return context.json(
{ error: "An exploration is already in progress" }, { error: "An exploration is already in progress" },
400, 400,
); );
} }
if (area.status === "locked") { if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400); return context.json({ error: "Exploration area is locked" }, 400);
} }
const now = Date.now(); const now = Date.now();
area.status = "in_progress"; area.status = "in_progress";
area.startedAt = now; area.startedAt = now;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
const response: ExploreStartResponse = {
areaId,
endsAt,
};
return context.json(response);
});
exploreRouter.post("/collect", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreCollectRequest>();
const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < nothingProbability) {
await prisma.gameState.update({ await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now }, data: { state: state as object, updatedAt: now },
where: { discordId }, where: { discordId },
}); });
const response: ExploreCollectResponse = { // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
event: null, const endsAt = now + explorationArea.durationSeconds * 1000;
foundNothing: true, const response: ExploreStartResponse = {
materialsFound: [], areaId,
nothingMessage: pickNothingMessage(), endsAt,
}; };
return context.json(response); return context.json(response);
} catch (error) {
void logger.error(
"explore_start",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
} }
});
// Pick a random event exploreRouter.post("/collect", async(context) => {
const eventIndex = Math.floor(Math.random() * explorationArea.events.length); try {
const event = explorationArea.events[eventIndex]; const discordId = context.get("discordId");
// eslint-disable-next-line capitalized-comments -- v8 ignore const body = await context.req.json<ExploreCollectRequest>();
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
// Apply event effects and build the result summary const { areaId } = body;
let goldChange = 0; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
let essenceChange = 0; if (!areaId) {
let materialGained: { materialId: string; quantity: number } | null = null; return context.json({ error: "areaId is required" }, 400);
}
if (event.effect.type === "gold_gain") { const explorationArea = defaultExplorations.find((a) => {
// Gold gain — amount may be undefined in edge cases return a.id === areaId;
// eslint-disable-next-line capitalized-comments -- v8 ignore });
/* v8 ignore next -- @preserve */ if (!explorationArea) {
const amount = event.effect.amount ?? 0; return context.json({ error: "Unknown exploration area" }, 404);
state.resources.gold = state.resources.gold + amount; }
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount; const record = await prisma.gameState.findUnique({ where: { discordId } });
} else if (event.effect.type === "gold_loss") { if (!record) {
// Gold loss — amount may be undefined in edge cases return context.json({ error: "No save found" }, 404);
// eslint-disable-next-line capitalized-comments -- v8 ignore }
/* v8 ignore next -- @preserve */
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0); const rawState: unknown = record.state;
state.resources.gold = state.resources.gold - amount; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
goldChange = -amount; const state = rawState as GameState;
} else if (event.effect.type === "essence_gain") {
// Essence gain — amount may be undefined in edge cases if (!state.exploration) {
// eslint-disable-next-line capitalized-comments -- v8 ignore return context.json({ error: "No exploration state found" }, 400);
/* v8 ignore next -- @preserve */ }
const amount = event.effect.amount ?? 0;
state.resources.essence = state.resources.essence + amount; const area = state.exploration.areas.find((a) => {
essenceChange = amount; return a.id === areaId;
} else if (event.effect.type === "material_gain") { });
const { materialId } = event.effect; if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
const quantity = event.effect.quantity ?? 1; const startedAt = area.startedAt ?? 0;
if (materialId !== undefined && materialId !== "") { const durationMs = explorationArea.durationSeconds * 1000;
const existing = state.exploration.materials.find((m) => { const expiresAt = startedAt + durationMs;
return m.materialId === materialId;
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < nothingProbability) {
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
}); });
if (existing) {
existing.quantity = existing.quantity + quantity; const response: ExploreCollectResponse = {
} else { event: null,
state.exploration.materials.push({ materialId, quantity }); foundNothing: true,
} materialsFound: [],
materialGained = { materialId, quantity }; nothingMessage: pickNothingMessage(),
// eslint-disable-next-line capitalized-comments -- v8 ignore };
/* v8 ignore next 13 -- @preserve */ return context.json(response);
} }
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// Adventurer loss — fraction and loop are defensive // Pick a random event
const eventIndex = Math.floor(
Math.random() * explorationArea.events.length,
);
const event = explorationArea.events[eventIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */ /* v8 ignore next 3 -- @preserve */
const fraction = event.effect.fraction ?? 0.05; if (!event) {
for (const adventurer of state.adventurers) { return context.json({ error: "No events available" }, 500);
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
}
} }
}
// eslint-disable-next-line capitalized-comments -- v8 ignore // Apply event effects and build the result summary
/* v8 ignore next 8 -- @preserve */ let goldChange = 0;
let adventurerLostCount = 0; let essenceChange = 0;
if (event.effect.type === "adventurer_loss") { let materialGained: { materialId: string; quantity: number } | null = null;
const fraction = event.effect.fraction ?? 0.05;
for (const adv of state.adventurers) {
const lost = Math.floor(adv.count * fraction);
adventurerLostCount = adventurerLostCount + lost;
}
}
const eventResult: ExploreCollectEventResult = { if (event.effect.type === "gold_gain") {
adventurerLostCount: adventurerLostCount, // Gold gain — amount may be undefined in edge cases
essenceChange: essenceChange, // eslint-disable-next-line capitalized-comments -- v8 ignore
goldChange: goldChange, /* v8 ignore next -- @preserve */
materialGained: materialGained, const amount = event.effect.amount ?? 0;
text: event.text, state.resources.gold = state.resources.gold + amount;
}; state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount;
// Roll for material drops from possibleMaterials (weighted random selection) } else if (event.effect.type === "gold_loss") {
const materialsFound: Array<{ materialId: string; quantity: number }> = []; // Gold loss — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
if (explorationArea.possibleMaterials.length > 0) { /* v8 ignore next -- @preserve */
let totalWeight = 0; const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
for (const materialDrop of explorationArea.possibleMaterials) { state.resources.gold = state.resources.gold - amount;
totalWeight = totalWeight + materialDrop.weight; goldChange = -amount;
} } else if (event.effect.type === "essence_gain") {
let roll = Math.random() * totalWeight; // Essence gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
for (const possible of explorationArea.possibleMaterials) { /* v8 ignore next -- @preserve */
roll = roll - possible.weight; const amount = event.effect.amount ?? 0;
if (roll <= 0) { state.resources.essence = state.resources.essence + amount;
const maxMinDiff = possible.maxQuantity - possible.minQuantity; essenceChange = amount;
const range = maxMinDiff + 1; } else if (event.effect.type === "material_gain") {
const randomOffset = Math.floor(Math.random() * range); const { materialId } = event.effect;
const quantity = randomOffset + possible.minQuantity;
const { materialId } = possible;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const quantity = event.effect.quantity ?? 1;
if (materialId !== undefined && materialId !== "") {
const existing = state.exploration.materials.find((m) => { const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId; return m.materialId === materialId;
}); });
@@ -331,25 +285,97 @@ exploreRouter.post("/collect", async(context) => {
} else { } else {
state.exploration.materials.push({ materialId, quantity }); state.exploration.materials.push({ materialId, quantity });
} }
materialGained = { materialId, quantity };
materialsFound.push({ materialId, quantity }); // eslint-disable-next-line capitalized-comments -- v8 ignore
break; /* v8 ignore next 13 -- @preserve */
}
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// Adventurer loss — fraction and loop are defensive
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const fraction = event.effect.fraction ?? 0.05;
for (const adventurer of state.adventurers) {
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
}
} }
} }
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
let adventurerLostCount = 0;
if (event.effect.type === "adventurer_loss") {
const fraction = event.effect.fraction ?? 0.05;
for (const adv of state.adventurers) {
const lost = Math.floor(adv.count * fraction);
adventurerLostCount = adventurerLostCount + lost;
}
}
const eventResult: ExploreCollectEventResult = {
adventurerLostCount: adventurerLostCount,
essenceChange: essenceChange,
goldChange: goldChange,
materialGained: materialGained,
text: event.text,
};
// Roll for material drops from possibleMaterials (weighted random selection)
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
if (explorationArea.possibleMaterials.length > 0) {
let totalWeight = 0;
for (const materialDrop of explorationArea.possibleMaterials) {
totalWeight = totalWeight + materialDrop.weight;
}
let roll = Math.random() * totalWeight;
for (const possible of explorationArea.possibleMaterials) {
roll = roll - possible.weight;
if (roll <= 0) {
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
const range = maxMinDiff + 1;
const randomOffset = Math.floor(Math.random() * range);
const quantity = randomOffset + possible.minQuantity;
const { materialId } = possible;
const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity = existing.quantity + quantity;
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialsFound.push({ materialId, quantity });
break;
}
}
}
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: ExploreCollectResponse = {
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
} catch (error) {
void logger.error(
"explore_collect",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
} }
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: ExploreCollectResponse = {
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
}); });
export { exploreRouter }; export { exploreRouter };
+55
View File
@@ -0,0 +1,55 @@
/**
* @file Frontend logging routes that pipe client-side logs to the telemetry service.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Hono } from "hono";
import { logger } from "../services/logger.js";
const validLevels = new Set([ "debug", "info", "warn" ]);
const frontendRouter = new Hono();
frontendRouter.post("/log", async(context) => {
try {
const body = await context.req.json<{ level: string; message: string }>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.level || !body.message || !validLevels.has(body.level)) {
return context.json({ error: "level and message are required" }, 400);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated above */
void logger.log(body.level as "debug" | "info" | "warn", `[FE] ${body.message}`);
return context.json({ ok: true });
} catch (error) {
void logger.error(
"frontend_log",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
frontendRouter.post("/error", async(context) => {
try {
const body = await context.req.json<{ context: string; message: string }>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.context || !body.message) {
return context.json({ error: "context and message are required" }, 400);
}
void logger.error(`[FE] ${body.context}`, new Error(body.message));
return context.json({ ok: true });
} catch (error) {
void logger.error(
"frontend_error",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { frontendRouter };
+392 -356
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 { logger } from "../services/logger.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js";
import { import {
checkAndUnlockTitles, checkAndUnlockTitles,
@@ -681,18 +682,387 @@ const gameRouter = new Hono<HonoEnvironment>();
gameRouter.use("*", authMiddleware); gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async(context) => { gameRouter.get("/load", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const [ record, playerRecord ] = await Promise.all([ const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }), prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }),
]); ]);
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) {
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(),
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked,
// eslint-disable-next-line stylistic/max-len -- Long property names exceed limit after try-block indent
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.create({
data: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
state: freshState as object,
updatedAt: createdAt,
},
});
const secret = process.env.ANTI_CHEAT_SECRET;
// Sign the state for anti-cheat verification
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
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,
});
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
/*
* Always sync character name from the Player record — the profile update route
* writes to Player.characterName directly, bypassing the game state blob.
*/
if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName;
}
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds }
= calculateOfflineEarnings(state, now);
if (offlineGold > 0) {
state.resources.gold = state.resources.gold + offlineGold;
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
}
if (offlineEssence > 0) {
state.resources.essence = state.resources.essence + offlineEssence;
}
// Generate or reset daily challenges if a new day has begun
state.dailyChallenges = getOrResetDailyChallenges(state);
// Daily login bonus — award once per calendar day (UTC)
const todayUTC = new Date().toISOString().
slice(0, 10);
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
slice(0, 10);
let loginBonus: LoginBonusResult | null = null;
// Default loginStreak to 1 for brand-new accounts
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
let loginStreak = playerRecord?.loginStreak ?? 1;
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
const previousStreak = playerRecord.loginStreak;
const updatedStreak
= playerRecord.lastLoginDate === yesterdayUTC
? previousStreak + 1
: 1;
const dayIndex = (updatedStreak - 1) % 7;
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
const reward = dailyRewards[dayIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
state.resources.gold = Math.min(
state.resources.gold + goldEarned,
resourceCap,
);
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned;
state.resources.crystals = Math.min(
state.resources.crystals + crystalsEarned,
resourceCap,
);
loginStreak = updatedStreak;
loginBonus = {
crystalsEarned: crystalsEarned,
day: dayIndex + 1,
goldEarned: goldEarned,
streak: updatedStreak,
weekMultiplier: weekMultiplier,
};
await prisma.player.
update({
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
}
state.lastTickAt = now;
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
// Persist updated state immediately so offline/login rewards aren't double-counted.
/*
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
* server-side and must be persisted immediately so they aren't double-counted.
*/
await prisma.gameState.
update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
const secret = process.env.ANTI_CHEAT_SECRET;
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
return context.json({
currentSchemaVersion,
loginBonus,
loginStreak,
offlineEssence,
offlineGold,
offlineSeconds,
schemaOutdated,
signature,
state,
});
} catch (error) {
void logger.error(
"game_load",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
gameRouter.post("/save", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<SaveRequest>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
if (body.state === null || body.state === undefined) {
return context.json({ error: "Missing state in request body" }, 400);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
return context.json(
{
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Save rejected: outdated save. Reset your progress to continue.",
},
409,
);
}
const secret = process.env.ANTI_CHEAT_SECRET;
const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
let stateToSave = body.state;
if (record) {
const rawPreviousState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const previousState = rawPreviousState as GameState;
// Option D: verify HMAC signature if the secret is configured and client sent one
if (secret !== undefined && body.signature !== undefined) {
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
if (body.signature !== expectedSig) {
return context.json(
{ error: "Save rejected: signature mismatch" },
400,
);
}
}
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
stateToSave = validateAndSanitize(body.state, previousState);
}
const now = Date.now();
/*
* Stamp the authoritative save timestamp into the state blob so that on the
* next load the client reads the correct value from state.player.lastSavedAt.
*/
stateToSave = {
...stateToSave,
player: { ...stateToSave.player, lastSavedAt: now },
};
/*
* Preserve the Player record's character name so that profile updates are not
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
*/
stateToSave = {
...stateToSave,
player: {
...stateToSave.player,
characterName:
playerRecord?.characterName ?? stateToSave.player.characterName,
},
};
/*
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
* This prevents clients from claiming companions they haven't legitimately unlocked.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0,
});
const clientActiveCompanionId
= stateToSave.companions?.activeCompanionId ?? null;
const validatedActiveCompanionId
= clientActiveCompanionId !== null
&& companionUnlocks.includes(clientActiveCompanionId)
? clientActiveCompanionId
: null;
stateToSave = {
...stateToSave,
companions: {
activeCompanionId: validatedActiveCompanionId,
unlockedCompanionIds: companionUnlocks,
},
};
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 6 -- @preserve */
const updatedTitles = checkAndUnlockTitles({
createdAt: playerRecord?.createdAt ?? Date.now(),
currentUnlocked: currentUnlocked,
guildName: playerRecord?.guildName ?? "",
state: stateToSave,
});
const updatedUnlocked
= updatedTitles.length > 0
? [ ...currentUnlocked, ...updatedTitles ]
: undefined;
await prisma.player.update({
data: {
characterName: stateToSave.player.characterName,
lastSavedAt: now,
totalClicks: stateToSave.player.totalClicks,
totalGoldEarned: stateToSave.player.totalGoldEarned,
...updatedUnlocked
? { unlockedTitles: updatedUnlocked }
: {},
},
where: { discordId },
});
await prisma.gameState.upsert({
create: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
state: stateToSave as unknown as never,
updatedAt: now,
},
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
update: { state: stateToSave as unknown as never, updatedAt: now },
where: { discordId },
});
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(stateToSave), secret);
return context.json({ savedAt: now, signature: signature });
} catch (error) {
void logger.error(
"game_save",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
gameRouter.post("/reset", async(context) => {
try {
const discordId = context.get("discordId");
const playerRecord = await prisma.player.findUnique({
where: { discordId },
});
if (!playerRecord) { if (!playerRecord) {
return context.json({ error: "No player found" }, 404); return context.json({ error: "No player found" }, 404);
} }
const freshState = initialGameState( const freshState = initialGameState(
{ {
avatar: playerRecord.avatar, avatar: playerRecord.avatar,
@@ -713,23 +1083,25 @@ gameRouter.get("/load", async(context) => {
}, },
playerRecord.characterName, playerRecord.characterName,
); );
const createdAt = Date.now(); const createdAt = Date.now();
await prisma.gameState.create({ await prisma.gameState.upsert({
data: { create: {
discordId: discordId, discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
state: freshState as object, state: freshState as object,
updatedAt: createdAt, 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;
// Sign the state for anti-cheat verification const secret = process.env.ANTI_CHEAT_SECRET;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion: currentSchemaVersion,
loginBonus: null, loginBonus: null,
@@ -741,351 +1113,15 @@ gameRouter.get("/load", async(context) => {
signature: signature, signature: signature,
state: freshState, state: freshState,
}); });
} } catch (error) {
void logger.error(
const rawState: unknown = record.state; "game_reset",
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ error instanceof Error
const state = rawState as GameState; ? error
: new Error(String(error)),
/*
* Always sync character name from the Player record — the profile update route
* writes to Player.characterName directly, bypassing the game state blob.
*/
if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName;
}
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds }
= calculateOfflineEarnings(state, now);
if (offlineGold > 0) {
state.resources.gold = state.resources.gold + offlineGold;
state.player.totalGoldEarned = state.player.totalGoldEarned + offlineGold;
}
if (offlineEssence > 0) {
state.resources.essence = state.resources.essence + offlineEssence;
}
// Generate or reset daily challenges if a new day has begun
state.dailyChallenges = getOrResetDailyChallenges(state);
// Daily login bonus — award once per calendar day (UTC)
const todayUTC = new Date().toISOString().
slice(0, 10);
const yesterdayUTC = new Date(now - 86_400_000).toISOString().
slice(0, 10);
let loginBonus: LoginBonusResult | null = null;
// Default loginStreak to 1 for brand-new accounts
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
let loginStreak = playerRecord?.loginStreak ?? 1;
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
const previousStreak = playerRecord.loginStreak;
const updatedStreak
= playerRecord.lastLoginDate === yesterdayUTC
? previousStreak + 1
: 1;
const dayIndex = (updatedStreak - 1) % 7;
const weekMultiplier = Math.floor((updatedStreak - 1) / 7) + 1;
const reward = dailyRewards[dayIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
const crystalsEarned = (reward?.crystals ?? 0) * weekMultiplier;
state.resources.gold = Math.min(
state.resources.gold + goldEarned,
resourceCap,
); );
state.player.totalGoldEarned = state.player.totalGoldEarned + goldEarned; return context.json({ error: "Internal server error" }, 500);
state.resources.crystals = Math.min(
state.resources.crystals + crystalsEarned,
resourceCap,
);
loginStreak = updatedStreak;
loginBonus = {
crystalsEarned: crystalsEarned,
day: dayIndex + 1,
goldEarned: goldEarned,
streak: updatedStreak,
weekMultiplier: weekMultiplier,
};
await prisma.player.
update({
data: { lastLoginDate: todayUTC, loginStreak: updatedStreak },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
} }
state.lastTickAt = now;
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
// Persist updated state immediately so offline/login rewards aren't double-counted.
/*
* Swallow write conflicts (P2034): offline earnings and login bonus are applied
* server-side and must be persisted immediately so they aren't double-counted.
*/
await prisma.gameState.
update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
}).
catch((error: unknown) => {
// Ignore write-conflict errors (P2034) — rethrow anything else
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 5 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma error shape */
const { code } = error as { code?: string };
if (code !== "P2034") {
throw error;
}
});
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const schemaOutdated = (state.schemaVersion ?? 0) < currentSchemaVersion;
const secret = process.env.ANTI_CHEAT_SECRET;
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
return context.json({
currentSchemaVersion,
loginBonus,
loginStreak,
offlineEssence,
offlineGold,
offlineSeconds,
schemaOutdated,
signature,
state,
});
});
gameRouter.post("/save", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<SaveRequest>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for malformed requests
if (body.state === null || body.state === undefined) {
return context.json({ error: "Missing state in request body" }, 400);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
if ((body.state.schemaVersion ?? 0) < currentSchemaVersion) {
return context.json(
{
error: "Save rejected: outdated save. Reset your progress to continue.",
},
409,
);
}
const secret = process.env.ANTI_CHEAT_SECRET;
const [ record, playerRecord ] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
let stateToSave = body.state;
if (record) {
const rawPreviousState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const previousState = rawPreviousState as GameState;
// Option D: verify HMAC signature if the secret is configured and client sent one
if (secret !== undefined && body.signature !== undefined) {
const expectedSig = computeHmac(JSON.stringify(previousState), secret);
if (body.signature !== expectedSig) {
return context.json(
{ error: "Save rejected: signature mismatch" },
400,
);
}
}
// Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats
stateToSave = validateAndSanitize(body.state, previousState);
}
const now = Date.now();
/*
* Stamp the authoritative save timestamp into the state blob so that on the
* next load the client reads the correct value from state.player.lastSavedAt.
*/
stateToSave = {
...stateToSave,
player: { ...stateToSave.player, lastSavedAt: now },
};
/*
* Preserve the Player record's character name so that profile updates are not
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
*/
stateToSave = {
...stateToSave,
player: {
...stateToSave.player,
characterName:
playerRecord?.characterName ?? stateToSave.player.characterName,
},
};
/*
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
* This prevents clients from claiming companions they haven't legitimately unlocked.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0,
});
const clientActiveCompanionId
= stateToSave.companions?.activeCompanionId ?? null;
const validatedActiveCompanionId
= clientActiveCompanionId !== null
&& companionUnlocks.includes(clientActiveCompanionId)
? clientActiveCompanionId
: null;
stateToSave = {
...stateToSave,
companions: {
activeCompanionId: validatedActiveCompanionId,
unlockedCompanionIds: companionUnlocks,
},
};
const currentUnlocked = parseUnlockedTitles(playerRecord?.unlockedTitles);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 6 -- @preserve */
const updatedTitles = checkAndUnlockTitles({
createdAt: playerRecord?.createdAt ?? Date.now(),
currentUnlocked: currentUnlocked,
guildName: playerRecord?.guildName ?? "",
state: stateToSave,
});
const updatedUnlocked
= updatedTitles.length > 0
? [ ...currentUnlocked, ...updatedTitles ]
: undefined;
await prisma.player.update({
data: {
characterName: stateToSave.player.characterName,
lastSavedAt: now,
totalClicks: stateToSave.player.totalClicks,
totalGoldEarned: stateToSave.player.totalGoldEarned,
...updatedUnlocked
? { unlockedTitles: updatedUnlocked }
: {},
},
where: { discordId },
});
await prisma.gameState.upsert({
create: {
discordId: discordId,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
state: stateToSave as unknown as never,
updatedAt: now,
},
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never */
update: { state: stateToSave as unknown as never, updatedAt: now },
where: { discordId },
});
const signature = secret === undefined
? undefined
: computeHmac(JSON.stringify(stateToSave), secret);
return context.json({ savedAt: now, signature: signature });
});
gameRouter.post("/reset", async(context) => {
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,
});
}); });
export { gameRouter }; export { gameRouter };
+69 -58
View File
@@ -9,6 +9,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { gameTitles } from "../data/titles.js"; import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types"; import type { GameState } from "@elysium/types";
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
}; };
leaderboardRouter.get("/", async(context) => { leaderboardRouter.get("/", async(context) => {
const category = context.req.query("category") ?? "totalGold"; try {
const limitRaw = Number(context.req.query("limit") ?? "100"); const category = context.req.query("category") ?? "totalGold";
const limit = Math.min(Math.max(1, limitRaw), 100); const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
if (!validCategories.has(category)) { if (!validCategories.has(category)) {
return context.json({ error: "Invalid category" }, 400); return context.json({ error: "Invalid category" }, 400);
} }
const [ players, gameStates ] = await Promise.all([ const [ players, gameStates ] = await Promise.all([
prisma.player.findMany(), prisma.player.findMany(),
gameStateCategories.has(category) gameStateCategories.has(category)
? prisma.gameState.findMany() ? prisma.gameState.findMany()
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
const stateMap = new Map( const stateMap = new Map(
gameStates.map((gs) => { gameStates.map((gs) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
return [ gs.discordId, gs.state as unknown as GameState ]; return [ gs.discordId, gs.state as unknown as GameState ];
}), }),
); );
const entries = players. const entries = players.
filter((player) => { filter((player) => {
return parseShowOnLeaderboards(player.profileSettings); return parseShowOnLeaderboards(player.profileSettings);
}). }).
map((player) => { map((player) => {
let value = 0; let value = 0;
if (category === "totalGold") { if (category === "totalGold") {
value = player.lifetimeGoldEarned; value = player.lifetimeGoldEarned;
} else if (category === "bossesDefeated") { } else if (category === "bossesDefeated") {
value = player.lifetimeBossesDefeated; value = player.lifetimeBossesDefeated;
} else if (category === "questsCompleted") { } else if (category === "questsCompleted") {
value = player.lifetimeQuestsCompleted; value = player.lifetimeQuestsCompleted;
} else if (category === "achievementsUnlocked") { } else if (category === "achievementsUnlocked") {
value = player.lifetimeAchievementsUnlocked; value = player.lifetimeAchievementsUnlocked;
} else { } else {
const state = stateMap.get(player.discordId); const state = stateMap.get(player.discordId);
if (category === "prestigeCount") { if (category === "prestigeCount") {
value = state?.prestige.count ?? 0; value = state?.prestige.count ?? 0;
} else if (category === "transcendenceCount") { } else if (category === "transcendenceCount") {
value = state?.transcendence?.count ?? 0; value = state?.transcendence?.count ?? 0;
} else if (category === "apotheosisCount") { } else if (category === "apotheosisCount") {
value = state?.apotheosis?.count ?? 0; value = state?.apotheosis?.count ?? 0;
}
} }
} return {
return { activeTitle: resolveTitleName(player.activeTitle),
activeTitle: resolveTitleName(player.activeTitle), avatar: player.avatar ?? null,
avatar: player.avatar ?? null, characterName: player.characterName,
characterName: player.characterName, discordId: player.discordId,
discordId: player.discordId, username: player.username,
username: player.username, value: value,
value: value, };
}; }).
}). sort((a, b) => {
sort((a, b) => { return b.value - a.value;
return b.value - a.value; }).
}). slice(0, limit).
slice(0, limit). map((entry, index) => {
map((entry, index) => { return { ...entry, rank: index + 1 };
return { ...entry, rank: index + 1 }; });
});
return context.json({ category, entries }); return context.json({ category, entries });
} catch (error) {
void logger.error(
"leaderboards",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { leaderboardRouter }; export { leaderboardRouter };
+192 -163
View File
@@ -6,11 +6,13 @@
*/ */
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js"; import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import { import {
buildPostPrestigeState, buildPostPrestigeState,
computeRunestoneMultipliers, computeRunestoneMultipliers,
@@ -25,190 +27,217 @@ const prestigeRouter = new Hono<HonoEnvironment>();
prestigeRouter.use("*", authMiddleware); prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async(context) => { prestigeRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!isEligibleForPrestige(state)) { if (!isEligibleForPrestige(state)) {
return context.json( return context.json(
{ {
error: "Not eligible for prestige — collect 1,000,000 total gold first", // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for prestige — collect 1,000,000 total gold first",
},
400,
);
}
// Update daily prestige challenge progress before resetting the run
let updatedDailyChallenges = state.dailyChallenges;
let challengeCrystals = 0;
if (updatedDailyChallenges) {
const result = updateChallengeProgress(
updatedDailyChallenges,
"prestige",
1,
);
updatedDailyChallenges = result.updatedChallenges;
challengeCrystals = result.crystalsAwarded;
}
const {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
} = buildPostPrestigeState(state, state.player.characterName);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
const finalState: GameState = {
...prestigeState,
...updatedDailyChallenges === undefined
? {}
: { dailyChallenges: updatedDailyChallenges },
resources: {
...prestigeState.resources,
crystals: prestigeState.resources.crystals + challengeCrystals,
}, },
400, };
);
}
// Update daily prestige challenge progress before resetting the run // Capture current-run stats to accumulate into lifetime totals before resetting
let updatedDailyChallenges = state.dailyChallenges; // eslint-disable-next-line capitalized-comments -- v8 ignore
let challengeCrystals = 0; /* v8 ignore next 10 -- @preserve */
if (updatedDailyChallenges) { const runBossesDefeated = state.bosses.filter((boss) => {
const result = updateChallengeProgress( return boss.status === "defeated";
updatedDailyChallenges, }).length;
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals — never reset
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
const prestigeCount = prestigeData.count;
void logger.metric("prestige", 1, { discordId, prestigeCount });
void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: prestigeState.apotheosis?.count ?? 0,
prestige: prestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0,
});
return context.json({
milestoneRunestones: milestoneRunestones,
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
runestones: runestonesEarned,
});
} catch (error) {
void logger.error(
"prestige", "prestige",
1, error instanceof Error
? error
: new Error(String(error)),
); );
updatedDailyChallenges = result.updatedChallenges; return context.json({ error: "Internal server error" }, 500);
challengeCrystals = result.crystalsAwarded;
} }
const {
milestoneRunestones,
prestigeData,
prestigeState,
runestonesEarned,
} = buildPostPrestigeState(state, state.player.characterName);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
const finalState: GameState = {
...prestigeState,
...updatedDailyChallenges === undefined
? {}
: { dailyChallenges: updatedDailyChallenges },
resources: {
...prestigeState.resources,
crystals: prestigeState.resources.crystals + challengeCrystals,
},
};
// Capture current-run stats to accumulate into lifetime totals before resetting
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 10 -- @preserve */
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals — never reset
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters
totalGoldEarned: 0,
},
where: { discordId },
});
void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: prestigeState.apotheosis?.count ?? 0,
prestige: prestigeData.count,
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0,
});
return context.json({
milestoneRunestones: milestoneRunestones,
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
runestones: runestonesEarned,
});
}); });
prestigeRouter.post("/buy-upgrade", async(context) => { prestigeRouter.post("/buy-upgrade", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<BuyPrestigeUpgradeRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
const { upgradeId } = body; const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) { if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400); return context.json({ error: "upgradeId is required" }, 400);
} }
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => { const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
return prestigeUpgrade.id === upgradeId; return prestigeUpgrade.id === upgradeId;
}); });
if (!upgrade) { if (!upgrade) {
return context.json({ error: "Unknown prestige upgrade" }, 404); return context.json({ error: "Unknown prestige upgrade" }, 404);
} }
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
const { purchasedUpgradeIds, runestones } = state.prestige; const { purchasedUpgradeIds, runestones } = state.prestige;
if (purchasedUpgradeIds.includes(upgradeId)) { if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400); return context.json({ error: "Upgrade already purchased" }, 400);
} }
if (runestones < upgrade.runestonesCost) { if (runestones < upgrade.runestonesCost) {
return context.json({ error: "Not enough runestones" }, 400); return context.json({ error: "Not enough runestones" }, 400);
} }
const updatedRunestones = runestones - upgrade.runestonesCost; const updatedRunestones = runestones - upgrade.runestonesCost;
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ]; const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedState: GameState = { const updatedState: GameState = {
...state, ...state,
prestige: { prestige: {
...state.prestige, ...state.prestige,
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestones: updatedRunestones,
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
},
};
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: Date.now() },
where: { discordId },
});
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
void logger.metric("prestige_upgrade_purchased", 1, {
discordId,
upgradeId,
});
return context.json({
purchasedUpgradeIds: updatedPurchasedUpgradeIds, purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestones: updatedRunestones, runestonesRemaining: updatedRunestones,
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds), ...multipliers,
}, });
}; } catch (error) {
void logger.error(
await prisma.gameState.update({ "prestige_buy_upgrade",
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ error instanceof Error
data: { state: updatedState as object, updatedAt: Date.now() }, ? error
where: { discordId }, : new Error(String(error)),
}); );
return context.json({ error: "Internal server error" }, 500);
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds); }
return context.json({
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestonesRemaining: updatedRunestones,
...multipliers,
});
}); });
export { prestigeRouter }; export { prestigeRouter };
+183 -162
View File
@@ -20,6 +20,7 @@ import { Hono } from "hono";
import { gameTitles } from "../data/titles.js"; import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import { parseUnlockedTitles } from "../services/titles.js"; import { parseUnlockedTitles } from "../services/titles.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
@@ -81,190 +82,210 @@ const resolveTitle = (id: string): { id: string; name: string } => {
}; };
profileRouter.get("/:discordId", async(context) => { profileRouter.get("/:discordId", async(context) => {
const { discordId } = context.req.param(); try {
const { discordId } = context.req.param();
const [ player, gameStateRecord ] = await Promise.all([ const [ player, gameStateRecord ] = await Promise.all([
prisma.player.findUnique({ where: { discordId } }), prisma.player.findUnique({ where: { discordId } }),
prisma.gameState.findUnique({ where: { discordId } }), prisma.gameState.findUnique({ where: { discordId } }),
]); ]);
if (!player) { if (!player) {
return context.json({ error: "Player not found" }, 404); return context.json({ error: "Player not found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = gameStateRecord?.state as unknown as GameState | undefined; const state = gameStateRecord?.state as unknown as GameState | undefined;
const prestigeCount = state?.prestige.count ?? 0; const prestigeCount = state?.prestige.count ?? 0;
const transcendenceCount = state?.transcendence?.count ?? 0; const transcendenceCount = state?.transcendence?.count ?? 0;
const apotheosisCount = state?.apotheosis?.count ?? 0; const apotheosisCount = state?.apotheosis?.count ?? 0;
const profileSettings = parseProfileSettings(player.profileSettings); const profileSettings = parseProfileSettings(player.profileSettings);
const bossesDefeated const bossesDefeated
= state?.bosses.filter((boss) => { = state?.bosses.filter((boss) => {
return boss.status === "defeated"; return boss.status === "defeated";
}).length ?? 0; }).length ?? 0;
const questsCompleted const questsCompleted
= state?.quests.filter((quest) => { = state?.quests.filter((quest) => {
return quest.status === "completed"; return quest.status === "completed";
}).length ?? 0; }).length ?? 0;
let adventurersRecruited = 0;
if (state) {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
for (const adventurer of state.adventurers) {
adventurersRecruited = adventurersRecruited + adventurer.count;
}
}
let adventurersRecruited = 0;
if (state) {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 3 -- @preserve */
for (const adventurer of state.adventurers) { const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
adventurersRecruited = adventurersRecruited + adventurer.count; return achievement.unlockedAt !== null;
} }).length;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
/* v8 ignore next 3 -- @preserve */ const unlockedTitles = unlockedTitleIds.map((id) => {
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => { return resolveTitle(id);
return achievement.unlockedAt !== null;
}).length;
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
const unlockedTitles = unlockedTitleIds.map((id) => {
return resolveTitle(id);
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 12 -- @preserve */
const equippedItems = (state?.equipment ?? []).
filter((item) => {
return item.owned && item.equipped;
}).
map((item) => {
return {
bonus: item.bonus,
name: item.name,
rarity: item.rarity,
type: item.type,
};
}); });
const completedChapters = state?.story?.completedChapters ?? []; // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 12 -- @preserve */
const equippedItems = (state?.equipment ?? []).
filter((item) => {
return item.owned && item.equipped;
}).
map((item) => {
return {
bonus: item.bonus,
name: item.name,
rarity: item.rarity,
type: item.type,
};
});
return context.json({ const completedChapters = state?.story?.completedChapters ?? [];
achievementsUnlocked: achievementsUnlocked,
activeTitle: player.activeTitle, return context.json({
adventurersRecruited: adventurersRecruited, achievementsUnlocked: achievementsUnlocked,
apotheosisCount: apotheosisCount, activeTitle: player.activeTitle,
avatar: player.avatar, adventurersRecruited: adventurersRecruited,
bio: player.bio ?? "", apotheosisCount: apotheosisCount,
bossesDefeated: bossesDefeated, avatar: player.avatar,
characterClass: player.characterClass, bio: player.bio ?? "",
characterName: player.characterName, bossesDefeated: bossesDefeated,
characterRace: player.characterRace ?? "", characterClass: player.characterClass,
completedChapters: completedChapters, characterName: player.characterName,
createdAt: player.createdAt, characterRace: player.characterRace ?? "",
currentRunClicks: state?.player.totalClicks ?? 0, completedChapters: completedChapters,
currentRunGold: state?.player.totalGoldEarned ?? 0, createdAt: player.createdAt,
equippedItems: equippedItems, currentRunClicks: state?.player.totalClicks ?? 0,
guildDescription: player.guildDescription, currentRunGold: state?.player.totalGoldEarned ?? 0,
guildName: player.guildName, equippedItems: equippedItems,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked, guildDescription: player.guildDescription,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited, guildName: player.guildName,
lifetimeBossesDefeated: player.lifetimeBossesDefeated, lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted, lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
prestigeCount: prestigeCount, lifetimeBossesDefeated: player.lifetimeBossesDefeated,
profileSettings: profileSettings, lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
pronouns: player.pronouns ?? "", prestigeCount: prestigeCount,
questsCompleted: questsCompleted, profileSettings: profileSettings,
totalClicks: player.lifetimeClicks, pronouns: player.pronouns ?? "",
totalGoldEarned: player.lifetimeGoldEarned, questsCompleted: questsCompleted,
transcendenceCount: transcendenceCount, totalClicks: player.lifetimeClicks,
unlockedTitles: unlockedTitles, totalGoldEarned: player.lifetimeGoldEarned,
username: player.username, transcendenceCount: transcendenceCount,
}); unlockedTitles: unlockedTitles,
username: player.username,
});
} catch (error) {
void logger.error(
"profile_get",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
profileRouter.put("/", authMiddleware, async(context) => { profileRouter.put("/", authMiddleware, async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<UpdateProfileRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<UpdateProfileRequest>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.characterName) { if (!body.characterName) {
return context.json({ error: "Character name cannot be empty" }, 400); return context.json({ error: "Character name cannot be empty" }, 400);
} }
const characterName = body.characterName.trim().slice(0, 32); const characterName = body.characterName.trim().slice(0, 32);
if (characterName === "") { if (characterName === "") {
return context.json({ error: "Character name cannot be empty" }, 400); return context.json({ error: "Character name cannot be empty" }, 400);
} }
const pronouns = (body.pronouns ?? "").trim().slice(0, 20); const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const characterRace = (body.characterRace ?? "").trim().slice(0, 32); const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
const characterClass = (body.characterClass ?? "").trim().slice(0, 32); const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
const bio = (body.bio ?? "").trim().slice(0, 200); const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64); const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500); const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */ /* v8 ignore next 2 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"]) const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
: "suffix"; const numberFormat = validNumberFormats.has(parsedNumberFormat)
const profileSettings: ProfileSettings = { /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
enableNotifications: body.profileSettings.enableNotifications ?? false, ? (parsedNumberFormat as ProfileSettings["numberFormat"])
enableSounds: body.profileSettings.enableSounds ?? false, : "suffix";
numberFormat: numberFormat, const profileSettings: ProfileSettings = {
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, enableNotifications: body.profileSettings.enableNotifications ?? false,
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true, enableSounds: body.profileSettings.enableSounds ?? false,
showApotheosis: body.profileSettings.showApotheosis ?? true, numberFormat: numberFormat,
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true, showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
showCurrentGold: body.profileSettings.showCurrentGold ?? true, showApotheosis: body.profileSettings.showApotheosis ?? true,
showGuildFounded: body.profileSettings.showGuildFounded ?? true, showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true, showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true, showCurrentGold: body.profileSettings.showCurrentGold ?? true,
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true, showGuildFounded: body.profileSettings.showGuildFounded ?? true,
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true, showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true, showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
showPrestige: body.profileSettings.showPrestige ?? true, showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true, showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
showTotalClicks: body.profileSettings.showTotalClicks ?? true, showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true, showPrestige: body.profileSettings.showPrestige ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true, showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
}; showTotalClicks: body.profileSettings.showTotalClicks ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true,
};
const activeTitle const activeTitle
= typeof body.activeTitle === "string" = typeof body.activeTitle === "string"
? body.activeTitle.slice(0, 64) ? body.activeTitle.slice(0, 64)
: undefined; : undefined;
const updated = await prisma.player.update({ const updated = await prisma.player.update({
data: { data: {
bio: bio, bio: bio,
characterClass: characterClass, characterClass: characterClass,
characterName: characterName, characterName: characterName,
characterRace: characterRace, characterRace: characterRace,
guildDescription: guildDescription, guildDescription: guildDescription,
guildName: guildName, guildName: guildName,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
profileSettings: profileSettings as object, profileSettings: profileSettings as object,
pronouns: pronouns, pronouns: pronouns,
...activeTitle === undefined ...activeTitle === undefined
? {} ? {}
: { activeTitle }, : { activeTitle },
}, },
where: { discordId }, where: { discordId },
}); });
return context.json({ return context.json({
activeTitle: updated.activeTitle, activeTitle: updated.activeTitle,
bio: updated.bio, bio: updated.bio,
characterClass: updated.characterClass, characterClass: updated.characterClass,
characterName: updated.characterName, characterName: updated.characterName,
characterRace: updated.characterRace, characterRace: updated.characterRace,
guildDescription: updated.guildDescription, guildDescription: updated.guildDescription,
guildName: updated.guildName, guildName: updated.guildName,
profileSettings: profileSettings, profileSettings: profileSettings,
pronouns: updated.pronouns, pronouns: updated.pronouns,
}); });
} catch (error) {
void logger.error(
"profile_update",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
}); });
export { profileRouter }; export { profileRouter };
+171 -141
View File
@@ -6,10 +6,12 @@
*/ */
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable max-statements -- Route handlers require many statements */
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js"; import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import { import {
buildPostTranscendenceState, buildPostTranscendenceState,
computeTranscendenceMultipliers, computeTranscendenceMultipliers,
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
transcendenceRouter.use("*", authMiddleware); transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async(context) => { transcendenceRouter.post("/", async(context) => {
const discordId = context.get("discordId"); try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!isEligibleForTranscendence(state)) { if (!isEligibleForTranscendence(state)) {
return context.json( return context.json(
{ {
error: "Not eligible for transcendence — defeat The Absolute One first", // eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for transcendence — defeat The Absolute One first",
},
400,
);
}
const {
echoesEarned,
transcendenceData,
transcendenceState,
} = buildPostTranscendenceState(state, state.player.characterName);
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: transcendenceState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
}, },
400, where: { discordId },
});
const transcendenceCount = transcendenceData.count;
void logger.metric("transcendence", 1, { discordId, transcendenceCount });
void postMilestoneWebhook(discordId, "transcendence", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: transcendenceState.apotheosis?.count ?? 0,
prestige: transcendenceState.prestige.count,
transcendence: transcendenceData.count,
});
return context.json({
echoes: echoesEarned,
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newTranscendenceCount: transcendenceData.count,
});
} catch (error) {
void logger.error(
"transcendence",
error instanceof Error
? error
: new Error(String(error)),
); );
return context.json({ error: "Internal server error" }, 500);
} }
const {
echoesEarned,
transcendenceData,
transcendenceState,
} = buildPostTranscendenceState(state, state.player.characterName);
// Capture current-run stats before the nuclear reset
const runBossesDefeated = state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const runQuestsCompleted = state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of state.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
const now = Date.now();
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: transcendenceState as object, updatedAt: now },
where: { discordId },
});
await prisma.player.update({
data: {
characterName: state.player.characterName,
lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
lifetimeBossesDefeated: { increment: runBossesDefeated },
lifetimeClicks: { increment: state.player.totalClicks },
// Accumulate into lifetime totals
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
totalClicks: 0,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
},
where: { discordId },
});
void postMilestoneWebhook(discordId, "transcendence", {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
apotheosis: transcendenceState.apotheosis?.count ?? 0,
prestige: transcendenceState.prestige.count,
transcendence: transcendenceData.count,
});
return context.json({
echoes: echoesEarned,
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newTranscendenceCount: transcendenceData.count,
});
}); });
transcendenceRouter.post("/buy-upgrade", async(context) => { transcendenceRouter.post("/buy-upgrade", async(context) => {
const discordId = context.get("discordId"); try {
const body = await context.req.json<BuyEchoUpgradeRequest>(); const discordId = context.get("discordId");
const body = await context.req.json<BuyEchoUpgradeRequest>();
const { upgradeId } = body; const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) { if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400); return context.json({ error: "upgradeId is required" }, 400);
} }
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => { // eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
return transcendenceUpgrade.id === upgradeId; const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
}); return transcendenceUpgrade.id === upgradeId;
if (!upgrade) { });
return context.json({ error: "Unknown echo upgrade" }, 404); if (!upgrade) {
} return context.json({ error: "Unknown echo upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } }); const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!state.transcendence) { if (!state.transcendence) {
return context.json({ error: "No transcendence data found" }, 400); return context.json({ error: "No transcendence data found" }, 400);
} }
const { purchasedUpgradeIds, echoes } = state.transcendence; const { purchasedUpgradeIds, echoes } = state.transcendence;
if (purchasedUpgradeIds.includes(upgradeId)) { if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400); return context.json({ error: "Upgrade already purchased" }, 400);
} }
if (echoes < upgrade.cost) { if (echoes < upgrade.cost) {
return context.json({ error: "Not enough echoes" }, 400); return context.json({ error: "Not enough echoes" }, 400);
} }
const updatedEchoes = echoes - upgrade.cost; const updatedEchoes = echoes - upgrade.cost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers const updatedMultipliers
= computeTranscendenceMultipliers(updatedPurchasedIds); = computeTranscendenceMultipliers(updatedPurchasedIds);
const updatedState: GameState = { const updatedState: GameState = {
...state, ...state,
transcendence: { transcendence: {
...state.transcendence, ...state.transcendence,
echoes: updatedEchoes, echoes: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
},
};
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: updatedState as object, updatedAt: Date.now() },
where: { discordId },
});
void logger.metric("transcendence_upgrade_purchased", 1, {
discordId,
upgradeId,
});
return context.json({
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds, purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers, ...updatedMultipliers,
}, });
}; } catch (error) {
void logger.error(
await prisma.gameState.update({ "transcendence_buy_upgrade",
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ error instanceof Error
data: { state: updatedState as object, updatedAt: Date.now() }, ? error
where: { discordId }, : new Error(String(error)),
}); );
return context.json({ error: "Internal server error" }, 500);
return context.json({ }
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
});
}); });
export { transcendenceRouter }; export { transcendenceRouter };
+39 -18
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js";
interface DiscordTokenResponse { interface DiscordTokenResponse {
access_token: string; access_token: string;
@@ -50,18 +51,28 @@ const exchangeCode = async(
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
const response = await fetch("https://discord.com/api/v10/oauth2/token", { try {
body: parameters.toString(), const response = await fetch("https://discord.com/api/v10/oauth2/token", {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: parameters.toString(),
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
}); method: "POST",
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Discord token exchange failed: ${response.statusText}`); throw new Error(`Discord token exchange failed: ${response.statusText}`);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
return await (response.json() as Promise<DiscordTokenResponse>);
} catch (error) {
void logger.error(
"discord_exchange_code",
error instanceof Error
? error
: new Error(String(error)),
);
throw error;
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
return await (response.json() as Promise<DiscordTokenResponse>);
}; };
/** /**
@@ -73,16 +84,26 @@ const exchangeCode = async(
const fetchDiscordUser = async( const fetchDiscordUser = async(
accessToken: string, accessToken: string,
): Promise<DiscordUser> => { ): Promise<DiscordUser> => {
const response = await fetch("https://discord.com/api/v10/users/@me", { try {
headers: { Authorization: `Bearer ${accessToken}` }, const response = await fetch("https://discord.com/api/v10/users/@me", {
}); headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Discord user fetch failed: ${response.statusText}`); throw new Error(`Discord user fetch failed: ${response.statusText}`);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
} catch (error) {
void logger.error(
"discord_fetch_user",
error instanceof Error
? error
: new Error(String(error)),
);
throw error;
} }
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
}; };
/** /**
+12
View File
@@ -0,0 +1,12 @@
/**
* @file Logger service for handling logging.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
const logger = new Logger("Elysium", process.env.LOG_TOKEN ?? "");
export { logger };
+73 -1
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 {
@@ -205,10 +206,81 @@ const buildPostPrestigeState = (
}; };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve first-kill (bounty claimed) status across the prestige reset so
* the one-time bounty is never re-awarded in subsequent runs.
*/
const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => {
const currentBoss = currentState.bosses.find((candidate) => {
return candidate.id === freshBoss.id;
});
if (
currentBoss?.bountyRunestonesClaimed === true
|| currentBoss?.status === "defeated"
) {
return { ...freshBoss, bountyRunestonesClaimed: true };
}
return freshBoss;
});
// Compute current-run contributions to accumulate into lifetime totals
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
let runAdventurersRecruited = 0;
for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
}
const runAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const prestigeState: GameState = { const prestigeState: GameState = {
...freshState, ...freshState,
// Achievements are permanent — earned achievements survive all prestiges
achievements: currentState.achievements,
/*
* Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset.
*/
autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed,
lastTickAt: Date.now(), lastTickAt: Date.now(),
prestige: prestigeData,
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after prestige.
*/
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ runAchievementsUnlocked,
lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited,
lifetimeBossesDefeated:
freshState.player.lifetimeBossesDefeated + runBossesDefeated,
lifetimeClicks:
freshState.player.lifetimeClicks + currentState.player.totalClicks,
lifetimeGoldEarned:
freshState.player.lifetimeGoldEarned
+ currentState.player.totalGoldEarned,
lifetimeQuestsCompleted:
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
},
prestige: prestigeData,
// Codex lore persists across prestiges — players keep their discovered entries // Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined ...currentState.codex === undefined
? {} ? {}
+26 -3
View File
@@ -5,8 +5,16 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
import { logger } from "./logger.js";
const discordApi = "https://discord.com/api/v10"; const discordApi = "https://discord.com/api/v10";
/**
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
* triggering desktop or mobile push notifications.
*/
const suppressNotifications = 4096;
/** /**
* Grants the apotheosis Discord role to the given player if configured. * Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action. * Fails silently so role grant errors do not affect the game action.
@@ -34,7 +42,13 @@ const grantApotheosisRole = async(discordId: string): Promise<void> => {
method: "PUT", method: "PUT",
}, },
); );
} catch { } catch (error) {
void logger.error(
"webhook_apotheosis_role",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — role grant failure must not affect the apotheosis // Graceful degradation — role grant failure must not affect the apotheosis
} }
}; };
@@ -77,11 +91,20 @@ const postMilestoneWebhook = async(
try { try {
await fetch(webhookUrl, { await fetch(webhookUrl, {
body: JSON.stringify({ content }), body: JSON.stringify({
content: content,
flags: suppressNotifications,
}),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
} catch { } catch (error) {
void logger.error(
"webhook_milestone",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — webhook failure must not affect the game action // Graceful degradation — webhook failure must not affect the game action
} }
}; };
+11
View File
@@ -55,4 +55,15 @@ describe("authMiddleware", () => {
})); }));
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("returns 401 when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error";
});
const res = await app.fetch(new Request("http://localhost/test", {
headers: { Authorization: "Bearer bad_token" },
}));
expect(res.status).toBe(401);
});
}); });
+12
View File
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post();
expect(res.status).toBe(500);
});
it("returns apotheosis count on success", async () => { it("returns apotheosis count on success", async () => {
// Need all 15 transcendence upgrades purchased for eligibility // Need all 15 transcendence upgrades purchased for eligibility
const allUpgradeIds = [ const allUpgradeIds = [
+9
View File
@@ -113,5 +113,14 @@ describe("auth route", () => {
const location = res.headers.get("Location") ?? ""; const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed"); expect(location).toContain("error=auth_failed");
}); });
it("redirects with error when callback throws a non-Error value", async () => {
const { app, exchangeCode } = await makeApp();
exchangeCode.mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code"));
expect(res.status).toBe(302);
const location = res.headers.get("Location") ?? "";
expect(location).toContain("error=auth_failed");
});
}); });
}); });
+33
View File
@@ -293,4 +293,37 @@ describe("boss route", () => {
const body = await res.json() as { won: boolean }; const body = await res.json() as { won: boolean };
expect(body.won).toBe(true); expect(body.won).toBe(true);
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
const state = makeState({
bosses: [makeBoss({
bountyRunestonesClaimed: true,
currentHp: 100,
damagePerSecond: 1,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyRunestones).toBe(0);
});
}); });
+12
View File
@@ -143,4 +143,16 @@ describe("craft route", () => {
expect(body.recipeId).toBe(TEST_RECIPE_ID); expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income"); expect(body.bonusType).toBe("gold_income");
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
}); });
+450
View File
@@ -0,0 +1,450 @@
/* 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("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);
});
});
});
+26
View File
@@ -406,5 +406,31 @@ describe("explore route", () => {
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true); expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
mockRandom.mockRestore(); mockRandom.mockRestore();
}); });
it("returns 500 when the database throws on collect", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value on collect", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
describe("POST /start error path", () => {
it("returns 500 when the database throws on start", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value on start", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
}); });
}); });
+136
View File
@@ -0,0 +1,136 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
vi.mock("../../src/services/logger.js", () => ({
logger: {
log: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
},
}));
describe("frontend route", () => {
let loggerMock: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(async () => {
vi.clearAllMocks();
const { logger } = await import("../../src/services/logger.js");
loggerMock = logger as typeof loggerMock;
});
const makeApp = async () => {
const { frontendRouter } = await import("../../src/routes/frontend.js");
const app = new Hono();
app.route("/frontend", frontendRouter);
return app;
};
const postLog = async (body: unknown, contentType = "application/json") => {
const app = await makeApp();
return app.fetch(new Request("http://localhost/frontend/log", {
method: "POST",
headers: { "Content-Type": contentType },
body: typeof body === "string" ? body : JSON.stringify(body),
}));
};
const postError = async (body: unknown, contentType = "application/json") => {
const app = await makeApp();
return app.fetch(new Request("http://localhost/frontend/error", {
method: "POST",
headers: { "Content-Type": contentType },
body: typeof body === "string" ? body : JSON.stringify(body),
}));
};
describe("POST /log", () => {
it("returns 200 when level is debug and message is present", async () => {
const res = await postLog({ level: "debug", message: "test debug" });
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 200 when level is info and message is present", async () => {
const res = await postLog({ level: "info", message: "test info" });
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 200 when level is warn and message is present", async () => {
const res = await postLog({ level: "warn", message: "test warn" });
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when level is invalid", async () => {
const res = await postLog({ level: "error", message: "test" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("level and message are required");
});
it("returns 400 when level is missing", async () => {
const res = await postLog({ message: "test" });
expect(res.status).toBe(400);
});
it("returns 400 when message is missing", async () => {
const res = await postLog({ level: "info" });
expect(res.status).toBe(400);
});
it("returns 500 when request body is invalid JSON", async () => {
const res = await postLog("not valid json at all", "application/json");
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; });
const res = await postLog({ level: "info", message: "test" });
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
});
describe("POST /error", () => {
it("returns 200 with valid context and message", async () => {
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when context field is missing", async () => {
const res = await postError({ message: "Something went wrong" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("context and message are required");
});
it("returns 400 when message field is missing", async () => {
const res = await postError({ context: "SomeComponent" });
expect(res.status).toBe(400);
});
it("returns 500 when request body is invalid JSON", async () => {
const res = await postError("not valid json at all", "application/json");
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
it("returns 500 and covers non-Error branch when logger throws a raw value", async () => {
loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; });
const res = await postError({ context: "SomeComponent", message: "Something went wrong" });
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
});
});
+51
View File
@@ -420,6 +420,45 @@ describe("game route", () => {
}); });
}); });
describe("GET /load error path", () => {
it("returns 500 when the database throws during load", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during load", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(500);
});
});
describe("POST /save error path", () => {
const save = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/game/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 500 when the database throws during save", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await save({ state });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during save", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await save({ state });
expect(res.status).toBe(500);
});
});
describe("POST /reset", () => { describe("POST /reset", () => {
const reset = () => const reset = () =>
app.fetch(new Request("http://localhost/game/reset", { method: "POST" })); app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
@@ -450,5 +489,17 @@ describe("game route", () => {
const body = await res.json() as { signature: string | undefined }; const body = await res.json() as { signature: string | undefined };
expect(typeof body.signature).toBe("string"); expect(typeof body.signature).toBe("string");
}); });
it("returns 500 when the database throws during reset", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await reset();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during reset", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await reset();
expect(res.status).toBe(500);
});
}); });
}); });
+12
View File
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
expect(typeof body.entries[0]?.activeTitle).toBe("string"); expect(typeof body.entries[0]?.activeTitle).toBe("string");
}); });
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error"));
const res = await get();
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error");
const res = await get();
expect(res.status).toBe(500);
});
it("defaults to 0 for game-state categories when state is missing", async () => { it("defaults to 0 for game-state categories when state is missing", async () => {
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never); vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never); vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
+24
View File
@@ -93,6 +93,18 @@ describe("prestige route", () => {
expect(body.runestones).toBeGreaterThanOrEqual(0); expect(body.runestones).toBeGreaterThanOrEqual(0);
}); });
it("returns 500 when the database throws during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
it("updates daily challenge progress when dailyChallenges are set", async () => { it("updates daily challenge progress when dailyChallenges are set", async () => {
const state = makeState({ const state = makeState({
dailyChallenges: { dailyChallenges: {
@@ -152,5 +164,17 @@ describe("prestige route", () => {
expect(body.runestonesRemaining).toBe(90); // 100 - 10 expect(body.runestonesRemaining).toBe(90); // 100 - 10
expect(body.purchasedUpgradeIds).toContain("income_1"); expect(body.purchasedUpgradeIds).toContain("income_1");
}); });
it("returns 500 when the database throws during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "income_1" });
expect(res.status).toBe(500);
});
}); });
}); });
+30
View File
@@ -182,6 +182,18 @@ describe("profile route", () => {
expect(unknown?.name).toBe("unknown_title_id"); expect(unknown?.name).toBe("unknown_title_id");
}); });
it("returns 500 when the database throws during profile get", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during profile get", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(500);
});
it("includes completed story chapters in profile response", async () => { it("includes completed story chapters in profile response", async () => {
const state = makeState({ const state = makeState({
story: { story: {
@@ -256,5 +268,23 @@ describe("profile route", () => {
const body = await res.json() as { profileSettings: { numberFormat: string } }; const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix"); expect(body.profileSettings.numberFormat).toBe("suffix");
}); });
it("returns 500 when the database throws during profile update", async () => {
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error"));
const res = await put({
characterName: "NewName",
profileSettings: { numberFormat: "suffix" },
});
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during profile update", async () => {
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const res = await put({
characterName: "NewName",
profileSettings: { numberFormat: "suffix" },
});
expect(res.status).toBe(500);
});
}); });
}); });
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
expect(body.newTranscendenceCount).toBe(1); expect(body.newTranscendenceCount).toBe(1);
expect(body.echoes).toBeGreaterThanOrEqual(0); expect(body.echoes).toBeGreaterThanOrEqual(0);
}); });
it("returns 500 when the database throws during transcendence", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during transcendence", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
}); });
describe("POST /buy-upgrade", () => { describe("POST /buy-upgrade", () => {
@@ -149,5 +161,17 @@ describe("transcendence route", () => {
expect(body.echoesRemaining).toBe(95); // 100 - 5 expect(body.echoesRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("echo_income_1"); expect(body.purchasedUpgradeIds).toContain("echo_income_1");
}); });
it("returns 500 when the database throws during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(500);
});
}); });
}); });
+17
View File
@@ -86,5 +86,22 @@ describe("discord service", () => {
expect(result.id).toBe("123"); expect(result.id).toBe("123");
expect(result.username).toBe("testuser"); expect(result.username).toBe("testuser");
}); });
it("re-throws when fetch rejects with a non-Error value", async () => {
mockFetch.mockRejectedValueOnce("raw string error");
const { fetchDiscordUser } = await import("../../src/services/discord.js");
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
});
});
describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
});
}); });
}); });
+166 -8
View File
@@ -13,14 +13,24 @@ import {
} from "../../src/services/prestige.js"; } from "../../src/services/prestige.js";
import type { GameState } from "@elysium/types"; import type { GameState } from "@elysium/types";
const makePlayer = (totalGoldEarned: number) => ({ const makePlayer = (
discordId: "test_id", totalGoldEarned: number,
username: "testuser", lifetimeGoldEarned = 0,
discriminator: "0", totalClicks = 0,
avatar: null, ) => ({
totalGoldEarned, avatar: null,
totalClicks: 0, characterName: "Tester",
characterName: "Tester", discordId: "test_id",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: lifetimeGoldEarned,
lifetimeQuestsCompleted: 0,
totalClicks: totalClicks,
totalGoldEarned: totalGoldEarned,
username: "testuser",
}); });
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
@@ -242,4 +252,152 @@ describe("buildPostPrestigeState", () => {
const { prestigeState } = buildPostPrestigeState(state, "Tester"); const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.apotheosis).toEqual(apotheosis); expect(prestigeState.apotheosis).toEqual(apotheosis);
}); });
it("accumulates current-run gold into lifetime total", () => {
const state = makeMinimalState({
player: makePlayer(4_000_000, 1_000_000),
});
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000);
});
it("accumulates current-run clicks into lifetime total", () => {
const state = makeMinimalState({
player: makePlayer(4_000_000, 0, 500),
});
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses into lifetime total", () => {
const defeatedBoss = {
bountyRunestones: 0,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "boss_1",
maxHp: 100,
name: "Boss One",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "zone_1",
};
const state = makeMinimalState({ bosses: [ defeatedBoss ] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeBossesDefeated).toBe(1);
});
it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => {
const claimedBoss = {
bountyRunestones: 5,
bountyRunestonesClaimed: true,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "troll_king",
maxHp: 100,
name: "Troll King",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "verdant_vale",
};
const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
const matchingBoss = prestigeState.bosses.find((boss) => {
return boss.id === "troll_king";
});
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
});
it("sets bountyRunestonesClaimed on bosses defeated before the flag was introduced", () => {
const legacyDefeatedBoss = {
bountyRunestones: 5,
crystalReward: 0,
currentHp: 0,
damagePerSecond: 10,
description: "A boss",
equipmentRewards: [] as string[],
essenceReward: 0,
goldReward: 100,
id: "troll_king",
maxHp: 100,
name: "Troll King",
prestigeRequirement: 0,
status: "defeated" as const,
upgradeRewards: [] as string[],
zoneId: "verdant_vale",
};
const state = makeMinimalState({ bosses: [ legacyDefeatedBoss ] as GameState["bosses"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
const matchingBoss = prestigeState.bosses.find((boss) => {
return boss.id === "troll_king";
});
expect(matchingBoss?.bountyRunestonesClaimed).toBe(true);
});
it("accumulates completed quests into lifetime total", () => {
const quest = {
id: "q_1",
name: "A Quest",
description: "Do the thing",
status: "completed" as const,
zoneId: "zone_1",
};
const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1);
});
it("accumulates recruited adventurers into lifetime total", () => {
const adventurer = {
combatPower: 10,
count: 5,
essencePerSecond: 0,
goldPerSecond: 1,
id: "adv_1",
level: 1,
unlocked: true,
};
const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements from current state across prestige", () => {
const achievement = {
description: "Did a thing",
id: "ach_persisted",
name: "Achiever",
requirement: 1,
type: "totalClicks" as const,
unlockedAt: Date.now(),
};
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.achievements).toEqual([ achievement ]);
});
it("accumulates unlocked achievements into lifetime total", () => {
const achievement = {
description: "Did a thing",
id: "ach_1",
name: "Achiever",
requirement: 1,
type: "totalClicks" as const,
unlockedAt: Date.now(),
};
const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1);
});
}); });
+18 -1
View File
@@ -69,6 +69,15 @@ describe("webhook service", () => {
const { grantApotheosisRole } = await import("../../src/services/webhook.js"); const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
}); });
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
}); });
describe("postMilestoneWebhook", () => { describe("postMilestoneWebhook", () => {
@@ -88,9 +97,10 @@ describe("webhook service", () => {
await postMilestoneWebhook("user123", "prestige", counts); await postMilestoneWebhook("user123", "prestige", counts);
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://discord.com/webhook/abc"); expect(url).toBe("https://discord.com/webhook/abc");
const body = JSON.parse(options.body as string) as { content: string }; const body = JSON.parse(options.body as string) as { content: string; flags: number };
expect(body.content).toContain("<@user123>"); expect(body.content).toContain("<@user123>");
expect(body.content).toContain("prestiged"); expect(body.content).toContain("prestiged");
expect(body.flags).toBe(4096);
}); });
it("posts transcendence message correctly", async () => { it("posts transcendence message correctly", async () => {
@@ -119,5 +129,12 @@ describe("webhook service", () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined(); await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
}); });
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
mockFetch.mockRejectedValueOnce("raw string error");
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined();
});
}); });
}); });
+33
View File
@@ -5,6 +5,39 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysium — Idle RPG</title> <title>Elysium — Idle RPG</title>
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." /> <meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<!-- Open Graph -->
<meta property="og:title" content="Elysium — Idle RPG" />
<meta property="og:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://elysium.nhcarrigan.com" />
<meta property="og:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
<meta property="og:site_name" content="Elysium" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Elysium — Idle RPG" />
<meta name="twitter:description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<meta name="twitter:image" content="https://cdn.nhcarrigan.com/elysium/background.jpg" />
<!-- Plausible Analytics -->
<script defer data-domain="elysium.nhcarrigan.com" src="https://plausible.io/js/script.js"></script>
<!-- Tree-Nation -->
<script defer src="https://widgets.tree-nation.com/js/widgets/v1/widgets.min.js?v=1.0"></script>
<script>
(function () {
var interval = setInterval(function () {
if (typeof TreeNation !== "undefined") {
clearInterval(interval);
TreeNation.renderAll();
}
}, 100);
}());
</script>
<!-- Google Ads -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3569924701890974" crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.1.1", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"@elysium/types": "workspace:*", "@elysium/types": "workspace:*",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0",
"react-markdown": "10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0", "@nhcarrigan/eslint-config": "5.2.0",
+21
View File
@@ -21,6 +21,7 @@ import type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
LoadResponse, LoadResponse,
PrestigeRequest, PrestigeRequest,
PrestigeResponse, PrestigeResponse,
@@ -256,6 +257,24 @@ 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",
});
};
/**
* 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 +307,8 @@ export {
challengeBoss, challengeBoss,
collectExploration, collectExploration,
craftRecipe, craftRecipe,
debugHardReset,
forceUnlocks,
getAbout, getAbout,
getAuthUrl, getAuthUrl,
getPublicProfile, getPublicProfile,
+70
View File
@@ -0,0 +1,70 @@
/**
* @file React Error Boundary for catching unhandled render-time errors.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
import { logError } from "../utils/logError.js";
interface ErrorBoundaryProperties {
readonly children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
/**
* Catches unhandled render-time errors in the React tree, logs them to the
* backend telemetry service, and renders a fallback UI.
*/
class ErrorBoundary extends Component<
ErrorBoundaryProperties,
ErrorBoundaryState
> {
// eslint-disable-next-line jsdoc/require-jsdoc -- React Error Boundary constructor is standard boilerplate
public constructor(properties: ErrorBoundaryProperties) {
super(properties);
this.state = { hasError: false };
}
/**
* Updates state so the next render shows the fallback UI.
* @returns The updated error boundary state.
*/
public static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
/**
* Logs the error to the backend telemetry service.
* @param error - The error that was thrown during render.
* @param info - React error info containing the component stack trace.
*/
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- React lifecycle method cannot be static
public override componentDidCatch(error: Error, info: ErrorInfo): void {
logError("react_error_boundary", error, info.componentStack);
}
/**
* Renders the fallback UI when an error is caught, otherwise renders children.
* @returns The JSX element.
*/
public override render(): ReactNode {
const { hasError } = this.state;
const { children } = this.props;
if (hasError) {
return (
<div className="error-screen">
<p>{"Something went wrong. Please refresh the page."}</p>
</div>
);
}
return children;
}
}
export { ErrorBoundary };
+43 -20
View File
@@ -7,6 +7,7 @@
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */ /* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */ /* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import Markdown from "react-markdown";
import { getAbout } from "../../api/client.js"; import { getAbout } from "../../api/client.js";
import type { AboutResponse } from "@elysium/types"; import type { AboutResponse } from "@elysium/types";
@@ -30,14 +31,24 @@ const howToPlay = [
body: body:
"Purchase upgrades to multiply the gold and essence output of specific" "Purchase upgrades to multiply the gold and essence output of specific"
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent" + " adventurer tiers, or boost your whole guild. Upgrades are permanent"
+ " for the current run and compound with each other.", + " for the current run and stack multiplicatively — two ×2 upgrades"
+ " targeting the same adventurer combine to give ×4, not ×3. Global"
+ " upgrades multiply on top of adventurer-specific ones, so stacking"
+ " both types compounds the effect significantly. Late in a run, look"
+ " for the Essence Infusion upgrades — five powerful global multipliers"
+ " purchasable purely with essence, giving that resource an ongoing"
+ " use when gold upgrades are all bought.",
title: "🔧 Upgrades", title: "🔧 Upgrades",
}, },
{ {
body: body:
"Send your guild on quests that complete over time and reward gold," "Send your guild on quests that complete over time and reward gold,"
+ " essence, crystals, equipment, and upgrades. Multiple quests can run" + " essence, crystals, equipment, and upgrades. Multiple quests can run"
+ " simultaneously. Completing quests also unlocks new zones.", + " simultaneously. Completing quests also unlocks new zones."
+ " Each quest has a failure chance that increases in later zones"
+ " (from 10% in the starting zone up to 40% in the hardest zones)."
+ " If a quest fails, no rewards are granted and the quest resets —"
+ " your party must be sent again to retry it.",
title: "📜 Quests", title: "📜 Quests",
}, },
{ {
@@ -58,10 +69,12 @@ const howToPlay = [
{ {
body: body:
"Earn equipment from boss drops and quest rewards. Each piece provides" "Earn equipment from boss drops and quest rewards. Each piece provides"
+ " bonuses to gold income, click power, or combat. Rarer equipment" + " bonuses to gold income, click power, or boss combat DPS. Rarer"
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a" + " equipment provides stronger bonuses. Note: combat bonuses only"
+ " named set) to unlock escalating set bonuses shown at the top of the" + " affect boss fights — quest combat power is determined solely by"
+ " Equipment panel.", + " your adventurers. Equip matching set pieces (2 or 3 of a named set)"
+ " to unlock escalating set bonuses shown at the top of the Equipment"
+ " panel.",
title: "🗡️ Equipment & Sets", title: "🗡️ Equipment & Sets",
}, },
{ {
@@ -110,7 +123,11 @@ const howToPlay = [
+ " real-time and reward gold, essence, and crafting materials when" + " real-time and reward gold, essence, and crafting materials when"
+ " collected. Each area has a set duration — short explorations are" + " collected. Each area has a set duration — short explorations are"
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas" + " faster but longer ones offer rarer finds. A 📖 icon marks areas"
+ " you've collected from at least once, unlocking a Codex entry.", + " you've collected from at least once, unlocking a Codex entry."
+ " Exploration zones are locked until the corresponding main-game"
+ " zone is unlocked — which requires defeating that zone's final boss"
+ " and completing its final quest. The Exploration tab shows the"
+ " specific boss and quest required for each locked zone.",
title: "🗺️ Exploration", title: "🗺️ Exploration",
}, },
{ {
@@ -153,10 +170,12 @@ const howToPlay = [
{ {
body: body:
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets." "Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
+ " Each item provides bonuses to gold income, combat power, or click" + " Each item provides bonuses to gold income, boss combat DPS, or click"
+ " power. Only one item per slot can be equipped at a time — visit the" + " power. Combat bonuses only affect boss fights — quest combat power"
+ " Equipment panel to manage your loadout. Your currently equipped" + " is determined solely by your adventurers. Only one item per slot"
+ " items are displayed on your character sheet and public profile.", + " can be equipped at a time — visit the Equipment panel to manage"
+ " your loadout. Your currently equipped items are displayed on your"
+ " character sheet and public profile.",
title: "🗡️ Equipment", title: "🗡️ Equipment",
}, },
{ {
@@ -180,14 +199,16 @@ const howToPlay = [
}, },
{ {
body: body:
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest" "Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
+ " automatically sends your party on the highest-zone available quest" + " panels! Auto-Quest automatically sends your party on the"
+ " as soon as one completes, skipping quests whose combat power" + " highest-zone available quest as soon as one completes, skipping"
+ " requirement isn't met. Auto-Boss automatically challenges the" + " quests whose combat power requirement isn't met. Auto-Boss"
+ " highest available boss as soon as one is ready. Both can be toggled" + " automatically challenges the highest available boss as soon as one"
+ " on or off at any time using the 🤖 Auto button in each panel" + " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
+ " header.", + " runestones) automatically purchases the highest-tier adventurer you"
title: "🤖 Auto-Quest & Auto-Boss", + " can currently afford each tick, keeping your income growing after a"
+ " prestige without any manual clicks.",
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
}, },
{ {
body: body:
@@ -331,7 +352,9 @@ const aboutPanel = (): JSX.Element => {
</span> </span>
</button> </button>
{expandedRelease === release.tag_name {expandedRelease === release.tag_name
&& <pre className="about-release-body">{release.body}</pre> && <div className="about-release-body">
<Markdown>{release.body}</Markdown>
</div>
} }
</li> </li>
); );
@@ -7,8 +7,9 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement } from "@elysium/types"; import type { Achievement, GameState } from "@elysium/types";
/** /**
* Returns the plural form of a word based on a count. * Returns the plural form of a word based on a count.
@@ -53,9 +54,50 @@ const conditionDescription = (
} }
}; };
/**
* Returns the player's current progress value toward an achievement's unlock condition,
* mirroring the logic used by the tick engine's checkAchievements function.
* @param achievement - The achievement to evaluate progress for.
* @param state - The current game state.
* @returns The current numeric progress toward the achievement condition.
*/
const getCurrentProgress = (
achievement: Achievement,
state: GameState,
): number => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
return state.player.totalGoldEarned;
case "totalClicks":
return state.player.totalClicks;
case "bossesDefeated":
return state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
case "questsCompleted":
return state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
case "adventurerTotal":
return state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.owned;
}).length;
default:
return 0;
}
};
interface AchievementCardProperties { interface AchievementCardProperties {
readonly achievement: Achievement; readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
readonly progressValue: number;
} }
/** /**
@@ -63,26 +105,47 @@ interface AchievementCardProperties {
* @param props - The achievement card properties. * @param props - The achievement card properties.
* @param props.achievement - The achievement to display. * @param props.achievement - The achievement to display.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @param props.progressValue - The player's current progress toward the unlock condition.
* @returns The JSX element. * @returns The JSX element.
*/ */
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
const AchievementCard = ({ const AchievementCard = ({
achievement, achievement,
formatNumber, formatNumber,
progressValue,
}: AchievementCardProperties): JSX.Element => { }: AchievementCardProperties): JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null; const isUnlocked = achievement.unlockedAt !== null;
const crystals = achievement.reward?.crystals; const crystals = achievement.reward?.crystals;
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
return ( return (
<div className={`achievement-card ${isUnlocked <div className={`achievement-card ${isUnlocked
? "unlocked" ? "unlocked"
: "locked"}`}> : "locked"}`}>
<div className="achievement-icon">{achievement.icon}</div> <img
alt={achievement.name}
className="card-thumbnail"
src={cdnImage("achievements", achievement.id)}
/>
<div className="achievement-info"> <div className="achievement-info">
<h3>{achievement.name}</h3> <h3>{achievement.name}</h3>
<p>{achievement.description}</p> <p>{achievement.description}</p>
<p className="achievement-condition"> <p className="achievement-condition">
{conditionDescription(achievement, formatNumber)} {conditionDescription(achievement, formatNumber)}
</p> </p>
{!isUnlocked
&& <div className="achievement-progress">
<progress
max={achievement.condition.amount}
value={cappedProgress}
/>
<span className="achievement-progress-label">
{formatNumber(progressValue)}
{" / "}
{formatNumber(achievement.condition.amount)}
</span>
</div>
}
{crystals !== undefined {crystals !== undefined
&& <p className="achievement-reward"> && <p className="achievement-reward">
{"💎 +"} {"💎 +"}
@@ -158,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)}
/> />
); );
})} })}
@@ -9,21 +9,38 @@
/* eslint-disable complexity -- Complex component with many render paths */ /* eslint-disable complexity -- Complex component with many render paths */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer } from "@elysium/types"; import type { Adventurer } from "@elysium/types";
const iconByClass: Record<string, string> = {
cleric: "✝️",
mage: "🔮",
paladin: "🛡️",
ranger: "🏹",
rogue: "🗝️",
warrior: "🗡️",
};
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max"; type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ]; const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
/**
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
* @param stored - The raw string from localStorage (or null if absent).
* @returns A valid BatchSize value.
*/
const parseBatchSize = (stored: string | null): BatchSize => {
if (stored === "max") {
return "max";
}
const numeric = Number(stored);
if (numeric === 5) {
return 5;
}
if (numeric === 10) {
return 10;
}
if (numeric === 25) {
return 25;
}
if (numeric === 100) {
return 100;
}
return 1;
};
/** /**
* Computes the total cost to buy a batch of adventurers. * Computes the total cost to buy a batch of adventurers.
* @param adventurer - The adventurer to buy. * @param adventurer - The adventurer to buy.
@@ -105,14 +122,15 @@ const AdventurerCard = ({
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}` ? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
: "🔒 Locked"; : "🔒 Locked";
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
return ( return (
<div className={`adventurer-card ${adventurer.unlocked <div className={`adventurer-card ${adventurer.unlocked
? "" ? ""
: "locked"}`}> : "locked"}`}>
<div className="adventurer-icon">{adventurerIcon}</div> <img
alt={adventurer.name}
className="card-thumbnail"
src={cdnImage("adventurers", adventurer.id)}
/>
<div className="adventurer-info"> <div className="adventurer-info">
<h3>{adventurer.name}</h3> <h3>{adventurer.name}</h3>
<p> <p>
@@ -125,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">
{"×"} {"×"}
@@ -155,7 +177,9 @@ const AdventurerCard = ({
const AdventurerPanel = (): JSX.Element => { const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(1); const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
});
if (state === null) { if (state === null) {
return ( return (
@@ -203,6 +227,7 @@ const AdventurerPanel = (): JSX.Element => {
{batchOptions.map((option) => { {batchOptions.map((option) => {
function handleBatchSelect(): void { function handleBatchSelect(): void {
setBatchSize(option); setBatchSize(option);
localStorage.setItem("elysium_batch_size", String(option));
} }
return ( return (
<button <button
+89 -4
View File
@@ -11,6 +11,7 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Boss, GameState } from "@elysium/types"; import type { Boss, GameState } from "@elysium/types";
@@ -56,6 +57,11 @@ const BossCard = ({
return ( return (
<div className={`boss-card boss-${boss.status}`}> <div className={`boss-card boss-${boss.status}`}>
<img
alt={boss.name}
className="card-thumbnail"
src={cdnImage("bosses", boss.id)}
/>
<div className="boss-info"> <div className="boss-info">
<h3>{boss.name}</h3> <h3>{boss.name}</h3>
<p>{boss.description}</p> <p>{boss.description}</p>
@@ -120,7 +126,9 @@ const BossCard = ({
{" Equipment"} {" Equipment"}
</span> </span>
} }
{boss.status !== "defeated" && boss.bountyRunestones > 0 {boss.status !== "defeated"
&& boss.bountyRunestones > 0
&& boss.bountyRunestonesClaimed !== true
&& <span className="boss-bounty"> && <span className="boss-bounty">
{"🔮 "} {"🔮 "}
{boss.bountyRunestones} {boss.bountyRunestones}
@@ -220,11 +228,21 @@ const computePartyStats = (
* @returns The JSX element. * @returns The JSX element.
*/ */
const BossPanel = (): JSX.Element => { const BossPanel = (): JSX.Element => {
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame(); const {
state,
challengeBoss,
formatNumber,
toggleAutoBoss,
autoBossLastResult,
autoBossError,
bossError,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>( const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null, null,
); );
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
if (state === null) { if (state === null) {
@@ -249,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;
}); });
@@ -302,6 +337,11 @@ const BossPanel = (): JSX.Element => {
} }
} }
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_boss_zone", zoneId);
}
function handleToggle(): void { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -340,12 +380,57 @@ const BossPanel = (): JSX.Element => {
</div> </div>
</div> </div>
{bossError === null
? null
: <p className="auto-boss-error">
{"⚠️ "}
{bossError}
</p>
}
{autoBossError === null
? null
: <p className="auto-boss-error">
{"⚠️ Auto-boss stopped: "}
{autoBossError}
</p>
}
{autoBossLastResult !== null && autoBossError === null
? <p className="auto-boss-status">
{"🤖 Last fight: "}
{autoBossLastResult.bossName}
{autoBossLastResult.won
? " — ✅ Won"
: " — ❌ Lost"}
</p>
: null}
<ZoneSelector <ZoneSelector
activeZoneId={activeZoneId} activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId} onSelectZone={handleZoneSelect}
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats"> <div className="party-combat-stats">
<div className="combat-stat"> <div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span> <span className="stat-label">{"⚔️ Party DPS"}</span>
+12 -7
View File
@@ -14,6 +14,7 @@ import {
type PublicProfileResponse, type PublicProfileResponse,
} from "@elysium/types"; } from "@elysium/types";
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import { logError } from "../../utils/logError.js";
interface CharacterPageProperties { interface CharacterPageProperties {
readonly discordId: string; readonly discordId: string;
@@ -78,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
}, [ discordId ]); }, [ discordId ]);
function handleCopy(): void { function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => { void navigator.clipboard.writeText(window.location.href).
setCopied(true); then(() => {
setTimeout(() => { setCopied(true);
setCopied(false); setTimeout(() => {
}, 2000); setCopied(false);
}); }, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
} }
if (error !== null) { if (error !== null) {
@@ -238,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
return ( return (
<div <div
className="character-page-equipment-item" className="character-page-equipment-item"
key={item.type} key={item.name}
> >
<div className="character-page-equipment-header"> <div className="character-page-equipment-header">
<span className="character-page-equipment-slot"> <span className="character-page-equipment-slot">
@@ -19,6 +19,7 @@ import {
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react"; import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js"; import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { logError } from "../../utils/logError.js";
interface EquippedItem { interface EquippedItem {
name: string; name: string;
@@ -205,12 +206,16 @@ const CharacterSheetPanel = (): JSX.Element => {
function handleShareClick(): void { function handleShareClick(): void {
const discordId = player?.discordId ?? ""; const discordId = player?.discordId ?? "";
const url = `${window.location.origin}/character/${discordId}`; const url = `${window.location.origin}/character/${discordId}`;
void navigator.clipboard.writeText(url).then(() => { void navigator.clipboard.writeText(url).
setCopied(true); then(() => {
setTimeout(() => { setCopied(true);
setCopied(false); setTimeout(() => {
}, 2000); setCopied(false);
}); }, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
} }
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void { function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
+24 -1
View File
@@ -8,6 +8,7 @@
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js"; import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
import { cdnImage } from "../../utils/cdn.js";
import type { CodexEntry } from "@elysium/types"; import type { CodexEntry } from "@elysium/types";
/** /**
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
zone: "🗺️", zone: "🗺️",
}; };
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
adventurer: "adventurers",
boss: "bosses",
equipment: "equipment",
exploration: "explorations",
prestige: "prestige-upgrades",
quest: "quests",
recipe: "recipes",
upgrade: "upgrades",
zone: "zones",
};
/** /**
* Renders the codex panel with lore entries grouped by zone. * Renders the codex panel with lore entries grouped by zone.
* @returns The JSX element. * @returns The JSX element.
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
</span> </span>
</div> </div>
{isExpanded {isExpanded
? <p className="codex-entry-content">{entry.content}</p> ? <>
<img
alt={entry.title}
className="codex-entry-image"
src={cdnImage(
sourceTypeFolder[entry.sourceType],
entry.sourceId,
)}
/>
<p className="codex-entry-content">{entry.content}</p>
</>
: null} : null}
</div> </div>
); );
@@ -8,6 +8,7 @@
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */ /* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
import { COMPANIONS, type Companion } from "@elysium/types"; import { COMPANIONS, type Companion } from "@elysium/types";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import type { JSX } from "react"; import type { JSX } from "react";
const bonusLabels: Record<string, string> = { const bonusLabels: Record<string, string> = {
@@ -96,6 +97,11 @@ const CompanionCard = ({
: ""}`} : ""}`}
> >
<div className="companion-header"> <div className="companion-header">
<img
alt={companion.name}
className="card-thumbnail"
src={cdnImage("companions", companion.id)}
/>
<div className="companion-name-block"> <div className="companion-name-block">
<span className="companion-name">{companion.name}</span> <span className="companion-name">{companion.name}</span>
<span className="companion-title">{companion.title}</span> <span className="companion-title">{companion.title}</span>
+20 -2
View File
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { MATERIALS } from "../../data/materials.js"; import { MATERIALS } from "../../data/materials.js";
import { RECIPES } from "../../data/recipes.js"; import { RECIPES } from "../../data/recipes.js";
import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
const bonusLabel: Record<string, string> = { const bonusLabel: Record<string, string> = {
@@ -25,7 +26,9 @@ const bonusLabel: Record<string, string> = {
*/ */
const CraftingPanel = (): JSX.Element => { const CraftingPanel = (): JSX.Element => {
const { state, craftRecipe, formatNumber } = useGame(); const { state, craftRecipe, formatNumber } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
});
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null); const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
if (state === null) { if (state === null) {
@@ -67,6 +70,11 @@ const CraftingPanel = (): JSX.Element => {
}); });
} }
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_craft_zone", zoneId);
}
async function handleCraft(recipeId: string): Promise<void> { async function handleCraft(recipeId: string): Promise<void> {
setPendingRecipeId(recipeId); setPendingRecipeId(recipeId);
try { try {
@@ -84,7 +92,7 @@ const CraftingPanel = (): JSX.Element => {
<ZoneSelector <ZoneSelector
activeZoneId={activeZoneId} activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId} onSelectZone={handleZoneSelect}
zones={zones} zones={zones}
/> />
@@ -105,6 +113,11 @@ const CraftingPanel = (): JSX.Element => {
}`} }`}
key={material.id} key={material.id}
> >
<img
alt={material.name}
className="card-thumbnail"
src={cdnImage("materials", material.id)}
/>
<div className="material-info"> <div className="material-info">
<span className="material-name">{material.name}</span> <span className="material-name">{material.name}</span>
<span className="material-rarity">{material.rarity}</span> <span className="material-rarity">{material.rarity}</span>
@@ -144,6 +157,11 @@ const CraftingPanel = (): JSX.Element => {
: ""}`} : ""}`}
key={recipe.id} key={recipe.id}
> >
<img
alt={recipe.name}
className="card-thumbnail"
src={cdnImage("recipes", recipe.id)}
/>
<div className="recipe-info"> <div className="recipe-info">
<h4>{recipe.name}</h4> <h4>{recipe.name}</h4>
<p className="recipe-description">{recipe.description}</p> <p className="recipe-description">{recipe.description}</p>
+145
View File
@@ -0,0 +1,145 @@
/**
* @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" | null;
/**
* Renders the debug panel with tools for fixing stuck game state.
* @returns The JSX element.
*/
const DebugPanel = (): JSX.Element => {
const { forceUnlocks, debugHardReset, isLoading } = useGame();
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
function handleOpenForceUnlocks(): void {
setForceUnlocksResult(null);
setActiveModal("force-unlocks");
}
function handleOpenHardReset(): void {
setActiveModal("hard-reset");
}
function handleCancel(): void {
setActiveModal(null);
}
function handleConfirmForceUnlocks(): void {
setActiveModal(null);
void (async(): Promise<void> => {
const result = await forceUnlocks();
const parts: Array<string> = [];
if (result.zonesUnlocked > 0) {
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
}
if (result.questsUnlocked > 0) {
parts.push(`${String(result.questsUnlocked)} quest(s)`);
}
if (result.bossesUnlocked > 0) {
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
}
if (result.explorationUnlocked > 0) {
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
}
const total
= result.zonesUnlocked
+ result.questsUnlocked
+ result.bossesUnlocked
+ result.explorationUnlocked;
const message
= parts.length === 0
? "Everything looks correct — no missing unlocks were found."
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
setForceUnlocksResult(message);
})();
}
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>{"💀 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 === "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,9 +7,11 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js"; import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Equipment, EquipmentType } from "@elysium/types"; import type { Equipment, EquipmentType } from "@elysium/types";
@@ -20,12 +22,6 @@ const rarityLabel: Record<string, string> = {
rare: "Rare", rare: "Rare",
}; };
const typeIcon: Record<EquipmentType, string> = {
armour: "🛡️",
trinket: "💍",
weapon: "⚔️",
};
/** /**
* Computes a human-readable bonus description for a piece of equipment. * Computes a human-readable bonus description for a piece of equipment.
* @param item - The equipment item. * @param item - The equipment item.
@@ -35,7 +31,7 @@ const bonusDescription = (item: Equipment): string => {
const parts: Array<string> = []; const parts: Array<string> = [];
if (item.bonus.combatMultiplier !== undefined) { if (item.bonus.combatMultiplier !== undefined) {
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100); const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Combat`); parts.push(`+${String(pct)}% Boss Combat`);
} }
if (item.bonus.goldMultiplier !== undefined) { if (item.bonus.goldMultiplier !== undefined) {
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100); const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
@@ -128,7 +124,11 @@ const EquipmentCard = ({
<div <div
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`} className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
> >
<div className="equipment-icon">{typeIcon[item.type]}</div> <img
alt={item.name}
className="card-thumbnail"
src={cdnImage("equipment", item.id)}
/>
<div className="equipment-info"> <div className="equipment-info">
<div className="equipment-name-row"> <div className="equipment-name-row">
<h3>{item.name}</h3> <h3>{item.name}</h3>
@@ -189,6 +189,20 @@ const EquipmentCard = ({
); );
}; };
/**
* Computes a combined power score for sorting — sum of all bonus multipliers.
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
* @param item - The equipment piece whose bonus multipliers are summed.
* @returns The combined bonus value.
*/
const equipmentPower = (item: Equipment): number => {
return (
(item.bonus.combatMultiplier ?? 1)
+ (item.bonus.goldMultiplier ?? 1)
+ (item.bonus.clickMultiplier ?? 1)
);
};
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ]; const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
const slotLabel: Record<EquipmentType, string> = { const slotLabel: Record<EquipmentType, string> = {
armour: "🛡️ Armour", armour: "🛡️ Armour",
@@ -262,7 +276,7 @@ const EquipmentPanel = (): JSX.Element => {
} }
if (bonus.combatMultiplier !== undefined) { if (bonus.combatMultiplier !== undefined) {
const pct = Math.round((bonus.combatMultiplier - 1) * 100); const pct = Math.round((bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`); parts.push(`+${String(pct)}% Boss Combat (${String(threshold)}pc)`);
} }
if (bonus.clickMultiplier !== undefined) { if (bonus.clickMultiplier !== undefined) {
const pct = Math.round((bonus.clickMultiplier - 1) * 100); const pct = Math.round((bonus.clickMultiplier - 1) * 100);
@@ -321,6 +335,8 @@ const EquipmentPanel = (): JSX.Element => {
{slotOrder.map((slotType) => { {slotOrder.map((slotType) => {
const items = equipment.filter((item) => { const items = equipment.filter((item) => {
return item.type === slotType && (showLocked || item.owned); return item.type === slotType && (showLocked || item.owned);
}).sort((a, b) => {
return equipmentPower(a) - equipmentPower(b);
}); });
return ( return (
<div className="equipment-slot-section" key={slotType}> <div className="equipment-slot-section" key={slotType}>
@@ -6,9 +6,11 @@
*/ */
/* 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";
import { cdnImage } from "../../utils/cdn.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { ExploreCollectResponse } from "@elysium/types"; import type { ExploreCollectResponse } from "@elysium/types";
@@ -45,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);
}; };
@@ -66,7 +78,9 @@ interface CollectResult {
const ExplorationPanel = (): JSX.Element => { const ExplorationPanel = (): JSX.Element => {
const { state, startExploration, collectExploration, formatNumber } const { state, startExploration, collectExploration, formatNumber }
= useGame(); = useGame();
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_explore_zone") ?? "verdant_vale";
});
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null); const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null); const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
@@ -78,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;
@@ -115,6 +146,7 @@ const ExplorationPanel = (): JSX.Element => {
function handleZoneSelect(id: string): void { function handleZoneSelect(id: string): void {
setActiveZoneId(id); setActiveZoneId(id);
setLastResult(null); setLastResult(null);
sessionStorage.setItem("elysium_explore_zone", id);
} }
const goldChange = lastResult?.response.event?.goldChange ?? 0; const goldChange = lastResult?.response.event?.goldChange ?? 0;
@@ -206,6 +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) => {
@@ -213,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 {
@@ -230,6 +284,11 @@ const ExplorationPanel = (): JSX.Element => {
className={`exploration-card exploration-${status}`} className={`exploration-card exploration-${status}`}
key={area.id} key={area.id}
> >
<img
alt={area.name}
className="card-thumbnail"
src={cdnImage("explorations", area.id)}
/>
<div className="exploration-info"> <div className="exploration-info">
<h3> <h3>
{area.name} {area.name}
@@ -267,9 +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>
} }
+6 -1
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" },
]; ];
/** /**
@@ -189,6 +192,7 @@ const GameLayout = (): JSX.Element => {
<div className="game-main"> <div className="game-main">
<aside className="game-sidebar"> <aside className="game-sidebar">
<ClickArea /> <ClickArea />
<div id="tree-nation-offset-website" />
<p className="game-copyright">{"© NHCarrigan"}</p> <p className="game-copyright">{"© NHCarrigan"}</p>
</aside> </aside>
@@ -241,6 +245,7 @@ const GameLayout = (): JSX.Element => {
{activeTab === "story" && <StoryPanel />} {activeTab === "story" && <StoryPanel />}
{activeTab === "codex" && <CodexPanel />} {activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />} {activeTab === "about" && <AboutPanel />}
{activeTab === "debug" && <DebugPanel />}
</div> </div>
</main> </main>
</div> </div>
@@ -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">
+30 -1
View File
@@ -15,6 +15,7 @@ import {
PRESTIGE_UPGRADES, PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS, PRESTIGE_UPGRADE_CATEGORY_LABELS,
} from "../../data/prestigeUpgrades.js"; } from "../../data/prestigeUpgrades.js";
import { cdnImage } from "../../utils/cdn.js";
import { sendNotification } from "../../utils/notification.js"; import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js"; import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types"; import type { PrestigeUpgradeCategory } from "@elysium/types";
@@ -88,6 +89,7 @@ const PrestigePanel = (): JSX.Element => {
buyPrestigeUpgrade, buyPrestigeUpgrade,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
toggleAutoAdventurer,
toggleAutoPrestige, toggleAutoPrestige,
triggerPrestigeToast, triggerPrestigeToast,
} = useGame(); } = useGame();
@@ -109,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(
@@ -172,6 +174,10 @@ const PrestigePanel = (): JSX.Element => {
void handlePrestige(); void handlePrestige();
} }
function handleAutoAdventurerToggle(): void {
toggleAutoAdventurer();
}
function handleAutoPrestigeToggle(): void { function handleAutoPrestigeToggle(): void {
toggleAutoPrestige(); toggleAutoPrestige();
} }
@@ -346,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
@@ -366,6 +375,11 @@ const PrestigePanel = (): JSX.Element => {
: ""}`} : ""}`}
key={upgrade.id} key={upgrade.id}
> >
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("prestige-upgrades", upgrade.id)}
/>
<div className="shop-upgrade-info"> <div className="shop-upgrade-info">
<h4>{upgrade.name}</h4> <h4>{upgrade.name}</h4>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
@@ -375,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 ${
+11 -6
View File
@@ -8,6 +8,7 @@
/* eslint-disable complexity -- Many conditional stat visibility checks */ /* eslint-disable complexity -- Many conditional stat visibility checks */
import { useEffect, useState, type JSX } from "react"; import { useEffect, useState, type JSX } from "react";
import { formatNumber } from "../../utils/format.js"; import { formatNumber } from "../../utils/format.js";
import { logError } from "../../utils/logError.js";
import type { PublicProfileResponse } from "@elysium/types"; import type { PublicProfileResponse } from "@elysium/types";
interface ProfilePageProperties { interface ProfilePageProperties {
@@ -52,12 +53,16 @@ const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
}, [ discordId ]); }, [ discordId ]);
function handleCopy(): void { function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => { void navigator.clipboard.writeText(window.location.href).
setCopied(true); then(() => {
setTimeout(() => { setCopied(true);
setCopied(false); setTimeout(() => {
}, 2000); setCopied(false);
}); }, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
} }
if (error !== null) { if (error !== null) {
+72 -6
View File
@@ -4,12 +4,15 @@
* @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 { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Quest } from "@elysium/types"; import type { Quest } from "@elysium/types";
@@ -81,6 +84,11 @@ const QuestCard = ({
return ( return (
<div className={`quest-card quest-${quest.status}`}> <div className={`quest-card quest-${quest.status}`}>
<img
alt={quest.name}
className="card-thumbnail"
src={cdnImage("quests", quest.id)}
/>
<div className="quest-info"> <div className="quest-info">
<h3>{quest.name}</h3> <h3>{quest.name}</h3>
<p>{quest.description}</p> <p>{quest.description}</p>
@@ -102,9 +110,9 @@ const QuestCard = ({
</p> </p>
} }
<div className="quest-rewards"> <div className="quest-rewards">
{quest.rewards.map((reward) => { {quest.rewards.map((reward, rewardIndex) => {
return ( return (
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}> <span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
{reward.type === "gold" {reward.type === "gold"
&& `🪙 ${formatNumber(reward.amount ?? 0)}`} && `🪙 ${formatNumber(reward.amount ?? 0)}`}
{reward.type === "essence" {reward.type === "essence"
@@ -137,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
@@ -178,7 +195,9 @@ const QuestCard = ({
*/ */
const QuestPanel = (): JSX.Element => { const QuestPanel = (): JSX.Element => {
const { state, toggleAutoQuest } = useGame(); const { state, toggleAutoQuest } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale"); const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_quest_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
if (state === null) { if (state === null) {
@@ -189,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;
@@ -237,6 +273,11 @@ const QuestPanel = (): JSX.Element => {
} }
} }
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_quest_zone", zoneId);
}
function handleToggle(): void { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -279,10 +320,35 @@ const QuestPanel = (): JSX.Element => {
<ZoneSelector <ZoneSelector
activeZoneId={activeZoneId} activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId} onSelectZone={handleZoneSelect}
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<p className="quest-failure-note">
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
</p>
<div className="quest-list"> <div className="quest-list">
{visibleQuests.map((quest) => { {visibleQuests.map((quest) => {
return ( return (
@@ -9,6 +9,7 @@
import { STORY_CHAPTERS } from "@elysium/types"; import { STORY_CHAPTERS } from "@elysium/types";
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
/** /**
* Substitutes the character name placeholder in story text. * Substitutes the character name placeholder in story text.
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
: <div className="story-chapter-view"> : <div className="story-chapter-view">
{isUnlocked {isUnlocked
? <> ? <>
<img
alt={activeChapter.title}
className="story-chapter-banner"
src={cdnImage("story-chapters", activeChapter.id)}
/>
<h2 className="story-chapter-title"> <h2 className="story-chapter-title">
{"Chapter "} {"Chapter "}
{activeChapterIndex + 1} {activeChapterIndex + 1}
@@ -7,12 +7,14 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */ /* eslint-disable max-statements -- Transcendence panel manages many local state variables */
/* eslint-disable max-lines -- Transcendence panel with CDN images exceeds line limit */
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import {
TRANSCENDENCE_UPGRADES, TRANSCENDENCE_UPGRADES,
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS, TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
} from "../../data/transcendenceUpgrades.js"; } from "../../data/transcendenceUpgrades.js";
import { cdnImage } from "../../utils/cdn.js";
import type { TranscendenceUpgradeCategory } from "@elysium/types"; import type { TranscendenceUpgradeCategory } from "@elysium/types";
const echoFormulaConstant = 853; const echoFormulaConstant = 853;
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
: ""}`} : ""}`}
key={upgrade.id} key={upgrade.id}
> >
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("transcendence-upgrades", upgrade.id)}
/>
<div className="shop-upgrade-info"> <div className="shop-upgrade-info">
<h4>{upgrade.name}</h4> <h4>{upgrade.name}</h4>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
+54 -2
View File
@@ -9,8 +9,9 @@
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ /* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Upgrade } from "@elysium/types"; import type { Adventurer, Upgrade } from "@elysium/types";
interface UpgradeCardProperties { interface UpgradeCardProperties {
readonly upgrade: Upgrade; readonly upgrade: Upgrade;
@@ -19,6 +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>;
} }
/** /**
@@ -30,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 = ({
@@ -39,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
@@ -53,11 +62,23 @@ const UpgradeCard = ({
if (upgrade.unlocked && upgrade.purchased) { if (upgrade.unlocked && upgrade.purchased) {
return ( return (
<div className="upgrade-card purchased"> <div className="upgrade-card purchased">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<span className="upgrade-name"> <span className="upgrade-name">
{"✅ "} {"✅ "}
{upgrade.name} {upgrade.name}
</span> </span>
<span className="upgrade-desc">{upgrade.description}</span> <span className="upgrade-desc">{upgrade.description}</span>
{adventurerName === undefined
? null
: <span className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</span>
}
</div> </div>
); );
} }
@@ -65,9 +86,21 @@ const UpgradeCard = ({
if (upgrade.unlocked) { if (upgrade.unlocked) {
return ( return (
<div className="upgrade-card"> <div className="upgrade-card">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info"> <div className="upgrade-info">
<h3>{upgrade.name}</h3> <h3>{upgrade.name}</h3>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier"> <p className="upgrade-multiplier">
{"×"} {"×"}
{upgrade.multiplier} {upgrade.multiplier}
@@ -108,12 +141,24 @@ const UpgradeCard = ({
return ( return (
<div className="upgrade-card locked"> <div className="upgrade-card locked">
<img
alt={upgrade.name}
className="card-thumbnail"
src={cdnImage("upgrades", upgrade.id)}
/>
<div className="upgrade-info"> <div className="upgrade-info">
<h3> <h3>
{"🔒 "} {"🔒 "}
{upgrade.name} {upgrade.name}
</h3> </h3>
<p>{upgrade.description}</p> <p>{upgrade.description}</p>
{adventurerName === undefined
? null
: <p className="upgrade-target">
{"🗡️ Affects: "}
{adventurerName}
</p>
}
<p className="upgrade-multiplier"> <p className="upgrade-multiplier">
{"×"} {"×"}
{upgrade.multiplier} {upgrade.multiplier}
@@ -165,7 +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;
}); });
@@ -216,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!"}
@@ -224,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}
@@ -237,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}
@@ -251,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}
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { cdnImage } from "../../utils/cdn.js";
import type { Zone } from "@elysium/types"; import type { Zone } from "@elysium/types";
import type { JSX } from "react"; import type { JSX } from "react";
@@ -44,7 +45,11 @@ const ZoneSelector = ({
title={zone.description} title={zone.description}
type="button" type="button"
> >
<span className="zone-emoji">{zone.emoji}</span> <img
alt={zone.name}
className="zone-tab-image"
src={cdnImage("zones", zone.id)}
/>
<span className="zone-name">{zone.name}</span> <span className="zone-name">{zone.name}</span>
</button> </button>
); );
@@ -0,0 +1,68 @@
/**
* @file Reusable confirmation modal component for destructive operations.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { JSX } from "react";
interface ConfirmationModalProperties {
readonly title: string;
readonly description: string;
readonly confirmLabel: string;
readonly onConfirm: ()=> void;
readonly onCancel: ()=> void;
readonly isLoading: boolean;
}
/**
* Renders a confirmation modal for destructive operations.
* @param props - The modal properties.
* @param props.title - The modal heading.
* @param props.description - Warning text explaining what the operation does.
* @param props.confirmLabel - Label for the confirm button.
* @param props.onConfirm - Callback fired when the player confirms.
* @param props.onCancel - Callback fired when the player cancels.
* @param props.isLoading - Whether the operation is currently in progress.
* @returns The JSX element.
*/
const ConfirmationModal = ({
title,
description,
confirmLabel,
onConfirm,
onCancel,
isLoading,
}: ConfirmationModalProperties): JSX.Element => {
return (
<div className="modal-overlay">
<div className="modal">
<h2>{title}</h2>
<p>{description}</p>
<p className="modal-note">{"Are you sure you want to do this?"}</p>
<div className="modal-actions">
<button
className="modal-close-button modal-button-danger"
disabled={isLoading}
onClick={onConfirm}
type="button"
>
{isLoading
? "Working..."
: confirmLabel}
</button>
<button
className="modal-close-button"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{"Cancel"}
</button>
</div>
</div>
</div>
);
};
export { ConfirmationModal };
+15 -1
View File
@@ -77,8 +77,15 @@ const ResourceBar = ({
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProperties): JSX.Element => { }: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError } = useGame(); const { formatNumber, syncError, state } = useGame();
const { gold, essence, crystals } = resources; const { gold, essence, crystals } = resources;
let partyCombatPower = 0;
if (state !== null) {
for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
}
const resourceValues = [ gold, essence, crystals ]; const resourceValues = [ gold, essence, crystals ];
const anyFull = resourceValues.some((v) => { const anyFull = resourceValues.some((v) => {
return v >= RESOURCE_CAP; return v >= RESOURCE_CAP;
@@ -135,6 +142,13 @@ const ResourceBar = ({
<span className="resource-value">{formatNumber(runestones)}</span> <span className="resource-value">{formatNumber(runestones)}</span>
<span className="resource-label">{"Runestones"}</span> <span className="resource-label">{"Runestones"}</span>
</div> </div>
<div className="resource">
<span className="resource-icon">{"⚔️"}</span>
<span className="resource-value">
{formatNumber(partyCombatPower)}
</span>
<span className="resource-label">{"Combat Power"}</span>
</div>
{apotheosisCount > 0 {apotheosisCount > 0
&& <div className="apotheosis-badge"> && <div className="apotheosis-badge">
{"✨ Apotheosis "} {"✨ Apotheosis "}
+312 -71
View File
@@ -42,6 +42,8 @@ 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,
loadGame, loadGame,
prestige as prestigeApi, prestige as prestigeApi,
resetProgress as resetProgressApi, resetProgress as resetProgressApi,
@@ -50,7 +52,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,
@@ -59,6 +60,7 @@ import {
} from "../engine/tick.js"; } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js";
import { logError } from "../utils/logError.js";
import { sendNotification } from "../utils/notification.js"; import { sendNotification } from "../utils/notification.js";
import { playSound } from "../utils/sound.js"; import { playSound } from "../utils/sound.js";
@@ -445,6 +447,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).
*/ */
@@ -544,6 +551,42 @@ interface GameContextValue {
* Reset all progress to a fresh save state (resolves schema outdated). * Reset all progress to a fresh save state (resolves schema outdated).
*/ */
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<{
bossesUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
zonesUnlocked: number;
}>;
/**
* Completely wipe the player's progress back to a brand-new save via the
* debug endpoint.
*/
debugHardReset: ()=> Promise<void>;
/**
* Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off.
*/
autoBossLastResult: { bossName: string; won: boolean; at: number } | null;
/**
* Error message set when auto-boss stopped due to a critical failure (null
* when no error). Cleared automatically when the player re-enables auto-boss.
*/
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 {
@@ -587,6 +630,13 @@ export const GameProvider = ({
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null); const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
const [ isSyncing, setIsSyncing ] = useState(false); const [ isSyncing, setIsSyncing ] = useState(false);
const [ syncError, setSyncError ] = useState<string | null>(null); const [ syncError, setSyncError ] = useState<string | null>(null);
const [ autoBossLastResult, setAutoBossLastResult ] = useState<{
bossName: string;
won: boolean;
at: number;
} | 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,
); );
@@ -1028,6 +1078,42 @@ 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) => {
const costA
= adventurerA.baseCost * Math.pow(1.15, adventurerA.count);
const costB
= adventurerB.baseCost * Math.pow(1.15, adventurerB.count);
return costB - costA;
});
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) => {
@@ -1058,6 +1144,14 @@ 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;
}); });
@@ -1131,6 +1225,11 @@ export const GameProvider = ({
signatureReference.current = null; signatureReference.current = null;
localStorage.removeItem("elysium_save_signature"); localStorage.removeItem("elysium_save_signature");
} }
/*
* Network failures during background auto-save are expected on
* flaky connections — the next tick will retry, so no telemetry needed
*/
}); });
} }
} }
@@ -1160,7 +1259,7 @@ export const GameProvider = ({
}). }).
catch(() => { catch(() => {
/* Silently ignore — will retry next tick */ /* Silently ignore — eligibility is re-checked every tick */
}). }).
finally(() => { finally(() => {
isAutoPrestigingReference.current = false; isAutoPrestigingReference.current = false;
@@ -1196,13 +1295,39 @@ export const GameProvider = ({
if (previous === null) { if (previous === null) {
return previous; return previous;
} }
return applyBossResult(previous, bossId, result); const afterBoss = applyBossResult(previous, bossId, result);
// Defeat — turn off auto-boss so the player can reassess
if (!result.won) {
return { ...afterBoss, autoBoss: false };
}
return afterBoss;
});
setAutoBossLastResult({
at: Date.now(),
bossName: bossName,
won: result.won,
}); });
setBattleResult({ bossName, result });
}). }).
catch(() => { catch((error_: unknown) => {
const message
= error_ instanceof Error
? error_.message
: String(error_);
/* Silently ignore — will retry next tick */ /*
* "Boss is not currently available" is an expected race condition
* in the tick loop — suppress telemetry for this case only
*/
if (message !== "Boss is not currently available") {
logError("auto_boss", error_);
}
setAutoBossError(message);
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, autoBoss: false };
});
}). }).
finally(() => { finally(() => {
isAutoBossingReference.current = false; isAutoBossingReference.current = false;
@@ -1521,35 +1646,46 @@ export const GameProvider = ({
}, },
}; };
}); });
} catch { } catch (error_: unknown) {
logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
} }
}, []); }, []);
const transcend = useCallback(async() => { const transcend = useCallback(async() => {
const result = await transcendApi({}); try {
setShowTranscendenceToast(true); const result = await transcendApi({});
if (enableSoundsReference.current) { setShowTranscendenceToast(true);
playSound("transcendence"); if (enableSoundsReference.current) {
playSound("transcendence");
}
if (enableNotificationsReference.current) {
sendNotification("🌌 Transcendence!", "You have transcended reality!");
}
await reload();
return result;
} catch (error_: unknown) {
logError("transcend", error_);
throw error_;
} }
if (enableNotificationsReference.current) {
sendNotification("🌌 Transcendence!", "You have transcended reality!");
}
await reload();
return result;
}, [ reload ]); }, [ reload ]);
const apotheosis = useCallback(async() => { const apotheosis = useCallback(async() => {
const result = await achieveApotheosisApi({}); try {
setShowApotheosisToast(true); const result = await achieveApotheosisApi({});
if (enableSoundsReference.current) { setShowApotheosisToast(true);
playSound("apotheosis"); if (enableSoundsReference.current) {
playSound("apotheosis");
}
if (enableNotificationsReference.current) {
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
}
await reload();
return result;
} catch (error_: unknown) {
logError("apotheosis", error_);
throw error_;
} }
if (enableNotificationsReference.current) {
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
}
await reload();
return result;
}, [ reload ]); }, [ reload ]);
const buyEchoUpgrade = useCallback(async(upgradeId: string) => { const buyEchoUpgrade = useCallback(async(upgradeId: string) => {
@@ -1575,21 +1711,14 @@ export const GameProvider = ({
}, },
}; };
}); });
} catch { } catch (error_: unknown) {
// Silently ignore server errors logError("buy_echo_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI
} }
}, []); }, []);
const startExploration = useCallback(async(areaId: string) => { const startExploration = useCallback(async(areaId: string) => {
const response = await startExplorationApi({ areaId }); const response = await startExplorationApi({ areaId });
const areaData = EXPLORATION_AREAS.find((a) => {
return a.id === areaId;
});
if (areaData === undefined) {
return;
}
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
setState((previous) => { setState((previous) => {
if (previous?.exploration === undefined) { if (previous?.exploration === undefined) {
return previous; return previous;
@@ -1600,7 +1729,11 @@ export const GameProvider = ({
...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,
endsAt: response.endsAt,
status: "in_progress" as const,
}
: a; : a;
}), }),
}, },
@@ -1668,13 +1801,13 @@ export const GameProvider = ({
player: { player: {
...previous.player, ...previous.player,
totalGoldEarned: totalGoldEarned:
previous.player.totalGoldEarned previous.player.totalGoldEarned
+ Math.max(0, result.event?.goldChange ?? 0), + Math.max(0, result.event?.goldChange ?? 0),
}, },
resources: { resources: {
...previous.resources, ...previous.resources,
essence: essence:
previous.resources.essence + (result.event?.essenceChange ?? 0), previous.resources.essence + (result.event?.essenceChange ?? 0),
gold: Math.max( gold: Math.max(
0, 0,
previous.resources.gold + (result.event?.goldChange ?? 0), previous.resources.gold + (result.event?.goldChange ?? 0),
@@ -1694,35 +1827,40 @@ export const GameProvider = ({
if (recipe === undefined) { if (recipe === undefined) {
return; return;
} }
const result = await craftRecipeApi({ recipeId }); try {
setState((previous) => { const result = await craftRecipeApi({ recipeId });
if (previous?.exploration === undefined) { setState((previous) => {
return previous; if (previous?.exploration === undefined) {
} return previous;
let materials = [ ...previous.exploration.materials ]; }
for (const request of recipe.requiredMaterials) { let materials = [ ...previous.exploration.materials ];
materials = materials.map((mat) => { for (const request of recipe.requiredMaterials) {
return mat.materialId === request.materialId materials = materials.map((mat) => {
? { ...mat, quantity: mat.quantity - request.quantity } return mat.materialId === request.materialId
: mat; ? { ...mat, quantity: mat.quantity - request.quantity }
}); : mat;
} });
return { }
...previous, return {
exploration: { ...previous,
...previous.exploration, exploration: {
craftedClickMultiplier: result.craftedClickMultiplier, ...previous.exploration,
craftedCombatMultiplier: result.craftedCombatMultiplier, craftedClickMultiplier: result.craftedClickMultiplier,
craftedEssenceMultiplier: result.craftedEssenceMultiplier, craftedCombatMultiplier: result.craftedCombatMultiplier,
craftedGoldMultiplier: result.craftedGoldMultiplier, craftedEssenceMultiplier: result.craftedEssenceMultiplier,
craftedRecipeIds: [ craftedGoldMultiplier: result.craftedGoldMultiplier,
...previous.exploration.craftedRecipeIds, craftedRecipeIds: [
recipeId, ...previous.exploration.craftedRecipeIds,
], recipeId,
materials: materials, ],
}, materials: materials,
}; },
}); };
});
} catch (error_: unknown) {
logError("craft_recipe", error_);
throw error_;
}
}, []); }, []);
const toggleAutoPrestige = useCallback(() => { const toggleAutoPrestige = useCallback(() => {
@@ -1750,6 +1888,8 @@ export const GameProvider = ({
}, []); }, []);
const toggleAutoBoss = useCallback(() => { const toggleAutoBoss = useCallback(() => {
setAutoBossError(null);
setAutoBossLastResult(null);
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
return previous; return previous;
@@ -1758,6 +1898,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) {
@@ -1789,6 +1941,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) => {
@@ -1798,10 +1958,24 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
} catch { } catch (error_: unknown) {
// Silently ignore — server errors shouldn't crash the UI const bossErrorMessage
= 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);
@@ -1927,6 +2101,61 @@ 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 {
bossesUnlocked: data.bossesUnlocked,
explorationUnlocked: data.explorationUnlocked,
questsUnlocked: data.questsUnlocked,
zonesUnlocked: data.zonesUnlocked,
};
} catch (error_: unknown) {
setError(
error_ instanceof Error
? error_.message
: "Failed to force unlocks",
);
return {
bossesUnlocked: 0,
explorationUnlocked: 0,
questsUnlocked: 0,
zonesUnlocked: 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);
}, []); }, []);
@@ -1941,7 +2170,10 @@ export const GameProvider = ({
const contextValue = useMemo<GameContextValue>(() => { const contextValue = useMemo<GameContextValue>(() => {
return { return {
apotheosis, apotheosis,
autoBossError,
autoBossLastResult,
battleResult, battleResult,
bossError,
buyAdventurer, buyAdventurer,
buyEchoUpgrade, buyEchoUpgrade,
buyEquipment, buyEquipment,
@@ -1953,6 +2185,7 @@ export const GameProvider = ({
completedQuestToasts, completedQuestToasts,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
debugHardReset,
dismissAchievement, dismissAchievement,
dismissApotheosisToast, dismissApotheosisToast,
dismissBattle, dismissBattle,
@@ -1971,6 +2204,7 @@ export const GameProvider = ({
failedQuestToasts, failedQuestToasts,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks,
formatNumber, formatNumber,
handleClick, handleClick,
isLoading, isLoading,
@@ -1996,6 +2230,7 @@ export const GameProvider = ({
startQuest, startQuest,
state, state,
syncError, syncError,
toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
@@ -2007,7 +2242,10 @@ export const GameProvider = ({
}; };
}, [ }, [
apotheosis, apotheosis,
autoBossError,
autoBossLastResult,
battleResult, battleResult,
bossError,
completedQuestToasts, completedQuestToasts,
failedQuestToasts, failedQuestToasts,
formatNumber, formatNumber,
@@ -2021,6 +2259,7 @@ export const GameProvider = ({
completeChapter, completeChapter,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
debugHardReset,
dismissAchievement, dismissAchievement,
dismissApotheosisToast, dismissApotheosisToast,
dismissBattle, dismissBattle,
@@ -2038,6 +2277,7 @@ export const GameProvider = ({
error, error,
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks,
handleClick, handleClick,
isLoading, isLoading,
isSyncing, isSyncing,
@@ -2062,6 +2302,7 @@ export const GameProvider = ({
startQuest, startQuest,
state, state,
syncError, syncError,
toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
+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:
+1 -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,
+7 -1
View File
@@ -8,8 +8,12 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./app.js"; import { App } from "./app.js";
import { ErrorBoundary } from "./components/errorBoundary.js";
import { initialiseFrontendLogger } from "./utils/logger.js";
import "./styles.css"; import "./styles.css";
initialiseFrontendLogger();
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
@@ -18,6 +22,8 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );
+208 -5
View File
@@ -26,6 +26,7 @@
--radius: 8px; --radius: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--font: "Segoe UI", system-ui, sans-serif; --font: "Segoe UI", system-ui, sans-serif;
--resource-bar-height: 3.5rem;
} }
body { body {
@@ -33,6 +34,20 @@ body {
color: var(--colour-text); color: var(--colour-text);
font-family: var(--font); font-family: var(--font);
min-height: 100vh; min-height: 100vh;
position: relative;
}
body::before {
background-attachment: fixed;
background-image: url("https://cdn.nhcarrigan.com/elysium/background.jpg");
background-position: center;
background-size: cover;
content: "";
inset: 0;
opacity: 0.15;
pointer-events: none;
position: fixed;
z-index: -1;
} }
/* ===================== RESOURCE BAR ===================== */ /* ===================== RESOURCE BAR ===================== */
@@ -122,6 +137,10 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
position: sticky;
top: var(--resource-bar-height);
height: calc(100vh - var(--resource-bar-height));
overflow-y: auto;
} }
.game-content { .game-content {
@@ -2056,8 +2075,11 @@ body {
opacity: 0.45; opacity: 0.45;
} }
.zone-emoji { .zone-tab-image {
font-size: 1.4rem; aspect-ratio: 16 / 9;
border-radius: 0.35rem;
object-fit: cover;
width: 96px;
} }
.zone-name { .zone-name {
@@ -2285,9 +2307,6 @@ body {
} }
.about-release-body { .about-release-body {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--colour-text-secondary, #b0b0b0); color: var(--colour-text-secondary, #b0b0b0);
padding: 0 1rem 0.75rem; padding: 0 1rem 0.75rem;
@@ -2295,6 +2314,81 @@ body {
border-top: 1px solid var(--colour-border, #0f3460); border-top: 1px solid var(--colour-border, #0f3460);
} }
.about-release-body p {
margin: 0.4rem 0;
}
.about-release-body p:first-child {
margin-top: 0.5rem;
}
.about-release-body p:last-child {
margin-bottom: 0;
}
.about-release-body ul,
.about-release-body ol {
padding-left: 1.5rem;
margin: 0.4rem 0;
}
.about-release-body li {
margin-bottom: 0.2rem;
line-height: 1.5;
}
.about-release-body h1,
.about-release-body h2,
.about-release-body h3,
.about-release-body h4 {
color: var(--colour-accent);
font-size: 0.9rem;
font-weight: bold;
margin: 0.75rem 0 0.25rem;
}
.about-release-body h1:first-child,
.about-release-body h2:first-child,
.about-release-body h3:first-child,
.about-release-body h4:first-child {
margin-top: 0.5rem;
}
.about-release-body code {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
padding: 0.1em 0.3em;
font-family: monospace;
font-size: 0.8rem;
}
.about-release-body pre {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 0.75rem;
overflow-x: auto;
margin: 0.4rem 0;
}
.about-release-body pre code {
background: none;
padding: 0;
}
.about-release-body a {
color: var(--colour-accent-light);
text-decoration: none;
}
.about-release-body a:hover {
text-decoration: underline;
}
.about-release-body strong {
color: var(--colour-text);
font-weight: bold;
}
.about-how-to-play { .about-how-to-play {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -3092,8 +3186,11 @@ body {
border-right: none; border-right: none;
flex-direction: row; flex-direction: row;
gap: 0.75rem; gap: 0.75rem;
height: auto;
justify-content: center; justify-content: center;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
position: static;
top: auto;
width: 100%; width: 100%;
} }
@@ -4393,3 +4490,109 @@ body {
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.5; line-height: 1.5;
} }
/* ===================== CDN ASSET IMAGES ===================== */
.card-thumbnail {
border-radius: var(--radius);
flex-shrink: 0;
height: 72px;
object-fit: cover;
width: 72px;
}
.story-chapter-banner {
border-radius: var(--radius);
height: 220px;
margin-bottom: 1rem;
object-fit: cover;
width: 100%;
}
.codex-entry-image {
border-radius: var(--radius);
height: 80px;
margin-bottom: 0.5rem;
object-fit: cover;
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;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* @file CDN URL utility for Elysium game assets.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
const cdnBase = "https://cdn.nhcarrigan.com/elysium";
/**
* Returns the CDN URL for a game asset image.
* @param folder - The asset category folder (e.g. "bosses", "companions").
* @param id - The asset identifier (file name without extension).
* @returns The full CDN URL for the asset.
*/
const cdnImage = (folder: string, id: string): string => {
return `${cdnBase}/${folder}/${id}.jpg`;
};
export { cdnImage };
+19
View File
@@ -0,0 +1,19 @@
/**
* @file Frontend error logging utility that forwards errors to the backend telemetry service.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
/**
* Logs an error to the backend telemetry service.
* Accepts the same arguments as console.error conventionally a context string
* followed by the error value.
* @param logArguments - The values to log, forwarded directly to console.error.
*/
const logError = (...logArguments: Array<unknown>): void => {
console.error(...logArguments);
};
export { logError };
+68
View File
@@ -0,0 +1,68 @@
/**
* @file Frontend logger that forwards console output to the backend telemetry service.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable no-console -- This file intentionally overrides console methods */
type Level = "debug" | "info" | "warn";
const post = (path: string, body: object): void => {
void fetch(path, {
body: JSON.stringify(body),
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header names use kebab-case
headers: { "Content-Type": "application/json" },
method: "POST",
}).catch(() => {
// Intentionally swallowed — we cannot log logger failures without infinite recursion.
});
};
/**
* Overrides the global console.log and console.error methods so that all
* frontend log output is forwarded to the backend telemetry endpoints.
* Must be called once at application startup before any other code runs.
*/
const initialiseFrontendLogger = (): void => {
const originalLog = console.log.bind(console);
const originalError = console.error.bind(console);
console.log = (...consoleArguments: Array<unknown>): void => {
originalLog(...consoleArguments);
const level: Level = "info";
const message = consoleArguments.map((argument) => {
return typeof argument === "string"
? argument
: JSON.stringify(argument);
}).join(" ");
post("/api/fe/log", { level, message });
};
console.error = (...consoleArguments: Array<unknown>): void => {
originalError(...consoleArguments);
const message = consoleArguments.map((argument) => {
if (argument instanceof Error) {
return `${argument.message}\n${argument.stack ?? ""}`;
}
return typeof argument === "string"
? argument
: JSON.stringify(argument);
}).join(" ");
const context = "console.error";
post("/api/fe/error", { context, message });
};
console.warn = (...consoleArguments: Array<unknown>): void => {
originalLog(...consoleArguments);
const level: Level = "warn";
const message = consoleArguments.map((argument) => {
return typeof argument === "string"
? argument
: JSON.stringify(argument);
}).join(" ");
post("/api/fe/log", { level, message });
};
};
export { initialiseFrontendLogger };
+3 -1
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { logError } from "./logError.js";
/** /**
* Requests browser notification permission from the user. * Requests browser notification permission from the user.
@@ -38,7 +39,8 @@ const sendNotification = (title: string, body: string): void => {
try { try {
// eslint-disable-next-line no-new -- Notification constructor has side effects // eslint-disable-next-line no-new -- Notification constructor has side effects
new Notification(title, { body: body, icon: "/favicon.ico" }); new Notification(title, { body: body, icon: "/favicon.ico" });
} catch { } catch (error_: unknown) {
logError("send_notification", error_);
// Silently ignore — notifications may fail silently // Silently ignore — notifications may fail silently
} }
}; };
+3 -1
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { logError } from "./logError.js";
type SoundEvent = type SoundEvent =
| "achievement" | "achievement"
@@ -101,7 +102,8 @@ const playSound = (event: SoundEvent): void => {
oscillator.start(startTime); oscillator.start(startTime);
oscillator.stop(endTime); oscillator.stop(endTime);
} }
} catch { } catch (error_: unknown) {
logError("play_sound", error_);
// Silently ignore — audio may not be available in all environments // Silently ignore — audio may not be available in all environments
} }
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.1.1", "version": "0.2.0",
"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.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+1
View File
@@ -60,6 +60,7 @@ export type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease, GiteaRelease,
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,
+34
View File
@@ -398,6 +398,39 @@ 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;
/**
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
*/
signature?: string;
}
export type { export type {
AboutResponse, AboutResponse,
ApiError, ApiError,
@@ -417,6 +450,7 @@ export type {
ExploreCollectResponse, ExploreCollectResponse,
ExploreStartRequest, ExploreStartRequest,
ExploreStartResponse, ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease, GiteaRelease,
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,
+7
View File
@@ -59,6 +59,13 @@ interface Boss {
* One-time runestone bounty awarded on first-ever defeat. * One-time runestone bounty awarded on first-ever defeat.
*/ */
bountyRunestones: number; bountyRunestones: number;
/**
* Whether the first-kill runestone bounty has already been claimed.
* Set to true on first defeat and preserved across all prestiges so the
* bounty is never re-awarded in subsequent runs.
*/
bountyRunestonesClaimed?: boolean;
} }
export type { Boss, BossStatus }; export type { Boss, BossStatus };
@@ -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.
*/ */
+713 -20
View File
File diff suppressed because it is too large Load Diff