64 Commits

Author SHA1 Message Date
hikari 91c9f52daf feat: goddess expansion chunks 6–9 — UI panels, tick engine, CSS theme, about page
- Add 11 goddess panels (zones, bosses, quests, disciples, equipment,
  upgrades, consecration, enlightenment, crafting, exploration, achievements)
- Wire all panels into gameLayout via mode/tab routing
- Add goddess passive income, disciple tick, quest timers, zone/quest
  unlock logic, and achievement checking to the tick engine
- Add goddess CSS variables, .goddess-mode overrides, 300ms fade
  transition, and full panel stylesheet coverage
- Add 13 Goddess expansion entries to the How to Play guide
- Add web-side data files for crafting recipes, exploration areas, materials
2026-04-13 18:38:27 -07:00
hikari 96d6759661 feat: mode bar, goddess tab row, themed resource dropdown (chunk 5)
Add Mortal/Goddess/Vampire mode selector bar, dynamic second tab row
that swaps per mode, goddess currencies in the resource bar dropdown
(locked pre-apotheosis), full CSS goddess theme with 300ms fade
transition, and localStorage persistence of the active mode.
2026-04-13 15:59:43 -07:00
hikari 0d36b255ee feat: goddess API routes, services, and tests (chunk 4)
Add six new goddess-mode API routes (boss fight, consecration,
enlightenment, upgrade purchase, crafting, exploration) alongside
matching service modules and full test suites at 100% coverage.
2026-04-13 15:48:35 -07:00
hikari 7da1f3942d feat: goddess sync, sanitize, and apotheosis init (chunk 3)
- initialState: add initialGoddessState() with all goddess sub-objects
- apotheosis: init GoddessState on first apotheosis, preserve on subsequent
- game: add goddessSpread block in validateAndSanitize (server-only fields capped, forward-only boss/quest/achievement enforcement)
- debug: add injectMissingGoddessExplorationAreas helper and inject all 8 goddess content arrays in syncNewContent
- vitest.config.ts: remove 8 goddess data files from coverage exclude (now imported via initialState)
- tests: full coverage for all new code (482 tests, 100% coverage)
2026-04-13 14:23:02 -07:00
hikari c5d1f53eef feat: goddess expansion chunk 2 — full content data at base game scale
- 18 zones, 72 bosses, 90 quests across the goddess realm
- 32 disciple tiers, 53 equipment pieces, 9 equipment sets
- 57 goddess upgrades, 25 consecration upgrades, 15 enlightenment upgrades
- 54 sacred materials, 36 crafting recipes, 72 exploration areas
- 40 goddess achievements
- Added GoddessEquipmentSet type + computeGoddessSetBonuses to @elysium/types
- All data files excluded from coverage pending Chunk 4 route imports
2026-04-13 12:50:25 -07:00
hikari c09777199a feat: add Goddess expansion type definitions
Adds all TypeScript interfaces for the Goddess expansion to packages/types:
GoddessState, GoddessZone, GoddessBoss, GoddessQuest, GoddessDisciple,
GoddessEquipment, GoddessUpgrade, ConsecrationData, EnlightenmentData,
GoddessExplorationState, GoddessAchievement. Extends Resource with optional
goddess currencies (prayers, divinity, stardust) and GameState with optional
goddess field. Also adds goddess-todo.md implementation tracker.
2026-04-13 12:01:40 -07:00
hikari 9bb1d01d2b fix: resolve all 8 open bug tickets (#242–#249) (#250)
CI / Lint, Build & Test (push) Successful in 2m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m13s
## Summary

- **#242** — Crystals in the resource bar now use `formatNumber` to respect the player's notation setting (suffix/scientific/engineering)
- **#243** — Companion unlock progress includes current-run gold (`totalGoldEarned`) on both client and server, so companions unlock at the correct threshold
- **#244** — Empty green reward bubbles no longer render for quest crystal rewards with a zero amount
- **#245/#248** — Auto-save skips when `isAutoPrestigingReference.current` is true, preventing it from racing with an in-flight prestige and breaking the optimistic lock
- **#246** — Generated and uploaded CDN images for `crystal_pulse`, `crystal_surge`, and `crystal_tempest` upgrades
- **#247** — `validateAndSanitize` merges daily challenge progress by taking the max of client vs. server progress per challenge, so stale auto-saves can no longer roll back server-side completions
- **#249** — Cached save signature is cleared after `buyPrestigeUpgrade` succeeds, preventing a stale-signature mismatch on the next auto-save

## Test plan

- [ ] Lint passes (`pnpm lint`)
- [ ] Build passes (`pnpm build`)
- [ ] Tests pass with 100% coverage (`pnpm test`)
- [ ] Crystals display in resource bar respects notation setting
- [ ] No empty reward bubbles on quests that don't award crystals
- [ ] Companion progress bar shows correct value including current-run gold
- [ ] Auto-prestige no longer causes save errors
- [ ] Crafting a recipe updates daily challenge progress persistently (not rolled back by next auto-save)
- [ ] Buying a prestige upgrade does not cause a signature mismatch error on next save
- [ ] Crystal upgrade images display correctly in-game

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #250
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 09:50:20 -07:00
naomi e341db56af release: v0.5.0
CI / Lint, Build & Test (push) Successful in 1m15s
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 44s
2026-04-06 20:19:18 -07:00
hikari 2bc47b79aa fix: suppress expired-token log noise and redirect expired sessions to login (#241)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m11s
## Summary

- **Server**: `authMiddleware` no longer calls `logger.error` for expired tokens — expiry is expected behaviour, not an error. Only tampered signatures and malformed tokens (genuinely suspicious) still log.
- **Client**: `fetchJson` now handles 401 responses by clearing `elysium_token` and `elysium_save_signature` from localStorage and redirecting to `/`. Players whose 30-day token has expired will see the login page instead of a stuck "Invalid or expired token" error screen with no recovery path.

Closes #241

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #241
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 20:17:28 -07:00
hikari 3afe64e48a feat: comprehensive balance and bug fix pass (#240)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m10s
## Summary

- **fix(#148)**: Boss fights now return a fresh HMAC signature in the response; both the manual and auto-boss paths update `signatureReference` from it, ending the signature-mismatch loop that stopped auto-boss after the first fight
- **fix(#145)**: Militia `baseCost` lowered from 100g → 65g, smoothing the peasant→militia jump from 10× to ~6.5×
- **fix(#144)**: `crystal_shard` buffed from `1.65×/1.2×` → `1.9×/1.3×` — now competitive as an epic trinket
- **fix(#142)**: Click-power recipe progression smoothed across zones 13–18 and ceiling raised: z13 1.20→1.22, z15 1.22→1.25, z17 1.25→1.28, z18 1.28→1.30
- **close(#143)**: `elder_bark_shield` (1.2×), `void_fragment_amulet` (1.15×), and `soul_bound_catalyst` (1.2×) are all already at or above their target values from a prior pass

Closes #148
Closes #145
Closes #144
Closes #142

Reviewed-on: #240
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 19:33:05 -07:00
hikari e7164257c5 feat: comprehensive balance pass (#239)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m12s
## Summary

Working through all 15 open balance tickets in a coordinated multi-pass approach.

### Pass 1 — Quest failure rates (closes #172)
- Capped all zone quest failure chances at 15% (down from up to 40%)
- Proportional scaling preserved (harder zones still fail more than easier ones)

### Pass 2 — Crystal economy (closes #165, #173, #215)
- Added `crystal_pulse` (3,000 crystals), `crystal_surge` (20,000), `crystal_tempest` (150,000) upgrades to fill the dead zone between 600 and 2M crystal sinks
- Bumped `click_deity`, `prestige_master`, and `prestige_legend` achievement crystal rewards (5K→15K, 5K→15K, 25K→75K)
- Added crystal rewards to `first_steps` (+5) and `goblin_camp` (+10) early quests

### Pass 3 — Runestone/prestige loop (closes #166, #170)
- Bumped `runestonesPerPrestigeLevel` from 15 → 20 (~33% yield increase for mid-game runs)
- Reduced `income_10` cost from 22,500 → 15,000 and `income_11` from 60,000 → 35,000
- Kept client/server parity: `runestonesPerPrestigeLevelClient` in tick.ts updated to match

### Pass 4 — Quest content (#175, #178)
- Both already resolved in commit 666a5b2: quests now reach 5e141 CP across reality_forge, cosmic_maelstrom, primeval_sanctum, and the_absolute — fully covering P60–P212

### Pass 5 — Daily challenges (closes #167)
- Added `crafting` as a new `DailyChallengeType`
- Added 3 crafting challenge templates (craft 1/2/3 recipes)
- Changed generation to guarantee: 1 clicks + 1 crafting + 1 from progression pool
- Added crafting challenge tracking in `craft.ts` (awards crystals on recipe craft)
- Stuck players now have 2/3 daily challenges always completable

### Pass 6 — Transcendence costs (#179)
- Already resolved in commit 666a5b2: echo meta costs are 15/45/100 (was 25/75/200)

### Also closed as stale
- #171 (milestone bonus already quadratic)
- #174 (production multiplier already 1.3^n)
- #176 (expanse_sovereign HP already at 3e39)
- #177 (recipe costs already in expected range)
- #178 (post-absolute quests already present)
- #179 (echo meta costs already reduced)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #239
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:58:04 -07:00
hikari 1195b657a0 feat: another balance and bug fix pass (#238)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint, Build & Test (push) Successful in 1m13s
Working through open issues — fixes, balance changes, and features.

## Closed

- Closes #161
- Closes #181
- Closes #191
- Closes #199
- Closes #201
- Closes #202
- Closes #203
- Closes #204
- Closes #205
- Closes #206
- Closes #208
- Closes #211
- Closes #212
- Closes #213
- Closes #214
- Closes #216
- Closes #219
- Closes #220
- Closes #221
- Closes #222
- Closes #224
- Closes #225
- Closes #226
- Closes #228
- Closes #229
- Closes #230
- Closes #231
- Closes #232
- Closes #233
- Closes #234
- Closes #235
- Closes #236

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #238
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:17:00 -07:00
hikari b0227c1709 chore: update minor and patch dependencies (#237)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m11s
## Summary

- Bumped minor/patch versions for `@hono/node-server`, `@types/node`, `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `hono`, `jsdom`, `react`, `react-dom`, `tsx`, and `vite`
- Full pipeline (lint → build → test) confirmed passing

## Held back

- **TypeScript** — held at 5.8.2; 6.x is incompatible with the current `@nhcarrigan/eslint-config`
- **Vitest** — held at 3.x; 4.x changes v8 coverage pragma handling, needs investigation
- **Prisma** — held at 6.x; 7.x requires a `prisma.config.ts` migration

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #237
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 12:09:10 -07:00
hikari de5570b5fc fix: filter third-party script errors from frontend telemetry
CI / Lint, Build & Test (push) Successful in 1m14s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m56s
2026-04-01 13:55:40 -07:00
hikari 133c81fefe chore: bump schema version to 2 for v0.4.0 balance pass
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m11s
2026-03-31 20:06:13 -07:00
naomi 1408e067b7 release: v0.4.0 2026-03-31 20:00:08 -07:00
hikari 666a5b2d6d fix: runestone formula, prestige/transcendence rebalance, exploration fixes, and comprehensive balance audit (#135)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m12s
CI / Lint, Build & Test (push) Successful in 1m13s
## What changed and why

### Runestone formula (`prestige.ts`)
- Swapped `sqrt` for `cbrt` — much stronger diminishing returns for large gold values
- Added base cap of **200** (→ ~1,125 max with all upgrades at 5.625× multiplier)
- Prevents extended AFK sessions from producing runestone windfalls that allow immediate upgrade purchasing and rapid prestige chaining

### Prestige threshold formula (`prestige.ts`)
- Old: `1,000,000 × 5^n` — exponential, grows impossibly fast, prestige 10+ takes years
- New: `1,000,000 × (n+1)²` — polynomial, peaks at ~1 day/run around P8–10, then gets *easier* as the production multiplier overtakes it
- Removed `thresholdScaleFactor` constant (no longer needed)

### Production multiplier (`prestige.ts`)
- Old: `1.15^n`
- New: `1.25^n` — compounds faster, ensures the polynomial threshold eventually gets easy in the late game

### Boss prestige requirements (`bosses.ts`)
- Rescaled proportionally from 0–88 range to 0–20 range
- The Absolute One now requires prestige **20** (was 88), making transcendence reachable in a few weeks of idle play

### Echo formula (`transcendence.ts`)
- Constant changed from 853 → **224**
- At the target prestige of 20: `floor(224 / sqrt(20)) = 50 echoes` per transcendence (no meta upgrades)
- With all echo_meta upgrades (3.75× total): up to **187 echoes** per transcendence

### Transcendence upgrade costs (`transcendenceUpgrades.ts`)
- Old total: **866 echoes** → New total: **400 echoes** (roughly halved across all categories)
- Apotheosis still requires **all 15 upgrades** purchased

### Balance fixes (closes #141, #142, #143, #144, #145)
- Equipment: `philosophers_stone` click multiplier 2.25→2.5, `crystal_shard` 1.55→1.65 (#144)
- Recipes: added `primal_omega_lens` cross-zone click_power recipe at 1.38× (#142)
- Adventurers: `celestial_guard` base cost adjusted to smooth tier 14→15→16 cost curve (#145)

### Quest reward rebalancing (closes #136, #137)
- Shadow Marshes: buffed `shadow_mere`, `witch_coven`, `plague_ruins` rewards to match combat requirements (#136)
- Astral Void: added gold to `void_rift`, increased rewards across all Astral Void quests (#137)

### Boss reward additions (closes #138, #139, #140)
- Assigned 9 unassigned adventurer-specific upgrades to Crystalline Spire through Eternal Throne bosses that had empty `upgradeRewards` arrays (#140)

### Combat power documentation (closes #153)
- Expanded JSDoc on `computePartyCombatPower` to clarify companion `bossDamage` multiplier behaviour

### Effective adventurer stats (closes #154)
- Added `computeEffectiveAdventurerStats` to `tick.ts` and updated `AdventurerCard` to display effective post-multiplier stats

### Adventurer upgrade timing (closes #158)
- Audited every adventurer-specific upgrade reward — upgrades now land within the same progression window where that adventurer tier is still a meaningful contributor

### Sync and save fixes (closes #147, #148, #151)
- Fixed sync new content count to report only genuinely changed items (#147)
- Fixed signature mismatch after first auto-boss completion (#148)
- Added auto-buy cap (100) on non-max-tier adventurers (#151)

### Auto-adventurer persistence (closes #156)
- Auto-buy preference now preserved across prestige resets

### Broken CDN image (closes #159)
- Uploaded missing `auto_adventurer.jpg` to CDN

### Codex unlock hints (closes #146)
- Locked codex entries now display a hint generated from `sourceType` and `sourceId`

### Exploration bug fixes (closes #160, #161)
- Fixed auto-save race condition discarding exploration materials collected mid-tick (#160)
- Fixed exploration areas failing to unlock when zone was unlocked via boss kill or quest completion (#161)

### Concurrent prestige fix (closes #162)
- Added optimistic locking via `updatedAt` — concurrent prestige requests return 409

### Prestige UX (closes #163)
- Added `reloadSilent` to game context — no loading screen flash after prestige

### Balance adjustments (closes #164, #165, #166, #167)
- Reduced `shadow_mere` CP requirement 5,000,000 → 2,000,000 (#164)
- Buffed crystal drops from Shadow Marshes bosses and quests (#165)
- Increased runestone yield from 10 → 15 per prestige level (#166)
- Daily challenge set always includes a clicks challenge (#167)

### Progression QoL (closes #168, #169)
- Added `computeProjectedRunestones()` and persistent `+N On Prestige` resource bar row (#168)
- Added `enablePrestigeAnnouncements` setting per player (#169)

---

## Comprehensive balance audit (closes #187, #191, #192, #193, #194, #195, #196, #197, #198)

### Crystal economy fixes
- Zeroed crystal rewards for all Zone 7+ boss drops (Celestial Reaches onwards) — crystals are an early/mid-game currency and should not flow freely into the endgame (#187)
- Zeroed crystal rewards for all Zone 9+ quest rewards (Infernal Court onwards) — same rationale (#191)

### Achievement additions and fixes
- Added quest milestone achievements at 75 quests (10,000 crystals) and 100 quests (15,000 crystals)
- Added boss milestone achievement at 50 bosses (15,000 crystals)
- Added prestige milestone achievements at P50, P100, P150, P200 — rewarding **runestones** rather than crystals to match the late-game economy
- Added gold milestone achievements through 1e90 gold earned
- Fixed `quest_eternal` condition from 122 → **112** (actual quest count) — was permanently impossible (#197)
- Fixed `fully_equipped` condition from 65 → **78** (actual equipment count after new items) (#197)
- Fixed `devourer_slayer` description to remove incorrect zone reference

### Upgrade balance
- Fixed Essence Guild multiplier 1.5× → **2×** — was identical to the cheaper Merchant Alliance for 5× the cost (#194)
- Raised Void Ascendancy crystal cost 10M → **50M** — was trivially cheap compared to the parallel Celestial Mandate upgrade (100B essence + 50T gold) (#195)
- Fixed Sunken Temple quest rewards (gold 2M → 60M, essence 1,500 → 25,000, crystals 75 → 400) — was rewarding less than its easier prerequisite Witch Coven (#193)

### Equipment balance
- Buffed Eternal Prism stats to click 5×, combat **3×**, gold **2.5×** — was only marginally better than the free Eternity Stone boss drop for 100M crystals (#196)

### Missing content
- Created **13 missing equipment items** for Zones 15–18 (primordial_chaos through the_absolute) that were referenced by late-game boss `equipmentRewards` arrays but never existed in `equipment.ts` (#198):
  - `chaos_mantle`, `titan_core` (Primordial Chaos)
  - `expanse_blade`, `void_armour_mk2` (Infinite Expanse)
  - `cosmos_blade`, `reality_plate` (Reality Forge)
  - `maelstrom_edge`, `cosmic_plate` (Cosmic Maelstrom)
  - `primeval_blade`, `ancient_aegis` (Primeval Sanctum)
  - `absolute_blade`, `eternity_plate`, `omniversal_core` (The Absolute)
- Stats scale from combat 14× / gold 9× (Zone 15) up to combat 28× / gold 20× for the final boss drops

### Type system
- Extended `AchievementReward` type to support `runestones` field
- Updated tick engine achievement processing to award both crystals and runestones

---

## Target progression timeline (optimal play, ~16h/day idle)
- First cycle to P20: ~375h (~3.3 weeks)
- Each subsequent cycle gets faster as echo upgrades boost income/combat/threshold
- Expected **~5 transcendences** before apotheosis at 50–187 echoes/transcendence
- **~6 months** to apotheosis for a dedicated player

## Test plan
- [ ] Lint, build, and test pipeline passes (100% coverage maintained)
- [ ] Prestige threshold at P0 is still 1,000,000 gold
- [ ] Prestige runs feel ~1 day long around P8–10 and get easier after
- [ ] The Absolute One is locked until prestige 20
- [ ] Transcendence at P20 awards 50 echoes (no meta upgrades)
- [ ] All 15 transcendence upgrades cost 400 echoes total
- [ ] Bosses in Zones 7+ drop 0 crystals; Zones 1–6 retain crystal drops
- [ ] Quests in Zones 9+ reward 0 crystals; Zones 1–8 retain crystal rewards
- [ ] Sunken Temple rewards more gold/essence/crystals than Witch Coven
- [ ] Essence Guild gives 2× income (stronger than Merchant Alliance 1.5×)
- [ ] Void Ascendancy costs 50M crystals
- [ ] Eternal Prism stats are click 5×, combat 3×, gold 2.5×
- [ ] Late-game bosses (primordial_titan through the_absolute_one) drop equipment on kill
- [ ] `quest_eternal` achievement requires 112 quests
- [ ] `fully_equipped` achievement requires 78 equipment pieces
- [ ] P50/P100/P150/P200 prestige achievements reward runestones
- [ ] Adventurer cards show effective post-multiplier stats
- [ ] Exploration areas unlock correctly when their zone is unlocked
- [ ] Concurrent prestige requests return 409
- [ ] No loading screen flash after prestige
- [ ] Daily challenge set always includes a clicks challenge
- [ ] Resource bar shows `+N On Prestige` runestone preview

 This PR was crafted with help from Hikari~ 🌸

Reviewed-on: #135
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-31 19:57:53 -07:00
naomi 9926e7f639 release: v0.3.2
CI / Lint, Build & Test (push) Successful in 1m13s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m17s
2026-03-24 18:50:37 -07:00
hikari 6bf1ac5e7d feat: grant Elysian role on auth and prompt non-members to join (#134)
CI / Lint, Build & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

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

## Test plan

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

 This issue was created with help from Hikari~ 🌸

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

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

Closes #126

## Test plan

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

 This issue was created with help from Hikari~ 🌸

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

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

Closes #125
Closes #127
Closes #128

## Test plan

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

 This issue was created with help from Hikari~ 🌸

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

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

### Changes

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

### Tickets Closed

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

 This PR was created with help from Hikari~ 🌸

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

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

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

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

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

## Closes

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

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

## Summary

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

## Changes

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

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

 This issue was created with help from Hikari~ 🌸

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

Closes #89

 This PR was created with help from Hikari~ 🌸

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

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

Closes #88

 This PR was created with help from Hikari~ 🌸

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

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

## Root Cause

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

## Fix

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

 This PR was created with help from Hikari~ 🌸

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

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

## Root Cause

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

## Fix

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

 This PR was created with help from Hikari~ 🌸

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

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

### Root Cause

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

### Changes

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

### Progression

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

 This PR was created with help from Hikari~ 🌸

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

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

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #84
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 21:22:13 -07:00
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
177 changed files with 34459 additions and 3718 deletions
+35
View File
@@ -7,6 +7,41 @@
2. `pnpm build` — all packages build cleanly
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
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.
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.1.1",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
@@ -14,18 +14,19 @@
},
"dependencies": {
"@elysium/types": "workspace:*",
"@hono/node-server": "1.13.7",
"@hono/node-server": "1.19.12",
"@nhcarrigan/logger": "1.1.1",
"@prisma/client": "6.5.0",
"hono": "4.7.4",
"hono": "4.12.11",
"prisma": "6.5.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "25.3.5",
"@types/node": "25.5.2",
"@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0",
"tsx": "4.19.3",
"tsx": "4.21.0",
"typescript": "5.8.2",
"vitest": "3.0.8"
}
+1
View File
@@ -35,6 +35,7 @@ model Player {
lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String?
loginStreak Int @default(1)
inGuild Boolean @default(false)
}
model GameState {
+1 -4
View File
@@ -1,6 +1,4 @@
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
@@ -8,5 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+100 -10
View File
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
},
{
condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat all 18 bosses, including the Devourer of Worlds.",
description: "Defeat the 18 bosses of the mortal realms.",
icon: "🌟",
id: "devourer_slayer",
name: "World Saver",
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 40, type: "equipmentOwned" },
description: "Own 40 pieces of equipment.",
condition: { amount: 78, type: "equipmentOwned" },
description: "Own all 78 pieces of equipment.",
icon: "🛡️",
id: "fully_equipped",
name: "Fully Equipped",
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "☄️",
id: "click_deity",
name: "Click Deity",
reward: { crystals: 5000 },
reward: { crystals: 15_000 },
unlockedAt: null,
},
// Endgame gold milestones
@@ -269,6 +269,33 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 50_000 },
unlockedAt: null,
},
{
condition: { amount: 1e30, type: "totalGoldEarned" },
description: "Earn 1 nonillion gold in total.",
icon: "🌌",
id: "cosmic_wealthy",
name: "Cosmic Wealthy",
reward: { crystals: 100_000 },
unlockedAt: null,
},
{
condition: { amount: 1e60, type: "totalGoldEarned" },
description: "Earn a vigintillion gold in total.",
icon: "♾️",
id: "infinite_hoarder",
name: "Infinite Hoarder",
reward: { crystals: 250_000 },
unlockedAt: null,
},
{
condition: { amount: 1e90, type: "totalGoldEarned" },
description: "Earn a trigintillion gold in total.",
icon: "🔮",
id: "omniversal_tycoon",
name: "Omniversal Tycoon",
reward: { crystals: 1_000_000 },
unlockedAt: null,
},
// Higher quest milestones
{
condition: { amount: 30, type: "questsCompleted" },
@@ -289,8 +316,26 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 72, type: "questsCompleted" },
description: "Complete all 72 quests across the known multiverse.",
condition: { amount: 75, type: "questsCompleted" },
description: "Complete 75 quests.",
icon: "🌠",
id: "quest_hero",
name: "Quest Hero",
reward: { crystals: 10_000 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "questsCompleted" },
description: "Complete 100 quests.",
icon: "💫",
id: "quest_legend",
name: "Quest Legend",
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
condition: { amount: 122, type: "questsCompleted" },
description: "Complete all 122 quests across the known multiverse.",
icon: "🌌",
id: "quest_eternal",
name: "Quest Eternal",
@@ -317,8 +362,17 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 60, type: "bossesDefeated" },
description: "Defeat all 60 bosses across every plane of existence.",
condition: { amount: 50, type: "bossesDefeated" },
description: "Defeat 50 bosses.",
icon: "⚡",
id: "boss_legend",
name: "Legendary Vanquisher",
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
condition: { amount: 72, type: "bossesDefeated" },
description: "Defeat all 72 bosses across every plane of existence.",
icon: "💀",
id: "boss_eternal",
name: "Eternal Vanquisher",
@@ -351,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "💫",
id: "prestige_master",
name: "Master of Cycles",
reward: { crystals: 5000 },
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
@@ -360,7 +414,43 @@ export const defaultAchievements: Array<Achievement> = [
icon: "🌠",
id: "prestige_legend",
name: "Legend of Eternity",
reward: { crystals: 25_000 },
reward: { crystals: 75_000 },
unlockedAt: null,
},
{
condition: { amount: 50, type: "prestigeCount" },
description: "Prestige 50 times.",
icon: "✨",
id: "prestige_transcendent",
name: "Transcendent",
reward: { runestones: 100 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "prestigeCount" },
description: "Prestige 100 times.",
icon: "💎",
id: "prestige_eternal",
name: "Eternal Looper",
reward: { runestones: 500 },
unlockedAt: null,
},
{
condition: { amount: 150, type: "prestigeCount" },
description: "Prestige 150 times.",
icon: "🌟",
id: "prestige_immortal",
name: "Immortal Cycler",
reward: { runestones: 2000 },
unlockedAt: null,
},
{
condition: { amount: 200, type: "prestigeCount" },
description: "Prestige 200 times.",
icon: "👑",
id: "prestige_absolute",
name: "Absolute Champion",
reward: { runestones: 10_000 },
unlockedAt: null,
},
];
+53 -41
View File
@@ -21,12 +21,12 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: true,
},
{
baseCost: 100,
baseCost: 65,
class: "warrior",
combatPower: 3,
count: 0,
essencePerSecond: 0,
goldPerSecond: 0.5,
goldPerSecond: 0.7,
id: "militia",
level: 2,
name: "Militia",
@@ -129,50 +129,62 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: false,
},
{
baseCost: 4_000_000_000,
class: "rogue",
combatPower: 18_000,
baseCost: 2_850_000_000,
class: "mage",
combatPower: 13_000,
count: 0,
essencePerSecond: 6,
goldPerSecond: 5000,
id: "shadow_assassin",
level: 11,
name: "Shadow Assassin",
unlocked: false,
},
{
baseCost: 28_000_000_000,
class: "mage",
combatPower: 45_000,
count: 0,
essencePerSecond: 15,
goldPerSecond: 14_000,
goldPerSecond: 4500,
id: "arcane_scholar",
level: 12,
level: 11,
name: "Arcane Scholar",
unlocked: false,
},
{
baseCost: 200_000_000_000,
baseCost: 13_500_000_000,
class: "rogue",
combatPower: 28_000,
count: 0,
essencePerSecond: 11,
goldPerSecond: 9500,
id: "shadow_assassin",
level: 12,
name: "Shadow Assassin",
unlocked: false,
},
{
baseCost: 64_000_000_000,
class: "paladin",
combatPower: 60_000,
count: 0,
essencePerSecond: 20,
goldPerSecond: 20_000,
id: "dark_templar",
level: 13,
name: "Dark Templar",
unlocked: false,
},
{
baseCost: 300_000_000_000,
class: "rogue",
combatPower: 130_000,
count: 0,
essencePerSecond: 35,
goldPerSecond: 40_000,
id: "void_walker",
level: 13,
level: 14,
name: "Void Walker",
unlocked: false,
},
{
baseCost: 1_400_000_000_000,
baseCost: 1_800_000_000_000,
class: "paladin",
combatPower: 400_000,
count: 0,
essencePerSecond: 100,
goldPerSecond: 120_000,
id: "celestial_guard",
level: 14,
level: 15,
name: "Celestial Guard",
unlocked: false,
},
@@ -184,7 +196,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300,
goldPerSecond: 400_000,
id: "divine_champion",
level: 15,
level: 16,
name: "Divine Champion",
unlocked: false,
},
@@ -196,7 +208,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 800,
goldPerSecond: 1_200_000,
id: "seraph_knight",
level: 16,
level: 17,
name: "Seraph Knight",
unlocked: false,
},
@@ -208,7 +220,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2000,
goldPerSecond: 3_500_000,
id: "abyss_diver",
level: 17,
level: 18,
name: "Abyss Diver",
unlocked: false,
},
@@ -220,7 +232,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 5000,
goldPerSecond: 10_000_000,
id: "infernal_warden",
level: 18,
level: 19,
name: "Infernal Warden",
unlocked: false,
},
@@ -232,7 +244,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000,
goldPerSecond: 30_000_000,
id: "crystal_sage",
level: 19,
level: 20,
name: "Crystal Sage",
unlocked: false,
},
@@ -244,7 +256,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 30_000,
goldPerSecond: 90_000_000,
id: "void_sentinel",
level: 20,
level: 21,
name: "Void Sentinel",
unlocked: false,
},
@@ -256,7 +268,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 80_000,
goldPerSecond: 270_000_000,
id: "eternal_champion",
level: 21,
level: 22,
name: "Eternal Champion",
unlocked: false,
},
@@ -268,7 +280,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 220_000,
goldPerSecond: 800_000_000,
id: "aether_weaver",
level: 22,
level: 23,
name: "Aether Weaver",
unlocked: false,
},
@@ -280,7 +292,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 600_000,
goldPerSecond: 2_500_000_000,
id: "titan_warrior",
level: 23,
level: 24,
name: "Titan Warrior",
unlocked: false,
},
@@ -292,7 +304,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 1_600_000,
goldPerSecond: 7_500_000_000,
id: "nexus_sage",
level: 24,
level: 25,
name: "Nexus Sage",
unlocked: false,
},
@@ -304,7 +316,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 4_500_000,
goldPerSecond: 22_000_000_000,
id: "cosmos_knight",
level: 25,
level: 26,
name: "Cosmos Knight",
unlocked: false,
},
@@ -316,7 +328,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 12_000_000,
goldPerSecond: 65_000_000_000,
id: "astral_sovereign",
level: 26,
level: 27,
name: "Astral Sovereign",
unlocked: false,
},
@@ -328,7 +340,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 35_000_000,
goldPerSecond: 200_000_000_000,
id: "primordial_mage",
level: 27,
level: 28,
name: "Primordial Mage",
unlocked: false,
},
@@ -340,7 +352,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 100_000_000,
goldPerSecond: 600_000_000_000,
id: "reality_warden",
level: 28,
level: 29,
name: "Reality Warden",
unlocked: false,
},
@@ -352,7 +364,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 300_000_000,
goldPerSecond: 1_800_000_000_000,
id: "infinity_ranger",
level: 29,
level: 30,
name: "Infinity Ranger",
unlocked: false,
},
@@ -364,7 +376,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 850_000_000,
goldPerSecond: 5_500_000_000_000,
id: "oblivion_paladin",
level: 30,
level: 31,
name: "Oblivion Paladin",
unlocked: false,
},
@@ -376,7 +388,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 2_500_000_000,
goldPerSecond: 16_000_000_000_000,
id: "transcendent_rogue",
level: 31,
level: 32,
name: "Transcendent Rogue",
unlocked: false,
},
@@ -388,7 +400,7 @@ export const defaultAdventurers: Array<Adventurer> = [
essencePerSecond: 7_000_000_000,
goldPerSecond: 50_000_000_000_000,
id: "omniversal_champion",
level: 32,
level: 33,
name: "Omniversal Champion",
unlocked: false,
},
File diff suppressed because it is too large Load Diff
+14
View File
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
target: 5000,
type: "clicks",
},
// Crafting — requires materials but no zone/boss progression
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
{
label: "Craft 2 recipes",
rewardCrystals: 175,
target: 2,
type: "crafting",
},
{
label: "Craft 3 recipes",
rewardCrystals: 350,
target: 3,
type: "crafting",
},
// Boss defeats — requires active combat
{
label: "Defeat 1 boss",
+174 -12
View File
@@ -101,7 +101,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "weapon",
},
{
bonus: { combatMultiplier: 2.75 },
bonus: { combatMultiplier: 3.25 },
cost: { crystals: 500, essence: 2000, gold: 0 },
description:
"A blade made of compressed nothingness. It does not cut — it simply unmakes.",
@@ -204,7 +204,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour",
},
{
bonus: { goldMultiplier: 2.25 },
bonus: { goldMultiplier: 2.75 },
description:
"Woven from threads of pure starlight harvested by the Astral Wraith. Income flows like cosmic energy.",
equipped: false,
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 1.55, goldMultiplier: 1.1 },
bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
description:
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
equipped: false,
@@ -305,9 +305,9 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 2, goldMultiplier: 1.25 },
bonus: { clickMultiplier: 2.5, goldMultiplier: 1.4 },
description:
"The legendary stone that grants mastery over gold and combat alike.",
"The legendary stone that transmutes effort into wealth — every action fills the coffers.",
equipped: false,
id: "philosophers_stone",
name: "Philosopher's Stone",
@@ -316,7 +316,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { clickMultiplier: 2.25, goldMultiplier: 1.15 },
bonus: { clickMultiplier: 2.25, combatMultiplier: 1.1, goldMultiplier: 1.25 },
description:
"A flame that has never been extinguished, sealed in crystal by the Phoenix Lord. It burns with the power of rebirth.",
equipped: false,
@@ -695,9 +695,171 @@ export const defaultEquipment: Array<Equipment> = [
setId: "eternal_throne",
type: "trinket",
},
// ── Primordial Chaos ──────────────────────────────────────────────────────
{
bonus: { goldMultiplier: 9 },
description:
"The Primordial Titan's carapace — formed before the concept of armour existed. It simply is what armour aspires to be.",
equipped: false,
id: "chaos_mantle",
name: "The Chaos Mantle",
owned: false,
rarity: "legendary",
setId: "primordial_chaos",
type: "armour",
},
{
bonus: { clickMultiplier: 5, combatMultiplier: 2, goldMultiplier: 2.5 },
description:
"The crystallised core of the Titan itself — the first stable thing to emerge from chaos. It radiates in every direction simultaneously.",
equipped: false,
id: "titan_core",
name: "The Titan Core",
owned: false,
rarity: "legendary",
setId: "primordial_chaos",
type: "trinket",
},
// ── Infinite Expanse ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 14 },
description:
"Forged from the Expanse Sovereign's own reach — a blade that has no beginning and no end, only edge.",
equipped: false,
id: "expanse_blade",
name: "The Expanse Blade",
owned: false,
rarity: "legendary",
setId: "infinite_expanse",
type: "weapon",
},
{
bonus: { goldMultiplier: 10 },
description:
"A second iteration of the void's armour — the first was not enough. This one has never been tested to its limit.",
equipped: false,
id: "void_armour_mk2",
name: "Void Armour Mk. II",
owned: false,
rarity: "legendary",
setId: "infinite_expanse",
type: "armour",
},
// ── Reality Forge ─────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 16 },
description:
"The Reality Architect's primary instrument — a sword that does not cut through things but rewrites what they are.",
equipped: false,
id: "cosmos_blade",
name: "The Cosmos Blade",
owned: false,
rarity: "legendary",
setId: "reality_forge",
type: "weapon",
},
{
bonus: { goldMultiplier: 12 },
description:
"Plated from the substance of reality itself — wearing it makes you feel slightly more real than everything around you.",
equipped: false,
id: "reality_plate",
name: "The Reality Plate",
owned: false,
rarity: "legendary",
setId: "reality_forge",
type: "armour",
},
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 18 },
description:
"Torn from the eye of the Cosmic Annihilator — a weapon that carries the force of an ending universe in every swing.",
equipped: false,
id: "maelstrom_edge",
name: "The Maelstrom Edge",
owned: false,
rarity: "legendary",
setId: "cosmic_maelstrom",
type: "weapon",
},
{
bonus: { goldMultiplier: 14 },
description:
"Armour that has weathered the destruction of countless realities. It has learned not to flinch.",
equipped: false,
id: "cosmic_plate",
name: "The Cosmic Plate",
owned: false,
rarity: "legendary",
setId: "cosmic_maelstrom",
type: "armour",
},
// ── Primeval Sanctum ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 22 },
description:
"The first weapon — older than the concept of war, older than the concept of a weapon. It remembers what it was made for.",
equipped: false,
id: "primeval_blade",
name: "The Primeval Blade",
owned: false,
rarity: "legendary",
setId: "primeval_sanctum",
type: "weapon",
},
{
bonus: { goldMultiplier: 17 },
description:
"The shield-form of the Primeval God — absolute protection from before the concept of harm existed.",
equipped: false,
id: "ancient_aegis",
name: "The Ancient Aegis",
owned: false,
rarity: "legendary",
setId: "primeval_sanctum",
type: "armour",
},
// ── The Absolute ──────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 28 },
description:
"There is no name for what this was before it became a sword. There is no name for what it is now. It ends things.",
equipped: false,
id: "absolute_blade",
name: "The Absolute Blade",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "weapon",
},
{
bonus: { goldMultiplier: 20 },
description:
"Eternity given the shape of armour — it has always existed, it will always exist, and it has always protected its wearer.",
equipped: false,
id: "eternity_plate",
name: "The Eternity Plate",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "armour",
},
{
bonus: { clickMultiplier: 6, combatMultiplier: 3, goldMultiplier: 3 },
description:
"The heart of everything — a thing so fundamental that its removal from the Absolute One ended all things, briefly. Briefly.",
equipped: false,
id: "omniversal_core",
name: "The Omniversal Core",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "trinket",
},
// ── Purchasable endgame sinks ─────────────────────────────────────────────
{
bonus: { clickMultiplier: 2.5 },
bonus: { clickMultiplier: 4.25 },
cost: { crystals: 0, essence: 20_000_000, gold: 0 },
description:
"A lens of compressed celestial light that sharpens every strike with divine precision.",
@@ -709,7 +871,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { goldMultiplier: 3 },
bonus: { goldMultiplier: 3.75 },
cost: { crystals: 0, essence: 50_000_000, gold: 0 },
description:
"A book written in the language of the deep — reading it aligns your guild's operations with abyssal efficiency.",
@@ -721,7 +883,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour",
},
{
bonus: { combatMultiplier: 4 },
bonus: { combatMultiplier: 10.5 },
cost: { crystals: 0, essence: 100_000_000, gold: 0 },
description:
"A weapon that channels void energy — the absence of resistance makes every strike devastating.",
@@ -733,7 +895,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "weapon",
},
{
bonus: { clickMultiplier: 3.5, goldMultiplier: 1.5 },
bonus: { clickMultiplier: 4, goldMultiplier: 1.5 },
cost: { crystals: 5_000_000, essence: 0, gold: 0 },
description:
"A gem forged in the heart of the Infernal Court — it burns with productivity and righteous fury in equal measure.",
@@ -745,7 +907,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket",
},
{
bonus: { goldMultiplier: 4 },
bonus: { goldMultiplier: 7.5 },
cost: { crystals: 20_000_000, essence: 0, gold: 0 },
description:
"Armour structured around a crystalline lattice of optimal income calculations. Every gold piece finds you faster.",
@@ -757,7 +919,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour",
},
{
bonus: { clickMultiplier: 5, combatMultiplier: 1.5, goldMultiplier: 2 },
bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 },
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
description:
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
/* eslint-disable max-lines -- Data file */
import type { GoddessAchievement } from "@elysium/types";
export const defaultGoddessAchievements: Array<GoddessAchievement> = [
// TotalPrayersEarned milestones
{
condition: { amount: 1000, type: "totalPrayersEarned" },
description: "Offer your first thousand prayers and feel the divine stir for the first time.",
icon: "🕯️",
id: "prayers_thousand",
name: "Whisper of the Faithful",
reward: { divinity: 5 },
unlockedAt: null,
},
{
condition: { amount: 10_000, type: "totalPrayersEarned" },
description: "Ten thousand prayers rise like incense smoke toward the heavens.",
icon: "🙏",
id: "prayers_ten_thousand",
name: "Voice of Devotion",
reward: { divinity: 25 },
unlockedAt: null,
},
{
condition: { amount: 100_000, type: "totalPrayersEarned" },
description: "A hundred thousand supplications echoing across the sacred halls.",
icon: "⛪",
id: "prayers_hundred_thousand",
name: "Chorus of the Devoted",
reward: { divinity: 100 },
unlockedAt: null,
},
{
condition: { amount: 1_000_000, type: "totalPrayersEarned" },
description: "One million prayers — the Goddess turns her gaze upon you at last.",
icon: "✨",
id: "prayers_million",
name: "Radiant Supplicant",
reward: { divinity: 300 },
unlockedAt: null,
},
{
condition: { amount: 10_000_000, type: "totalPrayersEarned" },
description: "Ten million prayers offered; the sacred flame burns without end.",
icon: "🔥",
id: "prayers_ten_million",
name: "Eternal Flame of Worship",
reward: { divinity: 750 },
unlockedAt: null,
},
{
condition: { amount: 100_000_000, type: "totalPrayersEarned" },
description: "A hundred million prayers — your faith moves the pillars of the cosmos.",
icon: "💫",
id: "prayers_hundred_million",
name: "Pillar of the Cosmos",
reward: { divinity: 1500 },
unlockedAt: null,
},
{
condition: { amount: 1_000_000_000, type: "totalPrayersEarned" },
description: "A billion prayers ascend, weaving a tapestry of light across the void.",
icon: "🌌",
id: "prayers_billion",
name: "Weaver of Sacred Light",
reward: { divinity: 2500 },
unlockedAt: null,
},
{
condition: { amount: 10_000_000_000, type: "totalPrayersEarned" },
description: "Ten billion prayers; the stars themselves bow in reverence.",
icon: "⭐",
id: "prayers_ten_billion",
name: "Constellation of Faith",
reward: { divinity: 3500 },
unlockedAt: null,
},
{
condition: { amount: 100_000_000_000, type: "totalPrayersEarned" },
description: "A hundred billion prayers — the divine throne trembles with your devotion.",
icon: "🌠",
id: "prayers_hundred_billion",
name: "Trembler of the Divine Throne",
reward: { divinity: 4250 },
unlockedAt: null,
},
{
condition: { amount: 1_000_000_000_000, type: "totalPrayersEarned" },
description: "A trillion prayers offered — you have become the Goddess's own heartbeat.",
icon: "👁️",
id: "prayers_trillion",
name: "Heartbeat of the Goddess",
reward: { divinity: 5000 },
unlockedAt: null,
},
// GoddessBossesDefeated milestones
{
condition: { amount: 1, type: "goddessBossesDefeated" },
description: "Strike down your first divine adversary and claim your place among the faithful.",
icon: "⚔️",
id: "goddess_boss_first",
name: "Champion's First Blood",
reward: { divinity: 10 },
unlockedAt: null,
},
{
condition: { amount: 5, type: "goddessBossesDefeated" },
description: "Five sacred titans fall before your righteous fury.",
icon: "🗡️",
id: "goddess_boss_five",
name: "Slayer of Sacred Titans",
reward: { divinity: 75 },
unlockedAt: null,
},
{
condition: { amount: 15, type: "goddessBossesDefeated" },
description: "Fifteen divine guardians vanquished — the celestial war begins in earnest.",
icon: "🛡️",
id: "goddess_boss_fifteen",
name: "Celestial Warlord",
reward: { divinity: 300 },
unlockedAt: null,
},
{
condition: { amount: 30, type: "goddessBossesDefeated" },
description: "Thirty heavenly champions broken at your feet.",
icon: "💥",
id: "goddess_boss_thirty",
name: "Breaker of the Heavenly Host",
reward: { divinity: 800 },
unlockedAt: null,
},
{
condition: { amount: 45, type: "goddessBossesDefeated" },
description: "Forty-five divine guardians have crumbled; the heavens ring with your name.",
icon: "🌟",
id: "goddess_boss_forty_five",
name: "Name Written in Heaven",
reward: { divinity: 2000 },
unlockedAt: null,
},
{
condition: { amount: 55, type: "goddessBossesDefeated" },
description: "Fifty-five sacred sentinels silenced; even the Goddess watches with awe.",
icon: "👑",
id: "goddess_boss_fifty_five",
name: "Awe of the Goddess",
reward: { divinity: 4000 },
unlockedAt: null,
},
{
condition: { amount: 65, type: "goddessBossesDefeated" },
description: "Sixty-five divine colossi toppled; the celestial order bends to your will.",
icon: "🌙",
id: "goddess_boss_sixty_five",
name: "Bender of the Celestial Order",
reward: { divinity: 7000 },
unlockedAt: null,
},
{
condition: { amount: 72, type: "goddessBossesDefeated" },
description: "All seventy-two divine guardians have fallen. The heavens stand open before you.",
icon: "🏆",
id: "goddess_boss_all",
name: "Conqueror of the Heavens",
reward: { divinity: 10_000 },
unlockedAt: null,
},
// GoddessQuestsCompleted milestones
{
condition: { amount: 1, type: "goddessQuestsCompleted" },
description: "Complete your first sacred trial and prove yourself worthy of divine attention.",
icon: "📜",
id: "goddess_quest_first",
name: "First Sacred Trial",
reward: { divinity: 5 },
unlockedAt: null,
},
{
condition: { amount: 5, type: "goddessQuestsCompleted" },
description: "Five holy tasks fulfilled — the Goddess acknowledges your diligence.",
icon: "🌿",
id: "goddess_quest_five",
name: "Acknowledged by the Divine",
reward: { divinity: 40 },
unlockedAt: null,
},
{
condition: { amount: 15, type: "goddessQuestsCompleted" },
description: "Fifteen sacred errands completed in the name of the eternal light.",
icon: "☀️",
id: "goddess_quest_fifteen",
name: "Errand of Eternal Light",
reward: { divinity: 175 },
unlockedAt: null,
},
{
condition: { amount: 30, type: "goddessQuestsCompleted" },
description: "Thirty divine mandates carried out; your legend grows in the celestial annals.",
icon: "📖",
id: "goddess_quest_thirty",
name: "Inscribed in the Celestial Annals",
reward: { divinity: 500 },
unlockedAt: null,
},
{
condition: { amount: 50, type: "goddessQuestsCompleted" },
description: "Fifty holy quests fulfilled — the sacred codex opens its deepest chapters to you.",
icon: "🔮",
id: "goddess_quest_fifty",
name: "Reader of the Sacred Codex",
reward: { divinity: 1200 },
unlockedAt: null,
},
{
condition: { amount: 65, type: "goddessQuestsCompleted" },
description: "Sixty-five divine missions accomplished; the heavenly choir sings your praises.",
icon: "🎶",
id: "goddess_quest_sixty_five",
name: "Sung by the Heavenly Choir",
reward: { divinity: 2500 },
unlockedAt: null,
},
{
condition: { amount: 80, type: "goddessQuestsCompleted" },
description: "Eighty sacred tasks complete — you walk the path of the exalted chosen.",
icon: "🕊️",
id: "goddess_quest_eighty",
name: "Path of the Exalted Chosen",
reward: { divinity: 3750 },
unlockedAt: null,
},
{
condition: { amount: 90, type: "goddessQuestsCompleted" },
description: "Ninety divine quests fulfilled — every last sacred duty discharged with glory.",
icon: "🌈",
id: "goddess_quest_all",
name: "Glory of Full Devotion",
reward: { divinity: 5000 },
unlockedAt: null,
},
// DiscipleTotal milestones
{
condition: { amount: 10, type: "discipleTotal" },
description: "Gather ten disciples beneath the Goddess's light and begin your congregation.",
icon: "👥",
id: "disciples_ten",
name: "Seeds of a Congregation",
reward: { divinity: 10 },
unlockedAt: null,
},
{
condition: { amount: 25, type: "discipleTotal" },
description: "Twenty-five faithful souls gathered — the divine community takes shape.",
icon: "🏛️",
id: "disciples_twenty_five",
name: "The Divine Community",
reward: { divinity: 50 },
unlockedAt: null,
},
{
condition: { amount: 50, type: "discipleTotal" },
description: "Fifty disciples united in worship — a true flock of the faithful.",
icon: "🕌",
id: "disciples_fifty",
name: "Flock of the Faithful",
reward: { divinity: 150 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "discipleTotal" },
description: "One hundred devoted souls kneel at the Goddess's altar by your invitation.",
icon: "💎",
id: "disciples_hundred",
name: "Hundred Kneeling Souls",
reward: { divinity: 450 },
unlockedAt: null,
},
{
condition: { amount: 250, type: "discipleTotal" },
description: "Two hundred and fifty disciples — a living temple of mortal devotion.",
icon: "🗼",
id: "disciples_two_fifty",
name: "Living Temple of Devotion",
reward: { divinity: 1200 },
unlockedAt: null,
},
{
condition: { amount: 500, type: "discipleTotal" },
description: "Five hundred disciples gathered; the mortal world trembles with collective faith.",
icon: "🌍",
id: "disciples_five_hundred",
name: "Trembling World of Faith",
reward: { divinity: 3000 },
unlockedAt: null,
},
{
condition: { amount: 1000, type: "discipleTotal" },
description: "A thousand disciples stand as testament to your divine calling.",
icon: "⚡",
id: "disciples_thousand",
name: "Testament of the Divine Calling",
reward: { divinity: 6000 },
unlockedAt: null,
},
{
condition: { amount: 2500, type: "discipleTotal" },
description: "Two thousand five hundred disciples — an empire of faith sculpted by your hands.",
icon: "🏰",
id: "disciples_two_five_hundred",
name: "Empire of Faith",
reward: { divinity: 10_000 },
unlockedAt: null,
},
// ConsecrationCount milestones
{
condition: { amount: 1, type: "consecrationCount" },
description: "Undergo the sacred rite of Consecration for the first time and be reborn in divine fire.",
icon: "🔱",
id: "consecration_first",
name: "First Rite of Rebirth",
reward: { stardust: 1 },
unlockedAt: null,
},
{
condition: { amount: 3, type: "consecrationCount" },
description: "Three consecrations endured — the cycle of death and rebirth refines your soul.",
icon: "♾️",
id: "consecration_three",
name: "Cycle of the Refined Soul",
reward: { stardust: 2 },
unlockedAt: null,
},
{
condition: { amount: 6, type: "consecrationCount" },
description: "Six times reborn through sacred flame — the Goddess has remade you utterly.",
icon: "🌺",
id: "consecration_six",
name: "Utterly Remade",
reward: { stardust: 3 },
unlockedAt: null,
},
{
condition: { amount: 10, type: "consecrationCount" },
description: "Ten consecrations completed — you have transcended the mortal concept of self.",
icon: "🌸",
id: "consecration_ten",
name: "Transcendence of Self",
reward: { stardust: 5 },
unlockedAt: null,
},
// GoddessEquipmentOwned milestones
{
condition: { amount: 10, type: "goddessEquipmentOwned" },
description: "Gather ten pieces of divine armament and begin to walk as an instrument of the Goddess.",
icon: "🛡️",
id: "goddess_equipment_ten",
name: "Instrument of the Goddess",
reward: { divinity: 500 },
unlockedAt: null,
},
{
condition: { amount: 53, type: "goddessEquipmentOwned" },
description: "Every piece of sacred armament claimed — the full divine arsenal is yours to wield.",
icon: "⚜️",
id: "goddess_equipment_all",
name: "Bearer of the Full Divine Arsenal",
reward: { stardust: 3 },
unlockedAt: null,
},
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { ConsecrationUpgrade } from "@elysium/types";
export const defaultConsecrationUpgrades: Array<ConsecrationUpgrade> = [
// ── Prayer income ────────────────────────────────────────────────────────
{
category: "prayers",
description: "The first drop of divinity awakens your disciples' devotion. All prayers/s ×1.25.",
divinityCost: 5,
id: "divine_prayers_1",
multiplier: 1.25,
name: "Divinity Blessing I",
},
{
category: "prayers",
description: "Deeper divine resonance amplifies every prayer across the order. All prayers/s ×1.5.",
divinityCost: 15,
id: "divine_prayers_2",
multiplier: 1.5,
name: "Divinity Blessing II",
},
{
category: "prayers",
description: "The full weight of accumulated consecration doubles prayer output entirely. All prayers/s ×2.",
divinityCost: 40,
id: "divine_prayers_3",
multiplier: 2,
name: "Divinity Blessing III",
},
{
category: "prayers",
description: "The goddess's own blessing multiplies prayers fivefold through all of creation. All prayers/s ×5.",
divinityCost: 120,
id: "divine_prayers_4",
multiplier: 5,
name: "Divinity Blessing IV",
},
{
category: "prayers",
description: "An unbroken chain of consecrations has tuned your disciples to a perfect divine frequency. All prayers/s ×10.",
divinityCost: 350,
id: "divine_prayers_5",
multiplier: 10,
name: "Divinity Blessing V",
},
{
category: "prayers",
description: "The consecration memory floods every prayer with exponential fervour. All prayers/s ×25.",
divinityCost: 900,
id: "divine_prayers_6",
multiplier: 25,
name: "Divinity Blessing VI",
},
{
category: "prayers",
description: "The ultimate consecration attunement — prayer income multiplied one hundredfold. All prayers/s ×100.",
divinityCost: 2500,
id: "divine_prayers_7",
multiplier: 100,
name: "Divinity Blessing VII",
},
// ── Disciple output ──────────────────────────────────────────────────────
{
category: "disciples",
description: "Consecrated disciples return with deepened devotion. Disciple output ×1.25.",
divinityCost: 8,
id: "disciple_mastery_1",
multiplier: 1.25,
name: "Disciple Mastery I",
},
{
category: "disciples",
description: "The order's collective devotion grows with each consecration. Disciple output ×1.5.",
divinityCost: 25,
id: "disciple_mastery_2",
multiplier: 1.5,
name: "Disciple Mastery II",
},
{
category: "disciples",
description: "Consecration has forged the disciples into instruments of pure divine will. Disciple output ×2.",
divinityCost: 80,
id: "disciple_mastery_3",
multiplier: 2,
name: "Disciple Mastery III",
},
{
category: "disciples",
description: "Every rebirth reshapes the disciples anew, each cycle stronger than the last. Disciple output ×5.",
divinityCost: 250,
id: "disciple_mastery_4",
multiplier: 5,
name: "Disciple Mastery IV",
},
{
category: "disciples",
description: "The disciples have transcended mortal devotion — they are consecration made flesh. Disciple output ×10.",
divinityCost: 750,
id: "disciple_mastery_5",
multiplier: 10,
name: "Disciple Mastery V",
},
// ── Combat power ─────────────────────────────────────────────────────────
{
category: "combat",
description: "Sacred battle experience accumulates across consecrations. Combat power ×1.25.",
divinityCost: 10,
id: "sacred_combat_c1",
multiplier: 1.25,
name: "Sacred Warfare I",
},
{
category: "combat",
description: "Veterans of consecration know how to fight smarter in the divine realm. Combat power ×1.5.",
divinityCost: 35,
id: "sacred_combat_c2",
multiplier: 1.5,
name: "Sacred Warfare II",
},
{
category: "combat",
description: "Consecrated warriors carry the strength of every past life into battle. Combat power ×2.",
divinityCost: 100,
id: "sacred_combat_c3",
multiplier: 2,
name: "Sacred Warfare III",
},
{
category: "combat",
description: "Battle-hardened by countless consecrations, every disciple strikes with threefold fury. Combat power ×3.",
divinityCost: 300,
id: "sacred_combat_c4",
multiplier: 3,
name: "Sacred Warfare IV",
},
{
category: "combat",
description: "The consecration cycle has transformed the order into an unstoppable divine army. Combat power ×5.",
divinityCost: 800,
id: "sacred_combat_c5",
multiplier: 5,
name: "Sacred Warfare V",
},
// ── Divinity meta ────────────────────────────────────────────────────────
{
category: "divinity",
description: "Your consecration attunement deepens with each rebirth. Earn 25% more divinity from future consecrations.",
divinityCost: 50,
id: "divine_legacy",
multiplier: 1.25,
name: "Divine Legacy",
},
{
category: "divinity",
description: "The echoes of past consecrations amplify the divinity extracted from future ones. Divinity from consecration ×1.5.",
divinityCost: 175,
id: "consecration_echo",
multiplier: 1.5,
name: "Consecration Echo",
},
{
category: "divinity",
description: "Insight beyond the cycle doubles the divinity gained each time you consecrate. Divinity from consecration ×2.",
divinityCost: 500,
id: "eternal_insight",
multiplier: 2,
name: "Eternal Insight",
},
{
category: "divinity",
description: "The loop of consecration feeds back upon itself, tripling the divinity in each harvest. Divinity from consecration ×3.",
divinityCost: 1500,
id: "divine_recursion",
multiplier: 3,
name: "Divine Recursion",
},
{
category: "divinity",
description: "Perfect consecration yields — the goddess rewards mastery with fivefold divinity. Divinity from consecration ×5.",
divinityCost: 4000,
id: "perfect_consecration",
multiplier: 5,
name: "Perfect Consecration",
},
// ── Utility ──────────────────────────────────────────────────────────────
{
category: "utility",
description: "Unlock the Auto-Consecration toggle. When enabled, you will automatically consecrate the moment you reach the consecration threshold.",
divinityCost: 20,
id: "auto_consecrate",
multiplier: 1,
name: "Autonomous Consecration",
},
{
category: "utility",
description: "The threshold required to consecrate is reduced by 10%, letting you cycle faster.",
divinityCost: 60,
id: "consecration_efficiency_1",
multiplier: 0.9,
name: "Consecration Efficiency I",
},
{
category: "utility",
description: "Deeper understanding of the ritual reduces the consecration threshold by a further 10%.",
divinityCost: 200,
id: "consecration_efficiency_2",
multiplier: 0.9,
name: "Consecration Efficiency II",
},
];
+546
View File
@@ -0,0 +1,546 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { CraftingRecipe } from "@elysium/types";
export const defaultGoddessCraftingRecipes: Array<CraftingRecipe> = [
// ── Celestial Garden ─────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.1,
},
description: "An amplifier woven from divine petals and crystallised prayer. It does not make prayers louder — it makes them more true.",
id: "prayer_amplifier",
name: "Prayer Amplifier",
requiredMaterials: [
{ materialId: "divine_petal", quantity: 3 },
{ materialId: "prayer_crystal", quantity: 2 },
],
zoneId: "goddess_celestial_garden",
},
{
bonus: {
type: "combat_power",
value: 1.1,
},
description: "Celestial dust ground into prayer crystals creates a focus that sharpens disciples' divine combat instincts.",
id: "celestial_focus",
name: "Celestial Focus",
requiredMaterials: [
{ materialId: "celestial_dust", quantity: 2 },
{ materialId: "prayer_crystal", quantity: 2 },
],
zoneId: "goddess_celestial_garden",
},
// ── Crystal Sanctum ──────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.15,
},
description: "Holy ink dissolved in sanctum water, then set with shard dust. Disciples who drink it can recite any prayer they have heard exactly once.",
id: "oracle_potion",
name: "Oracle Potion",
requiredMaterials: [
{ materialId: "holy_ink", quantity: 2 },
{ materialId: "sanctum_shard", quantity: 3 },
],
zoneId: "goddess_crystal_sanctum",
},
{
bonus: {
type: "essence_income",
value: 1.2,
},
description: "A lens ground from an oracle fragment and set in holy ink. Through it, the divine flow of the universe becomes briefly legible.",
id: "lens_of_truth",
name: "Lens of Truth",
requiredMaterials: [
{ materialId: "oracle_lens_fragment", quantity: 1 },
{ materialId: "holy_ink", quantity: 2 },
],
zoneId: "goddess_crystal_sanctum",
},
// ── Astral Cathedral ─────────────────────────────────────────────────────
{
bonus: {
type: "combat_power",
value: 1.2,
},
description: "A balm prepared from seraph feathers and choir essence. Applied before battle, it gives disciples the brief sensation of having wings.",
id: "seraph_balm",
name: "Seraph Balm",
requiredMaterials: [
{ materialId: "seraph_feather", quantity: 3 },
{ materialId: "choir_essence", quantity: 2 },
],
zoneId: "goddess_astral_cathedral",
},
{
bonus: {
type: "gold_income",
value: 1.2,
},
description: "Astral glass dissolved in choir essence creates a vial of concentrated resonance. One drop per disciple per morning.",
id: "astral_resonance_vial",
name: "Astral Resonance Vial",
requiredMaterials: [
{ materialId: "astral_glass", quantity: 2 },
{ materialId: "choir_essence", quantity: 2 },
],
zoneId: "goddess_astral_cathedral",
},
// ── Empyrean Citadel ─────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.25,
},
description: "Empyrean ore refined with divine alloy dust and pressed into a blessing-coin. The citadel uses these to sanctify each new weapon forged.",
id: "empyrean_blessing",
name: "Empyrean Blessing",
requiredMaterials: [
{ materialId: "empyrean_ore", quantity: 3 },
{ materialId: "divine_alloy", quantity: 2 },
],
zoneId: "goddess_empyrean_citadel",
},
{
bonus: {
type: "combat_power",
value: 1.25,
},
description: "A tincture prepared from a champion's medal and divine alloy filings. Dosed by champions before major engagements.",
id: "champions_tincture",
name: "Champion's Tincture",
requiredMaterials: [
{ materialId: "celestial_medal", quantity: 1 },
{ materialId: "divine_alloy", quantity: 2 },
],
zoneId: "goddess_empyrean_citadel",
},
// ── Primordial Springs ───────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.3,
},
description: "Creation water distilled with primordial essence — the closest thing to bottled genesis. Handle as if the universe is watching.",
id: "springs_elixir",
name: "Springs Elixir",
requiredMaterials: [
{ materialId: "creation_water", quantity: 3 },
{ materialId: "primordial_essence", quantity: 2 },
],
zoneId: "goddess_primordial_springs",
},
{
bonus: {
type: "essence_income",
value: 1.35,
},
description: "A genesis crystal dissolved in creation water — a brew of pure origination. Disciples who drink it briefly remember what it felt like to not yet exist.",
id: "genesis_brew",
name: "Genesis Brew",
requiredMaterials: [
{ materialId: "genesis_crystal", quantity: 1 },
{ materialId: "creation_water", quantity: 2 },
],
zoneId: "goddess_primordial_springs",
},
// ── Eternal Firmament ────────────────────────────────────────────────────
{
bonus: {
type: "combat_power",
value: 1.35,
},
description: "A ward inscribed on firmament stone using divine light shards as the writing medium. Disciples who carry it are protected by permanence itself.",
id: "firmament_ward",
name: "Firmament Ward",
requiredMaterials: [
{ materialId: "firmament_stone", quantity: 2 },
{ materialId: "divine_light_shard", quantity: 2 },
],
zoneId: "goddess_eternal_firmament",
},
{
bonus: {
type: "essence_income",
value: 1.4,
},
description: "An eternity fragment set in divine light — a lantern that never dims because it is powered by time itself. It illuminates things that do not normally have light.",
id: "eternity_lantern",
name: "Eternity Lantern",
requiredMaterials: [
{ materialId: "eternity_fragment", quantity: 1 },
{ materialId: "divine_light_shard", quantity: 3 },
],
zoneId: "goddess_eternal_firmament",
},
// ── Sacred Grove ─────────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.4,
},
description: "Grove resin mixed with luminous leaf extract and set around sacred heartwood creates a talisman that grows warmer in the presence of sincere prayer.",
id: "grove_talisman",
name: "Grove Talisman",
requiredMaterials: [
{ materialId: "grove_resin", quantity: 3 },
{ materialId: "luminous_leaf", quantity: 2 },
{ materialId: "sacred_heartwood", quantity: 1 },
],
zoneId: "goddess_sacred_grove",
},
{
bonus: {
type: "combat_power",
value: 1.4,
},
description: "Sacred heartwood carved into a pendant and sealed with grove resin. Disciples who wear it feel the grove's centuries of reverence as personal strength.",
id: "heartwood_pendant",
name: "Heartwood Pendant",
requiredMaterials: [
{ materialId: "sacred_heartwood", quantity: 2 },
{ materialId: "grove_resin", quantity: 2 },
],
zoneId: "goddess_sacred_grove",
},
// ── Luminous Expanse ─────────────────────────────────────────────────────
{
bonus: {
type: "essence_income",
value: 1.45,
},
description: "Captured radiance compressed around a light core — a beacon that broadcasts the goddess's presence to disciples too far away to feel it unaided.",
id: "radiance_beacon",
name: "Radiance Beacon",
requiredMaterials: [
{ materialId: "captured_radiance", quantity: 3 },
{ materialId: "light_core", quantity: 1 },
],
zoneId: "goddess_luminous_expanse",
},
{
bonus: {
type: "gold_income",
value: 1.45,
},
description: "A pool of radiance encased in glass and suspended on radiance pool solution — a focusing lens that amplifies divine light into something measurable.",
id: "luminous_prism",
name: "Luminous Prism",
requiredMaterials: [
{ materialId: "radiance_pool", quantity: 2 },
{ materialId: "captured_radiance", quantity: 2 },
{ materialId: "light_core", quantity: 1 },
],
zoneId: "goddess_luminous_expanse",
},
// ── Heavenly Forge ───────────────────────────────────────────────────────
{
bonus: {
type: "combat_power",
value: 1.5,
},
description: "Forge scale layered over divine slag and inlaid with a forge gem — a gauntlet that channels the forge's sacred heat into every blow struck.",
id: "forge_gauntlet",
name: "Forge Gauntlet",
requiredMaterials: [
{ materialId: "forge_scale", quantity: 3 },
{ materialId: "divine_slag", quantity: 2 },
{ materialId: "forge_gem", quantity: 1 },
],
zoneId: "goddess_heavenly_forge",
},
{
bonus: {
type: "essence_income",
value: 1.5,
},
description: "A forge gem set in refined divine slag — a crucible that converts ambient prayer energy directly into essence. Runs continuously once lit.",
id: "divine_crucible",
name: "Divine Crucible",
requiredMaterials: [
{ materialId: "forge_gem", quantity: 2 },
{ materialId: "divine_slag", quantity: 3 },
],
zoneId: "goddess_heavenly_forge",
},
// ── Oracle Sanctum ───────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.55,
},
description: "Vision residue suspended in prophecy crystal solution — an elixir that grants disciples fleeting precognitive awareness of lucrative opportunities.",
id: "oracle_elixir",
name: "Oracle Elixir",
requiredMaterials: [
{ materialId: "vision_residue", quantity: 3 },
{ materialId: "prophecy_crystal", quantity: 2 },
],
zoneId: "goddess_oracle_sanctum",
},
{
bonus: {
type: "combat_power",
value: 1.55,
},
description: "A fate shard mounted on a prophecy crystal matrix — when a disciple holds it before battle, they briefly see the outcome and can choose their approach accordingly.",
id: "fate_compass",
name: "Fate Compass",
requiredMaterials: [
{ materialId: "fate_shard", quantity: 1 },
{ materialId: "prophecy_crystal", quantity: 3 },
{ materialId: "vision_residue", quantity: 2 },
],
zoneId: "goddess_oracle_sanctum",
},
// ── Seraph's Nest ────────────────────────────────────────────────────────
{
bonus: {
type: "essence_income",
value: 1.6,
},
description: "Seraph down woven into a mantle and sealed with seraph primary barbs — wearing it is indistinguishable from being held by something enormous and gentle.",
id: "seraph_mantle",
name: "Seraph Mantle",
requiredMaterials: [
{ materialId: "seraph_down", quantity: 4 },
{ materialId: "seraph_primary", quantity: 2 },
],
zoneId: "goddess_seraphs_nest",
},
{
bonus: {
type: "combat_power",
value: 1.6,
},
description: "An ascended quill carved into a focus rod — it channels divine will with zero resistance, as though the goddess herself were guiding every motion.",
id: "ascended_focus",
name: "Ascended Focus",
requiredMaterials: [
{ materialId: "ascended_quill", quantity: 1 },
{ materialId: "seraph_primary", quantity: 2 },
{ materialId: "seraph_down", quantity: 2 },
],
zoneId: "goddess_seraphs_nest",
},
// ── Divine Archive ───────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.65,
},
description: "Celestial vellum stamped with archive seals and pressed into a portable codex — disciples who carry it can access divine knowledge in the field.",
id: "field_codex",
name: "Field Codex",
requiredMaterials: [
{ materialId: "celestial_vellum", quantity: 4 },
{ materialId: "archive_seal", quantity: 2 },
],
zoneId: "goddess_divine_archive",
},
{
bonus: {
type: "essence_income",
value: 1.65,
},
description: "A living codex page bound with archive seals — it updates itself with new divine knowledge continuously, and disciples gain essence simply by proximity.",
id: "living_tome",
name: "Living Tome",
requiredMaterials: [
{ materialId: "living_codex_page", quantity: 2 },
{ materialId: "archive_seal", quantity: 2 },
{ materialId: "celestial_vellum", quantity: 2 },
],
zoneId: "goddess_divine_archive",
},
// ── Consecrated Depths ───────────────────────────────────────────────────
{
bonus: {
type: "combat_power",
value: 1.7,
},
description: "Consecrated stone carved into armour plates and blessed with depth blessing — the armour remembers every prayer spoken over it and adds their weight to the wearer.",
id: "depth_armour",
name: "Depth Armour",
requiredMaterials: [
{ materialId: "consecrated_stone", quantity: 3 },
{ materialId: "depth_blessing", quantity: 2 },
{ materialId: "abyssal_gem", quantity: 1 },
],
zoneId: "goddess_consecrated_depths",
},
{
bonus: {
type: "essence_income",
value: 1.7,
},
description: "An abyssal gem set in depth blessing solution — a vessel that draws essence from the deepest consecrated places and stores it for release when needed.",
id: "abyssal_vessel",
name: "Abyssal Vessel",
requiredMaterials: [
{ materialId: "abyssal_gem", quantity: 2 },
{ materialId: "depth_blessing", quantity: 3 },
],
zoneId: "goddess_consecrated_depths",
},
// ── Astral Confluence ────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.75,
},
description: "Confluence shards woven into a prism using astral harmonics — it refracts divine energy across multiple streams simultaneously, multiplying its effective output.",
id: "confluence_prism",
name: "Confluence Prism",
requiredMaterials: [
{ materialId: "confluence_shard", quantity: 3 },
{ materialId: "astral_harmonic", quantity: 2 },
],
zoneId: "goddess_astral_confluence",
},
{
bonus: {
type: "combat_power",
value: 1.75,
},
description: "A convergence node bound in astral harmonic resonance — a weapon core that channels the power of seven converging astral streams into a single devastating point.",
id: "convergence_core",
name: "Convergence Core",
requiredMaterials: [
{ materialId: "convergence_node", quantity: 1 },
{ materialId: "astral_harmonic", quantity: 3 },
{ materialId: "confluence_shard", quantity: 2 },
],
zoneId: "goddess_astral_confluence",
},
// ── Celestial Throne ─────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 1.8,
},
description: "Throne gold leaf pressed over a sovereignty gem — a signet whose mark carries the full weight of divine authority and cannot be questioned.",
id: "sovereignty_signet",
name: "Sovereignty Signet",
requiredMaterials: [
{ materialId: "throne_gold_leaf", quantity: 3 },
{ materialId: "sovereignty_gem", quantity: 1 },
],
zoneId: "goddess_celestial_throne",
},
{
bonus: {
type: "combat_power",
value: 1.8,
},
description: "A crown fragment set in sovereignty gem and trimmed with throne gold — the fragment remembers every ruling ever passed from the throne and channels that authority.",
id: "crown_relic",
name: "Crown Relic",
requiredMaterials: [
{ materialId: "crown_fragment", quantity: 1 },
{ materialId: "sovereignty_gem", quantity: 2 },
{ materialId: "throne_gold_leaf", quantity: 2 },
],
zoneId: "goddess_celestial_throne",
},
// ── Infinite Choir ───────────────────────────────────────────────────────
{
bonus: {
type: "essence_income",
value: 1.85,
},
description: "Choir notes compressed with divine resonance into a single instrument — playing it fills nearby disciples with the choir's infinite devotion and multiplies their essence output.",
id: "resonance_instrument",
name: "Resonance Instrument",
requiredMaterials: [
{ materialId: "choir_note", quantity: 4 },
{ materialId: "divine_resonance", quantity: 2 },
],
zoneId: "goddess_infinite_choir",
},
{
bonus: {
type: "combat_power",
value: 1.85,
},
description: "The sacred chord crystallised and mounted on a divine resonance matrix — its vibration disrupts the coherence of any force that opposes the goddess's will.",
id: "sacred_chord_matrix",
name: "Sacred Chord Matrix",
requiredMaterials: [
{ materialId: "sacred_chord", quantity: 1 },
{ materialId: "divine_resonance", quantity: 2 },
{ materialId: "choir_note", quantity: 2 },
],
zoneId: "goddess_infinite_choir",
},
// ── The Veil ─────────────────────────────────────────────────────────────
{
bonus: {
type: "essence_income",
value: 1.9,
},
description: "Veil thread woven with liminal essence — a cloak that allows the wearer to exist partially outside reality, drawing essence from both sides of the divide.",
id: "liminal_cloak",
name: "Liminal Cloak",
requiredMaterials: [
{ materialId: "veil_thread", quantity: 3 },
{ materialId: "liminal_essence", quantity: 2 },
],
zoneId: "goddess_veil",
},
{
bonus: {
type: "combat_power",
value: 1.9,
},
description: "A beyond fragment encased in liminal essence and bound with veil thread — a weapon that strikes from a direction reality does not expect and cannot easily defend against.",
id: "veil_piercer",
name: "Veil Piercer",
requiredMaterials: [
{ materialId: "beyond_fragment", quantity: 1 },
{ materialId: "liminal_essence", quantity: 3 },
{ materialId: "veil_thread", quantity: 2 },
],
zoneId: "goddess_veil",
},
// ── Divine Heart ─────────────────────────────────────────────────────────
{
bonus: {
type: "gold_income",
value: 2,
},
description: "Heart pulses suspended in divine love crystal matrix — an amplifier that broadcasts the goddess's love as a measurable economic force. Disciples work harder when they feel it.",
id: "heart_amplifier",
name: "Heart Amplifier",
requiredMaterials: [
{ materialId: "heart_pulse", quantity: 4 },
{ materialId: "divine_love_crystal", quantity: 2 },
],
zoneId: "goddess_divine_heart",
},
{
bonus: {
type: "combat_power",
value: 2,
},
description: "Heart ichor distilled with divine love crystal into an essence that transforms willingness into unstoppable force. The goddess's love, weaponised. She approves.",
id: "divine_heart_essence",
name: "Divine Heart Essence",
requiredMaterials: [
{ materialId: "heart_ichor", quantity: 1 },
{ materialId: "divine_love_crystal", quantity: 2 },
{ materialId: "heart_pulse", quantity: 2 },
],
zoneId: "goddess_divine_heart",
},
];
+396
View File
@@ -0,0 +1,396 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
import type { GoddessDisciple } from "@elysium/types";
export const defaultGoddessDisciples: Array<GoddessDisciple> = [
{
baseCost: 1,
class: "oracle",
combatPower: 1,
count: 0,
divinityPerSecond: 0,
id: "novice",
level: 1,
name: "Novice",
prayersPerSecond: 0.1,
unlocked: true,
},
{
baseCost: 8,
class: "seraph",
combatPower: 3,
count: 0,
divinityPerSecond: 0,
id: "initiate",
level: 2,
name: "Initiate",
prayersPerSecond: 0.5,
unlocked: false,
},
{
baseCost: 80,
class: "invoker",
combatPower: 8,
count: 0,
divinityPerSecond: 0.01,
id: "acolyte",
level: 3,
name: "Acolyte",
prayersPerSecond: 1.5,
unlocked: false,
},
{
baseCost: 500,
class: "templar",
combatPower: 20,
count: 0,
divinityPerSecond: 0.02,
id: "devotee",
level: 4,
name: "Devotee",
prayersPerSecond: 4,
unlocked: false,
},
{
baseCost: 3500,
class: "herald",
combatPower: 50,
count: 0,
divinityPerSecond: 0.05,
id: "adept",
level: 5,
name: "Adept",
prayersPerSecond: 10,
unlocked: false,
},
{
baseCost: 25_000,
class: "oracle",
combatPower: 120,
count: 0,
divinityPerSecond: 0.1,
id: "priest",
level: 6,
name: "Priest",
prayersPerSecond: 25,
unlocked: false,
},
{
baseCost: 175_000,
class: "seraph",
combatPower: 300,
count: 0,
divinityPerSecond: 0.2,
id: "high_priest",
level: 7,
name: "High Priest",
prayersPerSecond: 75,
unlocked: false,
},
{
baseCost: 1_200_000,
class: "invoker",
combatPower: 800,
count: 0,
divinityPerSecond: 0.5,
id: "divine_scholar",
level: 8,
name: "Divine Scholar",
prayersPerSecond: 200,
unlocked: false,
},
{
baseCost: 8_500_000,
class: "templar",
combatPower: 2000,
count: 0,
divinityPerSecond: 1,
id: "holy_champion",
level: 9,
name: "Holy Champion",
prayersPerSecond: 600,
unlocked: false,
},
{
baseCost: 60_000_000,
class: "warden",
combatPower: 6000,
count: 0,
divinityPerSecond: 3,
id: "celestial_adept",
level: 10,
name: "Celestial Adept",
prayersPerSecond: 2000,
unlocked: false,
},
{
baseCost: 285_000_000,
class: "oracle",
combatPower: 13_000,
count: 0,
divinityPerSecond: 6,
id: "seraphic_master",
level: 11,
name: "Seraphic Master",
prayersPerSecond: 4500,
unlocked: false,
},
{
baseCost: 1_350_000_000,
class: "invoker",
combatPower: 28_000,
count: 0,
divinityPerSecond: 11,
id: "divine_invoker",
level: 12,
name: "Divine Invoker",
prayersPerSecond: 9500,
unlocked: false,
},
{
baseCost: 6_400_000_000,
class: "templar",
combatPower: 60_000,
count: 0,
divinityPerSecond: 20,
id: "astral_templar",
level: 13,
name: "Astral Templar",
prayersPerSecond: 20_000,
unlocked: false,
},
{
baseCost: 30_000_000_000,
class: "herald",
combatPower: 130_000,
count: 0,
divinityPerSecond: 35,
id: "empyrean_herald",
level: 14,
name: "Empyrean Herald",
prayersPerSecond: 40_000,
unlocked: false,
},
{
baseCost: 180_000_000_000,
class: "seraph",
combatPower: 400_000,
count: 0,
divinityPerSecond: 100,
id: "primordial_herald",
level: 15,
name: "Primordial Herald",
prayersPerSecond: 120_000,
unlocked: false,
},
{
baseCost: 1_000_000_000_000,
class: "warden",
combatPower: 1_200_000,
count: 0,
divinityPerSecond: 300,
id: "eternal_divine",
level: 16,
name: "Eternal Divine",
prayersPerSecond: 400_000,
unlocked: false,
},
{
baseCost: 6_000_000_000_000,
class: "oracle",
combatPower: 3_600_000,
count: 0,
divinityPerSecond: 900,
id: "cosmic_oracle",
level: 17,
name: "Cosmic Oracle",
prayersPerSecond: 1_200_000,
unlocked: false,
},
{
baseCost: 35_000_000_000_000,
class: "seraph",
combatPower: 10_800_000,
count: 0,
divinityPerSecond: 2700,
id: "radiant_seraph",
level: 18,
name: "Radiant Seraph",
prayersPerSecond: 3_600_000,
unlocked: false,
},
{
baseCost: 210_000_000_000_000,
class: "invoker",
combatPower: 32_000_000,
count: 0,
divinityPerSecond: 8000,
id: "grand_invoker",
level: 19,
name: "Grand Invoker",
prayersPerSecond: 10_500_000,
unlocked: false,
},
{
baseCost: 1_300_000_000_000_000,
class: "templar",
combatPower: 96_000_000,
count: 0,
divinityPerSecond: 24_000,
id: "sacred_templar",
level: 20,
name: "Sacred Templar",
prayersPerSecond: 32_000_000,
unlocked: false,
},
{
baseCost: 8_000_000_000_000_000,
class: "herald",
combatPower: 290_000_000,
count: 0,
divinityPerSecond: 72_000,
id: "celestial_herald",
level: 21,
name: "Celestial Herald",
prayersPerSecond: 96_000_000,
unlocked: false,
},
{
baseCost: 50_000_000_000_000_000,
class: "warden",
combatPower: 870_000_000,
count: 0,
divinityPerSecond: 216_000,
id: "divine_warden",
level: 22,
name: "Divine Warden",
prayersPerSecond: 288_000_000,
unlocked: false,
},
{
baseCost: 300_000_000_000_000_000,
class: "oracle",
combatPower: 2_600_000_000,
count: 0,
divinityPerSecond: 650_000,
id: "supreme_oracle",
level: 23,
name: "Supreme Oracle",
prayersPerSecond: 864_000_000,
unlocked: false,
},
{
baseCost: 1_800_000_000_000_000_000,
class: "seraph",
combatPower: 7_800_000_000,
count: 0,
divinityPerSecond: 1_950_000,
id: "arch_seraph",
level: 24,
name: "Arch-Seraph",
prayersPerSecond: 2_600_000_000,
unlocked: false,
},
{
baseCost: 11_000_000_000_000_000_000,
class: "invoker",
combatPower: 23_000_000_000,
count: 0,
divinityPerSecond: 5_850_000,
id: "primordial_invoker",
level: 25,
name: "Primordial Invoker",
prayersPerSecond: 7_800_000_000,
unlocked: false,
},
{
baseCost: 70_000_000_000_000_000_000,
class: "templar",
combatPower: 70_000_000_000,
count: 0,
divinityPerSecond: 17_500_000,
id: "eternal_templar",
level: 26,
name: "Eternal Templar",
prayersPerSecond: 23_000_000_000,
unlocked: false,
},
{
baseCost: 450_000_000_000_000_000_000,
class: "herald",
combatPower: 210_000_000_000,
count: 0,
divinityPerSecond: 52_000_000,
id: "firmament_herald",
level: 27,
name: "Firmament Herald",
prayersPerSecond: 70_000_000_000,
unlocked: false,
},
{
baseCost: 2_700_000_000_000_000_000_000,
class: "warden",
combatPower: 630_000_000_000,
count: 0,
divinityPerSecond: 156_000_000,
id: "goddess_warden",
level: 28,
name: "Goddess Warden",
prayersPerSecond: 210_000_000_000,
unlocked: false,
},
{
baseCost: 16_000_000_000_000_000_000_000,
class: "oracle",
combatPower: 1_900_000_000_000,
count: 0,
divinityPerSecond: 468_000_000,
id: "transcendent_oracle",
level: 29,
name: "Transcendent Oracle",
prayersPerSecond: 630_000_000_000,
unlocked: false,
},
{
baseCost: 100_000_000_000_000_000_000_000,
class: "seraph",
combatPower: 5_700_000_000_000,
count: 0,
divinityPerSecond: 1_400_000_000,
id: "exalted_seraph",
level: 30,
name: "Exalted Seraph",
prayersPerSecond: 1_900_000_000_000,
unlocked: false,
},
{
baseCost: 650_000_000_000_000_000_000_000,
class: "invoker",
combatPower: 17_000_000_000_000,
count: 0,
divinityPerSecond: 4_200_000_000,
id: "infinite_invoker",
level: 31,
name: "Infinite Invoker",
prayersPerSecond: 5_700_000_000_000,
unlocked: false,
},
{
baseCost: 4_000_000_000_000_000_000_000_000,
class: "templar",
combatPower: 51_000_000_000_000,
count: 0,
divinityPerSecond: 12_600_000_000,
id: "divine_heart_disciple",
level: 32,
name: "Divine Heart",
prayersPerSecond: 17_000_000_000_000,
unlocked: false,
},
];
@@ -0,0 +1,136 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { EnlightenmentUpgrade } from "@elysium/types";
export const defaultEnlightenmentUpgrades: Array<EnlightenmentUpgrade> = [
// ── Prayer income ────────────────────────────────────────────────────────
{
category: "prayers",
cost: 2,
description: "The memory of past consecrations echoes through your order, amplifying prayer income by 25%.",
id: "stardust_prayers_1",
multiplier: 1.25,
name: "Celestial Echo I",
},
{
category: "prayers",
cost: 4,
description: "Transcendent experience resonates through every disciple in the order, boosting prayers by 50%.",
id: "stardust_prayers_2",
multiplier: 1.5,
name: "Celestial Echo II",
},
{
category: "prayers",
cost: 8,
description: "The harmony of enlightened cycles surges through your order, doubling all prayer income.",
id: "stardust_prayers_3",
multiplier: 2,
name: "Celestial Echo III",
},
{
category: "prayers",
cost: 16,
description: "Divine overflow from enlightenment floods the order, tripling all prayer income.",
id: "stardust_prayers_4",
multiplier: 3,
name: "Celestial Echo IV",
},
{
category: "prayers",
cost: 32,
description: "The infinite chorus of every consecration you have completed multiplies prayer income fivefold.",
id: "stardust_prayers_5",
multiplier: 5,
name: "Celestial Echo V",
},
// ── Combat ───────────────────────────────────────────────────────────────
{
category: "combat",
cost: 2,
description: "Memories of every divine battle harden your disciples, increasing combat power by 25%.",
id: "stardust_combat_1",
multiplier: 1.25,
name: "Battle Memory I",
},
{
category: "combat",
cost: 6,
description: "Veterans of enlightenment know how to fight with transcendent precision, boosting combat power by 50%.",
id: "stardust_combat_2",
multiplier: 1.5,
name: "Battle Memory II",
},
{
category: "combat",
cost: 12,
description: "Your disciples carry the strength of every enlightened cycle, doubling all combat power.",
id: "stardust_combat_3",
multiplier: 2,
name: "Battle Memory III",
},
// ── Consecration threshold ───────────────────────────────────────────────
{
category: "consecration_threshold",
cost: 3,
description: "Enlightened experience shortens the road to consecration — threshold reduced by 10%.",
id: "stardust_consecration_threshold_1",
multiplier: 0.9,
name: "Accelerated Devotion I",
},
{
category: "consecration_threshold",
cost: 9,
description: "Mastery of the enlightenment cycle trims the consecration requirement by a further 15%.",
id: "stardust_consecration_threshold_2",
multiplier: 0.85,
name: "Accelerated Devotion II",
},
{
category: "consecration_threshold",
cost: 25,
description: "The path to consecration is now second nature — threshold reduced by 20% once more.",
id: "stardust_consecration_threshold_3",
multiplier: 0.8,
name: "Accelerated Devotion III",
},
// ── Consecration divinity ────────────────────────────────────────────────
{
category: "consecration_divinity",
cost: 5,
description: "Each cycle of enlightenment deepens the consecration harvest, increasing divinity yield by 25%.",
id: "stardust_consecration_divinity_1",
multiplier: 1.25,
name: "Luminous Harvest I",
},
{
category: "consecration_divinity",
cost: 20,
description: "The light of enlightenment pours into every consecration, boosting divinity yield by 50%.",
id: "stardust_consecration_divinity_2",
multiplier: 1.5,
name: "Luminous Harvest II",
},
{
category: "consecration_divinity",
cost: 60,
description: "Perfection of the cycle doubles the divinity drawn from every act of consecration.",
id: "stardust_consecration_divinity_3",
multiplier: 2,
name: "Luminous Harvest III",
},
// ── Stardust meta ────────────────────────────────────────────────────────
{
category: "stardust_meta",
cost: 15,
description: "Your enlightenment resonates deeper, amplifying future stardust yields by 25%.",
id: "stardust_meta_1",
multiplier: 1.25,
name: "Resonant Enlightenment",
},
];
+563
View File
@@ -0,0 +1,563 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { GoddessEquipment } from "@elysium/types";
export const defaultGoddessEquipment: Array<GoddessEquipment> = [
// ── Relics — Common ───────────────────────────────────────────────────────
{
bonus: { prayersMultiplier: 1.1 },
cost: { divinity: 0, prayers: 300, stardust: 0 },
description: "A weathered tome filled with the first prayers ever offered. The ink has faded, but the faith remains.",
equipped: false,
id: "divine_tome",
name: "Divine Tome",
owned: false,
rarity: "common",
type: "relic",
},
{
bonus: { prayersMultiplier: 1.1 },
cost: { divinity: 0, prayers: 350, stardust: 0 },
description: "A scroll of thin parchment bearing a single whispered blessing. Small, but sincere.",
equipped: false,
id: "prayer_scroll",
name: "Prayer Scroll",
owned: false,
rarity: "common",
type: "relic",
},
{
bonus: { combatMultiplier: 1.1 },
cost: { divinity: 0, prayers: 400, stardust: 0 },
description: "A slender wand carved from a branch blessed by a passing spirit. Humble in form, genuine in purpose.",
equipped: false,
id: "blessing_wand",
name: "Blessing Wand",
owned: false,
rarity: "common",
type: "relic",
},
// ── Relics — Rare ─────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.1 },
description: "A staff carved from petrified sanctuary wood. Its grain holds echoes of a thousand blessings.",
equipped: false,
id: "sacred_staff",
name: "Sacred Staff",
owned: false,
rarity: "rare",
type: "relic",
},
{
bonus: { prayersMultiplier: 1.3 },
description: "A crystalline lens ground from frozen oracle tears. Those who look through it see truths they cannot unhear.",
equipped: false,
id: "oracle_lens",
name: "Oracle Lens",
owned: false,
rarity: "rare",
type: "relic",
},
{
bonus: { combatMultiplier: 1.1, prayersMultiplier: 1.2 },
description: "A quill shed from the wing of a celestial herald. Whatever it writes becomes spoken prophecy.",
equipped: false,
id: "celestial_quill",
name: "Celestial Quill",
owned: false,
rarity: "rare",
type: "relic",
},
{
bonus: { prayersMultiplier: 1.25 },
description: "A smooth orb of blessed amber that hums with the residual faith of an entire sanctum's congregation.",
equipped: false,
id: "sanctum_focus",
name: "Sanctum Focus",
owned: false,
rarity: "rare",
type: "relic",
},
{
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.1 },
description: "A sceptre of star-cast silver that channels divine will through the constellations it was forged beneath.",
equipped: false,
id: "astral_sceptre",
name: "Astral Sceptre",
owned: false,
rarity: "rare",
type: "relic",
},
{
bonus: { combatMultiplier: 1.35 },
description: "A rod drawn from the highest reaches of the empyrean vault. Light bends around it as though in reverence.",
equipped: false,
id: "empyrean_rod",
name: "Empyrean Rod",
owned: false,
rarity: "rare",
type: "relic",
},
// ── Relics — Epic ─────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.5, prayersMultiplier: 1.25 },
description: "A blade borne by the highest order of seraphim. Its edge is said to cut through even divine illusion.",
equipped: false,
id: "seraph_blade",
name: "Seraph Blade",
owned: false,
rarity: "epic",
type: "relic",
},
{
bonus: { combatMultiplier: 1.4, prayersMultiplier: 1.4 },
description: "A sceptre inscribed with the Goddess's own name in a script no mortal tongue can speak aloud.",
equipped: false,
id: "divine_sceptre",
name: "Divine Sceptre",
owned: false,
rarity: "epic",
type: "relic",
},
{
bonus: { combatMultiplier: 1.6 },
description: "A lance of heavenly ore that strikes with the force of a falling star. No shield has ever stopped it twice.",
equipped: false,
id: "heavenly_lance",
name: "Heavenly Lance",
owned: false,
rarity: "epic",
type: "relic",
},
{
bonus: { combatMultiplier: 1.5 },
description: "A hammer that once shaped the divine armaments of the celestial forge. Its weight carries the memory of creation.",
equipped: false,
id: "forge_hammer",
name: "Forge Hammer",
owned: false,
rarity: "epic",
type: "relic",
},
{
bonus: { prayersMultiplier: 1.5 },
description: "A staff of oracle-bone and wrapped starlight. Prayers spoken through it travel to the Goddess without delay.",
equipped: false,
id: "oracle_staff",
name: "Oracle Staff",
owned: false,
rarity: "epic",
type: "relic",
},
{
bonus: { combatMultiplier: 1.45, prayersMultiplier: 1.3 },
description: "A twin blade to the Seraph Blade, wielded in the off-hand of a champion who ascended beyond mortality.",
equipped: false,
id: "seraph_sword",
name: "Seraph Sword",
owned: false,
rarity: "epic",
type: "relic",
},
// ── Relics — Legendary ────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.75, prayersMultiplier: 1.75 },
description: "A rod drawn from the highest layer of the firmament, where creation and void press against each other eternally.",
equipped: false,
id: "firmament_rod",
name: "Firmament Rod",
owned: false,
rarity: "legendary",
type: "relic",
},
{
bonus: { combatMultiplier: 2 },
description: "The personal weapon of the Goddess herself, wielded once at the dawn of the world and never since. Its tip still smells of stardust.",
equipped: false,
id: "goddess_spear",
name: "Goddess's Spear",
owned: false,
rarity: "legendary",
type: "relic",
},
{
bonus: { combatMultiplier: 1.8, prayersMultiplier: 2 },
description: "A relic formed from a single heartbeat of the Goddess, crystallised at the moment she first felt devotion returned.",
equipped: false,
id: "divine_heart_relic",
name: "Divine Heart Relic",
owned: false,
rarity: "legendary",
type: "relic",
},
// ── Vestments — Common ────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.1 },
cost: { divinity: 0, prayers: 300, stardust: 0 },
description: "Simple robes given to those who have just found their faith. The stitching is uneven, but the intention is pure.",
equipped: false,
id: "novice_vestments",
name: "Novice Vestments",
owned: false,
rarity: "common",
type: "vestment",
},
{
bonus: { prayersMultiplier: 1.1 },
cost: { divinity: 0, prayers: 350, stardust: 0 },
description: "The standard garb of an initiate entering the divine order. Clean, modest, and faintly perfumed with incense.",
equipped: false,
id: "initiate_robes",
name: "Initiate Robes",
owned: false,
rarity: "common",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.1 },
cost: { divinity: 0, prayers: 450, stardust: 0 },
description: "Practical garments worn by acolytes who serve the temples. The fabric repels both dust and doubt.",
equipped: false,
id: "acolyte_garb",
name: "Acolyte Garb",
owned: false,
rarity: "common",
type: "vestment",
},
// ── Vestments — Rare ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.1, prayersMultiplier: 1.2 },
description: "Robes woven through hours of silent prayer. Each thread carries a whispered blessing from the hands that made it.",
equipped: false,
id: "prayer_robes",
name: "Prayer Robes",
owned: false,
rarity: "rare",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.15 },
description: "The ceremonial dress of a sanctum's most decorated servant. Heavy with ornament and heavier with meaning.",
equipped: false,
id: "sanctum_regalia",
name: "Sanctum Regalia",
owned: false,
rarity: "rare",
type: "vestment",
},
{
bonus: { prayersMultiplier: 1.3 },
description: "A flowing garment sewn from fibres of celestial cloud. It never wrinkles and always catches the light perfectly.",
equipped: false,
id: "celestial_wrap",
name: "Celestial Wrap",
owned: false,
rarity: "rare",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.25, prayersMultiplier: 1.1 },
description: "A cloak dyed in the hue of the void between stars. It absorbs damage and whispers warnings to its wearer.",
equipped: false,
id: "astral_cloak",
name: "Astral Cloak",
owned: false,
rarity: "rare",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.35 },
description: "A cowl spun from the highest threads of empyrean silk. It protects the mind as much as it protects the head.",
equipped: false,
id: "empyrean_cowl",
name: "Empyrean Cowl",
owned: false,
rarity: "rare",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.2 },
description: "A mantle woven from sacred grove leaves that never wither. The forest's blessing persists in every fibre.",
equipped: false,
id: "grove_mantle",
name: "Grove Mantle",
owned: false,
rarity: "rare",
type: "vestment",
},
// ── Vestments — Epic ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.5, prayersMultiplier: 1.3 },
description: "A mantle torn from an astral projection and reforged into armour. It exists in two planes simultaneously.",
equipped: false,
id: "astral_mantle",
name: "Astral Mantle",
owned: false,
rarity: "epic",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.6 },
description: "Plate armour hammered from condensed empyrean light. It weighs nothing and deflects everything.",
equipped: false,
id: "empyrean_armour",
name: "Empyrean Armour",
owned: false,
rarity: "epic",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.45, prayersMultiplier: 1.4 },
description: "Armour that radiates a soft divine glow. Enemies flinch from its light before the blow even lands.",
equipped: false,
id: "luminous_plate",
name: "Luminous Plate",
owned: false,
rarity: "epic",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.55 },
description: "Vestments quenched in the divine forge, each layer fused by celestial fire until no ordinary blade can part them.",
equipped: false,
id: "forge_vestments",
name: "Forge Vestments",
owned: false,
rarity: "epic",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.35, prayersMultiplier: 1.5 },
description: "Robes worn by the oracle who first heard the Goddess speak. The fabric remembers every prophecy ever uttered within it.",
equipped: false,
id: "oracle_robes",
name: "Oracle Robes",
owned: false,
rarity: "epic",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.4, prayersMultiplier: 1.45 },
description: "Armour that marks its wearer as an instrument of divine will. Enemies see the Goddess reflected in its surface.",
equipped: false,
id: "divine_regalia",
name: "Divine Regalia",
owned: false,
rarity: "epic",
type: "vestment",
},
// ── Vestments — Legendary ─────────────────────────────────────────────────
{
bonus: { combatMultiplier: 1.75, prayersMultiplier: 1.75 },
description: "Vestments that persist beyond the death of the wearer. The cloth refuses to decay. So, eventually, does the soul.",
equipped: false,
id: "eternal_vestments",
name: "Eternal Vestments",
owned: false,
rarity: "legendary",
type: "vestment",
},
{
bonus: { combatMultiplier: 2 },
description: "Armour drawn from the firmament itself, where the boundary between existence and the void is at its thinnest.",
equipped: false,
id: "firmament_armour",
name: "Firmament Armour",
owned: false,
rarity: "legendary",
type: "vestment",
},
{
bonus: { combatMultiplier: 1.8, prayersMultiplier: 2 },
description: "The Goddess's own ceremonial robes, left behind as a covenant. They still carry the warmth of divinity.",
equipped: false,
id: "goddess_raiment",
name: "Goddess's Raiment",
owned: false,
rarity: "legendary",
type: "vestment",
},
// ── Sigils — Common ───────────────────────────────────────────────────────
{
bonus: { divinityMultiplier: 1.1 },
cost: { divinity: 0, prayers: 300, stardust: 0 },
description: "A small clay token stamped with a sunburst. Farmers press it into new soil to invite the Goddess's blessing.",
equipped: false,
id: "faith_token",
name: "Faith Token",
owned: false,
rarity: "common",
type: "sigil",
},
{
bonus: { prayersMultiplier: 1.1 },
cost: { divinity: 0, prayers: 350, stardust: 0 },
description: "A single bead worn smooth by generations of faithful fingers. Every prayer whispered over it still clings to the surface.",
equipped: false,
id: "prayer_bead",
name: "Prayer Bead",
owned: false,
rarity: "common",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.1 },
cost: { divinity: 0, prayers: 400, stardust: 0 },
description: "A charm carved from sanctified bone. It draws small kindnesses towards its wearer, like gravity for good fortune.",
equipped: false,
id: "blessing_charm",
name: "Blessing Charm",
owned: false,
rarity: "common",
type: "sigil",
},
// ── Sigils — Rare ─────────────────────────────────────────────────────────
{
bonus: { divinityMultiplier: 1.2, prayersMultiplier: 1.1 },
description: "A seal pressed in divine wax that has never cooled. The blessing it carries is renewed with every sunrise.",
equipped: false,
id: "blessing_seal",
name: "Blessing Seal",
owned: false,
rarity: "rare",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.3 },
description: "A sigil shaped into an open eye. Those who bear it find their prayers answered with unusual precision.",
equipped: false,
id: "oracle_sigil",
name: "Oracle Sigil",
owned: false,
rarity: "rare",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.1, prayersMultiplier: 1.2 },
description: "A token bearing the mark of the highest seraphim. It grants the holder passage through divine barriers.",
equipped: false,
id: "seraph_token",
name: "Seraph Token",
owned: false,
rarity: "rare",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.25 },
description: "A pendant woven from living vines that never wither. It carries the grove's unbroken memory of the divine.",
equipped: false,
id: "grove_pendant",
name: "Grove Pendant",
owned: false,
rarity: "rare",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.2, prayersMultiplier: 1.2 },
description: "A mark drawn in light rather than ink. It pulses faintly with the rhythm of divine order.",
equipped: false,
id: "luminous_mark",
name: "Luminous Mark",
owned: false,
rarity: "rare",
type: "sigil",
},
// ── Sigils — Epic ─────────────────────────────────────────────────────────
{
bonus: { divinityMultiplier: 1.5, prayersMultiplier: 1.3 },
description: "A mark burned into existence by a seraph's own finger. It does not fade because it is not merely physical.",
equipped: false,
id: "seraph_mark",
name: "Seraph Mark",
owned: false,
rarity: "epic",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.5, prayersMultiplier: 1.4 },
description: "An emblem bearing the full weight of divine authority. Those who carry it act as the Goddess's declared instrument.",
equipped: false,
id: "divine_emblem",
name: "Divine Emblem",
owned: false,
rarity: "epic",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.6 },
description: "A seal drawn from the highest empyrean archive. It radiates a hum that only the faithful can hear.",
equipped: false,
id: "empyrean_seal",
name: "Empyrean Seal",
owned: false,
rarity: "epic",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.4, prayersMultiplier: 1.4 },
description: "A brand seared in the divine forge. It marks the bearer as something that has passed through fire and remained.",
equipped: false,
id: "forge_brand",
name: "Forge Brand",
owned: false,
rarity: "epic",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.45, prayersMultiplier: 1.35 },
description: "A sigil drawn from the archive of every prayer ever recorded. It remembers everything that has ever been asked.",
equipped: false,
id: "archive_sigil",
name: "Archive Sigil",
owned: false,
rarity: "epic",
type: "sigil",
},
{
bonus: { divinityMultiplier: 1.55, prayersMultiplier: 1.25 },
description: "A celestial mark that resonates with the movement of heavenly bodies. It grows stronger under open sky.",
equipped: false,
id: "celestial_mark",
name: "Celestial Mark",
owned: false,
rarity: "epic",
type: "sigil",
},
// ── Sigils — Legendary ────────────────────────────────────────────────────
{
bonus: { divinityMultiplier: 2, prayersMultiplier: 1.75 },
description: "A sigil that has existed since before the first prayer was spoken. It does not grant eternity — it is eternity.",
equipped: false,
id: "eternity_sigil",
name: "Eternity Sigil",
owned: false,
rarity: "legendary",
type: "sigil",
},
{
bonus: { divinityMultiplier: 2.5 },
description: "A seal drawn from the innermost layer of the firmament, where time folds back on itself and prayers echo forever.",
equipped: false,
id: "firmament_seal",
name: "Firmament Seal",
owned: false,
rarity: "legendary",
type: "sigil",
},
{
bonus: { divinityMultiplier: 2, prayersMultiplier: 2 },
description: "A sigil shaped like a heartbeat, crystallised at the precise moment the Goddess chose to love the world back.",
equipped: false,
id: "divine_heart_sigil",
name: "Divine Heart Sigil",
owned: false,
rarity: "legendary",
type: "sigil",
},
];
+119
View File
@@ -0,0 +1,119 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { GoddessEquipmentSet } from "@elysium/types";
export const defaultGoddessEquipmentSets: Array<GoddessEquipmentSet> = [
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { prayersMultiplier: 1.15 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { divinityMultiplier: 1.1 },
},
description: "The simplest sacred things gathered in one place — a tome, a robe, a token. Together they are more than their stitching.",
id: "gardens_blessing",
name: "Garden's Blessing",
pieces: [ "divine_tome", "novice_vestments", "faith_token", "prayer_scroll", "initiate_robes" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { prayersMultiplier: 1.25 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { combatMultiplier: 1.2 },
},
description: "The instruments of the Crystal Sanctum's greatest scholars. Knowledge and combat are not opposites — they are complements.",
id: "sanctum_scholar",
name: "Sanctum Scholar",
pieces: [ "oracle_lens", "sanctum_regalia", "oracle_sigil", "celestial_quill", "sanctum_focus" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { combatMultiplier: 1.3 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { prayersMultiplier: 1.2 },
},
description: "The arms and marks of the seraphic order. Those who carry this set fight not for glory but because the Goddess asked them to.",
id: "seraphic_arsenal",
name: "Seraphic Arsenal",
pieces: [ "seraph_blade", "prayer_robes", "seraph_mark", "seraph_token", "seraph_sword" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { combatMultiplier: 1.35 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { divinityMultiplier: 1.25 },
},
description: "The armaments of those who hold the citadel against the void. Their faith is a wall and their weapons are its gate.",
id: "citadel_defender",
name: "Citadel Defender",
pieces: [ "sacred_staff", "empyrean_cowl", "empyrean_seal", "astral_sceptre", "empyrean_rod" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { prayersMultiplier: 1.3 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { divinityMultiplier: 1.3 },
},
description: "Relics of the astral void and the blessed seals born from it. To invoke the divine, one must first become a vessel worthy of it.",
id: "divine_invoker",
name: "Divine Invoker",
pieces: [ "astral_mantle", "blessing_seal", "astral_cloak", "empyrean_armour", "luminous_mark" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { combatMultiplier: 1.4 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { prayersMultiplier: 1.35 },
},
description: "The master-crafter's complete regalia — hammer, vestments, and brand. Every piece was forged in the same divine fire that shaped the celestial host.",
id: "forge_masters_regalia",
name: "Forge-Master's Regalia",
pieces: [ "forge_hammer", "forge_vestments", "forge_brand", "oracle_staff", "oracle_robes" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { combatMultiplier: 1.5 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { prayersMultiplier: 1.5 },
},
description: "Armaments drawn from the firmament's edge, where the sky becomes something else entirely. Those chosen to wear them rarely choose to return.",
id: "firmaments_chosen",
name: "Firmament's Chosen",
pieces: [ "firmament_rod", "firmament_armour", "firmament_seal", "empyrean_armour", "luminous_plate" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { divinityMultiplier: 1.4 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { prayersMultiplier: 1.4 },
},
description: "The oracle's complete truth — sceptre, raiment, emblem, and the archives of every word ever spoken in the Goddess's name.",
id: "oracles_truth",
name: "Oracle's Truth",
pieces: [ "divine_sceptre", "divine_regalia", "divine_emblem", "archive_sigil", "celestial_mark" ],
},
{
bonuses: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
2: { prayersMultiplier: 2 },
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
3: { divinityMultiplier: 2 },
},
description: "The Goddess's own heart, spear, raiment, and sigil, gathered together at last. To wear this set is to carry what she left behind — and to feel the weight of being chosen.",
id: "heart_of_the_goddess",
name: "Heart of the Goddess",
pieces: [ "divine_heart_relic", "goddess_raiment", "divine_heart_sigil", "goddess_spear", "eternal_vestments" ],
},
];
File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { Material } from "@elysium/types";
export const defaultGoddessMaterials: Array<Material> = [
// ── Celestial Garden ─────────────────────────────────────────────────────
{
description: "Petals from flowers that have never known anything but divine light. They crumble if touched by anything unworthy.",
id: "divine_petal",
name: "Divine Petal",
rarity: "common",
zoneId: "goddess_celestial_garden",
},
{
description: "Prayer energy that has crystallised over centuries of devotion. Each one holds a fragment of someone's deepest hope.",
id: "prayer_crystal",
name: "Prayer Crystal",
rarity: "common",
zoneId: "goddess_celestial_garden",
},
{
description: "Dust that falls from the celestial dome above — each mote a fragment of a star that finished its purpose and dissolved.",
id: "celestial_dust",
name: "Celestial Dust",
rarity: "uncommon",
zoneId: "goddess_celestial_garden",
},
// ── Crystal Sanctum ──────────────────────────────────────────────────────
{
description: "Shards broken from the sanctum walls during divine resonance events. They hum faintly with stored knowledge.",
id: "sanctum_shard",
name: "Sanctum Shard",
rarity: "common",
zoneId: "goddess_crystal_sanctum",
},
{
description: "Ink distilled from divine light and used to inscribe the sanctum's most sacred texts. It cannot write falsehoods.",
id: "holy_ink",
name: "Holy Ink",
rarity: "uncommon",
zoneId: "goddess_crystal_sanctum",
},
{
description: "A fragment of an oracle's primary lens, shattered during a vision of catastrophic clarity. The vision was worth it.",
id: "oracle_lens_fragment",
name: "Oracle Lens Fragment",
rarity: "rare",
zoneId: "goddess_crystal_sanctum",
},
// ── Astral Cathedral ─────────────────────────────────────────────────────
{
description: "A feather shed by a seraph during their first ascension. They shed exactly one. This is the rarest thing most people will ever hold.",
id: "seraph_feather",
name: "Seraph Feather",
rarity: "uncommon",
zoneId: "goddess_astral_cathedral",
},
{
description: "The concentrated resonance of the celestial choir — bottled by scholars who noticed that it had physical properties.",
id: "choir_essence",
name: "Choir Essence",
rarity: "uncommon",
zoneId: "goddess_astral_cathedral",
},
{
description: "Material formed where the cathedral's astral structure meets the void. Transparent, harder than diamond, warmer than sunlight.",
id: "astral_glass",
name: "Astral Glass",
rarity: "rare",
zoneId: "goddess_astral_cathedral",
},
// ── Empyrean Citadel ─────────────────────────────────────────────────────
{
description: "Ore mined from the citadel's deepest foundations — dense with divine potential but raw and unrefined.",
id: "empyrean_ore",
name: "Empyrean Ore",
rarity: "uncommon",
zoneId: "goddess_empyrean_citadel",
},
{
description: "Empyrean ore refined in the citadel's divine furnaces. The process requires both technical mastery and genuine faith.",
id: "divine_alloy",
name: "Divine Alloy",
rarity: "rare",
zoneId: "goddess_empyrean_citadel",
},
{
description: "A medal awarded only to champions of the citadel's trials. Fewer than a hundred exist. Each one has a name engraved on the back.",
id: "celestial_medal",
name: "Celestial Medal",
rarity: "rare",
zoneId: "goddess_empyrean_citadel",
},
// ── Primordial Springs ───────────────────────────────────────────────────
{
description: "Water drawn directly from the springs of creation. It tastes of nothing. It heals everything. Handle carefully.",
id: "creation_water",
name: "Creation Water",
rarity: "uncommon",
zoneId: "goddess_primordial_springs",
},
{
description: "The raw essence of creation — the stuff from which everything is made before it decides what to become.",
id: "primordial_essence",
name: "Primordial Essence",
rarity: "rare",
zoneId: "goddess_primordial_springs",
},
{
description: "A crystal formed spontaneously when creation energy reaches critical density. Each one is unique and has never existed before.",
id: "genesis_crystal",
name: "Genesis Crystal",
rarity: "rare",
zoneId: "goddess_primordial_springs",
},
// ── Eternal Firmament ────────────────────────────────────────────────────
{
description: "Stone from the eternal firmament itself — impossibly dense, impossibly enduring. It does not weather. It does not age.",
id: "firmament_stone",
name: "Firmament Stone",
rarity: "rare",
zoneId: "goddess_eternal_firmament",
},
{
description: "A shard of divine light that has solidified — the kind of light that exists before it is observed, before it is named.",
id: "divine_light_shard",
name: "Divine Light Shard",
rarity: "rare",
zoneId: "goddess_eternal_firmament",
},
{
description: "A fragment broken from eternity itself during a moment of divine turbulence. It is still vibrating. It will never stop.",
id: "eternity_fragment",
name: "Eternity Fragment",
rarity: "rare",
zoneId: "goddess_eternal_firmament",
},
// ── Sacred Grove ─────────────────────────────────────────────────────────
{
description: "Resin weeping from the sacred grove's eldest trees — each drop takes decades to form and carries the memory of every prayer offered beneath its branches.",
id: "grove_resin",
name: "Grove Resin",
rarity: "common",
zoneId: "goddess_sacred_grove",
},
{
description: "A leaf that has absorbed so much divine light it has become semi-translucent, like stained glass grown naturally from a living tree.",
id: "luminous_leaf",
name: "Luminous Leaf",
rarity: "uncommon",
zoneId: "goddess_sacred_grove",
},
{
description: "Bark shed from the grove's most ancient tree — said to be the first thing the goddess ever touched. No axe can cut it. No fire can burn it.",
id: "sacred_heartwood",
name: "Sacred Heartwood",
rarity: "rare",
zoneId: "goddess_sacred_grove",
},
// ── Luminous Expanse ─────────────────────────────────────────────────────
{
description: "The ambient radiance of the luminous expanse, captured in small crystalline vessels before it dissipates. Warm to the touch always.",
id: "captured_radiance",
name: "Captured Radiance",
rarity: "common",
zoneId: "goddess_luminous_expanse",
},
{
description: "Where radiance pools deep enough, it begins to behave like water. This is a vial of that impossible substance.",
id: "radiance_pool",
name: "Radiance Pool",
rarity: "uncommon",
zoneId: "goddess_luminous_expanse",
},
{
description: "A perfect sphere of compressed luminous energy — formed only at the expanse's absolute centre, where the light meets itself coming back.",
id: "light_core",
name: "Light Core",
rarity: "rare",
zoneId: "goddess_luminous_expanse",
},
// ── Heavenly Forge ───────────────────────────────────────────────────────
{
description: "Scale from a celestial creature shed near the forge — tempered by proximity to divine fire into something harder than most metals.",
id: "forge_scale",
name: "Forge Scale",
rarity: "uncommon",
zoneId: "goddess_heavenly_forge",
},
{
description: "The slag produced when divine alloy is refined to its purest form. Useless for most things. Priceless for the right ones.",
id: "divine_slag",
name: "Divine Slag",
rarity: "uncommon",
zoneId: "goddess_heavenly_forge",
},
{
description: "A gem formed in the forge's hottest chamber — absorbs heat and releases it as blessing energy over years. Handle with tongs.",
id: "forge_gem",
name: "Forge Gem",
rarity: "rare",
zoneId: "goddess_heavenly_forge",
},
// ── Oracle Sanctum ───────────────────────────────────────────────────────
{
description: "The residue left behind when an oracle's vision ends — collected from the floor of the viewing chamber before it evaporates.",
id: "vision_residue",
name: "Vision Residue",
rarity: "common",
zoneId: "goddess_oracle_sanctum",
},
{
description: "Crystals that form in the minds of oracles during particularly intense visions and are expelled as small shards afterward.",
id: "prophecy_crystal",
name: "Prophecy Crystal",
rarity: "uncommon",
zoneId: "goddess_oracle_sanctum",
},
{
description: "A shard of pure foresight — carved from the moment between a prophecy being spoken and it being understood. Extremely dangerous to hold for long.",
id: "fate_shard",
name: "Fate Shard",
rarity: "rare",
zoneId: "goddess_oracle_sanctum",
},
// ── Seraph's Nest ────────────────────────────────────────────────────────
{
description: "Down from the innermost layer of a seraph's plumage — softer than anything natural, warm as sunlight, impossible to soil.",
id: "seraph_down",
name: "Seraph Down",
rarity: "common",
zoneId: "goddess_seraphs_nest",
},
{
description: "A primary feather from a seraph's wing — longer than a person is tall, capable of carrying aloft far more than its size suggests.",
id: "seraph_primary",
name: "Seraph Primary",
rarity: "uncommon",
zoneId: "goddess_seraphs_nest",
},
{
description: "The hollow quill of a fully ascended seraph — said to channel divine will as faithfully as any sacred instrument ever made.",
id: "ascended_quill",
name: "Ascended Quill",
rarity: "rare",
zoneId: "goddess_seraphs_nest",
},
// ── Divine Archive ───────────────────────────────────────────────────────
{
description: "Vellum produced from materials that do not exist in the mortal world — can hold text that cannot be written on ordinary parchment.",
id: "celestial_vellum",
name: "Celestial Vellum",
rarity: "common",
zoneId: "goddess_divine_archive",
},
{
description: "A stamp used to seal the archive's most important documents — its mark cannot be forged and cannot be removed.",
id: "archive_seal",
name: "Archive Seal",
rarity: "uncommon",
zoneId: "goddess_divine_archive",
},
{
description: "A codex page that has absorbed so much divine knowledge it has become semi-sentient. It resists being filed incorrectly.",
id: "living_codex_page",
name: "Living Codex Page",
rarity: "rare",
zoneId: "goddess_divine_archive",
},
// ── Consecrated Depths ───────────────────────────────────────────────────
{
description: "Stone from the deepest consecrated chambers — blessed so thoroughly by generations of ritual that it radiates faint warmth in complete darkness.",
id: "consecrated_stone",
name: "Consecrated Stone",
rarity: "uncommon",
zoneId: "goddess_consecrated_depths",
},
{
description: "Water from the depths' sacred underground springs — it has been blessed so many times that blessing it again produces light.",
id: "depth_blessing",
name: "Depth Blessing",
rarity: "uncommon",
zoneId: "goddess_consecrated_depths",
},
{
description: "A gem found only at the absolute lowest point of the consecrated depths — formed from minerals and divine energy in equal parts.",
id: "abyssal_gem",
name: "Abyssal Gem",
rarity: "rare",
zoneId: "goddess_consecrated_depths",
},
// ── Astral Confluence ────────────────────────────────────────────────────
{
description: "A shard of ley-material harvested where two astral streams cross — vibrates at two frequencies simultaneously and cannot decide which to settle on.",
id: "confluence_shard",
name: "Confluence Shard",
rarity: "uncommon",
zoneId: "goddess_astral_confluence",
},
{
description: "The harmonic tone produced when multiple astral streams converge — bottled by scholars with sensitive enough ears to find it before it propagated away.",
id: "astral_harmonic",
name: "Astral Harmonic",
rarity: "uncommon",
zoneId: "goddess_astral_confluence",
},
{
description: "A knot of astral energy so dense it has become material — formed only at confluence points of seven or more streams. Profoundly stable.",
id: "convergence_node",
name: "Convergence Node",
rarity: "rare",
zoneId: "goddess_astral_confluence",
},
// ── Celestial Throne ─────────────────────────────────────────────────────
{
description: "Gold leaf beaten so thin it is translucent — used to gild the throne's ceremonial surfaces and shed during every royal audience.",
id: "throne_gold_leaf",
name: "Throne Gold Leaf",
rarity: "uncommon",
zoneId: "goddess_celestial_throne",
},
{
description: "A gem that fell from the throne's armrest during a momentous divine decision. It carries the weight of that decision.",
id: "sovereignty_gem",
name: "Sovereignty Gem",
rarity: "rare",
zoneId: "goddess_celestial_throne",
},
{
description: "A fragment of the divine crown — shed when the goddess channels her most absolute authority. Still crackles with that authority.",
id: "crown_fragment",
name: "Crown Fragment",
rarity: "rare",
zoneId: "goddess_celestial_throne",
},
// ── Infinite Choir ───────────────────────────────────────────────────────
{
description: "A note from the infinite choir crystallised mid-air — visible proof that sound, given enough devotion, can become matter.",
id: "choir_note",
name: "Choir Note",
rarity: "uncommon",
zoneId: "goddess_infinite_choir",
},
{
description: "The resonant frequency of the infinite choir, captured in a tuning fork made of condensed praise. Struck, it harmonises everything nearby.",
id: "divine_resonance",
name: "Divine Resonance",
rarity: "rare",
zoneId: "goddess_infinite_choir",
},
{
description: "The chord that underlies all sacred music — crystallised in a moment of perfect harmony that has not occurred before or since.",
id: "sacred_chord",
name: "Sacred Chord",
rarity: "rare",
zoneId: "goddess_infinite_choir",
},
// ── The Veil ─────────────────────────────────────────────────────────────
{
description: "A thread of the veil itself — taken from where it has worn thinnest. Still partially transparent. Still partially something else.",
id: "veil_thread",
name: "Veil Thread",
rarity: "uncommon",
zoneId: "goddess_veil",
},
{
description: "The liminal substance that exists only at the veil's boundary — neither fully divine nor fully void, but something genuinely new.",
id: "liminal_essence",
name: "Liminal Essence",
rarity: "rare",
zoneId: "goddess_veil",
},
{
description: "A fragment of what lies beyond the veil — contained only by the veil-thread it's wrapped in. Looking at it directly is inadvisable.",
id: "beyond_fragment",
name: "Beyond Fragment",
rarity: "rare",
zoneId: "goddess_veil",
},
// ── Divine Heart ─────────────────────────────────────────────────────────
{
description: "A pulse of the divine heart made tangible — each one a single beat, still warm, still rhythmic, still alive with purpose.",
id: "heart_pulse",
name: "Heart Pulse",
rarity: "uncommon",
zoneId: "goddess_divine_heart",
},
{
description: "The pure love of the divine heart, distilled into crystalline form — the most powerful healing agent in existence and the most dangerous to waste.",
id: "divine_love_crystal",
name: "Divine Love Crystal",
rarity: "rare",
zoneId: "goddess_divine_heart",
},
{
description: "A droplet of ichor from the divine heart itself — the essence of divinity in its most concentrated form. Handle with absolute reverence.",
id: "heart_ichor",
name: "Heart Ichor",
rarity: "rare",
zoneId: "goddess_divine_heart",
},
];
+953
View File
@@ -0,0 +1,953 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { GoddessQuest } from "@elysium/types";
export const defaultGoddessQuests: Array<GoddessQuest> = [
// ── Zone 1: Celestial Garden ──────────────────────────────────────────────
{
description: "Your disciples take their first hesitant steps into the Celestial Garden, brushing petals of woven starlight as the divine presence stirs around them. They light a single votive flame and whisper the oldest name of the Goddess into the perfumed air.",
durationSeconds: 30,
id: "celestial_awakening",
name: "Celestial Awakening",
prerequisiteIds: [],
rewards: [ { amount: 100, type: "prayers" } ],
status: "available",
zoneId: "goddess_celestial_garden",
},
{
description: "The Garden's luminous blossoms respond to devout hands. Your disciples weave garlands of heavenbloom and lay them upon the Goddess's altar, earning the first flicker of her attention as petals dissolve into radiant motes.",
durationSeconds: 75,
id: "garden_offering",
name: "Garden Offering",
prerequisiteIds: [ "celestial_awakening" ],
rewards: [ { amount: 225, type: "prayers" } ],
status: "locked",
zoneId: "goddess_celestial_garden",
},
{
description: "Hidden among the boughs of the silver-barked trees are whisper-fruits — blossoms that sing prayers back to those who listen. Your disciples harvest them at dusk and press the hymns into sacred wax tablets for the Goddess's archive.",
durationSeconds: 150,
id: "sacred_harvest",
name: "Sacred Harvest",
prerequisiteIds: [ "garden_offering" ],
rewards: [ { amount: 350, type: "prayers" } ],
status: "locked",
zoneId: "goddess_celestial_garden",
},
{
description: "A grove of moonpetal trees at the Garden's heart has begun to wither, their divine sap draining into cracked earth. Your disciples perform the Rite of Renewal, singing in turn so the melody never breaks, coaxing roots back toward the light.",
durationSeconds: 225,
id: "rite_of_renewal",
name: "Rite of Renewal",
prerequisiteIds: [ "sacred_harvest" ],
rewards: [ { amount: 425, type: "prayers" } ],
status: "locked",
zoneId: "goddess_celestial_garden",
},
{
description: "At the centre of the Celestial Garden stands the First Petal — a bloom that has never wilted since before memory. Your disciples kneel in a circle around it and offer the sum of everything they have learned. The Goddess hears. The petal trembles. The way forward opens.",
durationSeconds: 300,
id: "first_prayer",
name: "First Prayer",
prerequisiteIds: [ "rite_of_renewal" ],
rewards: [ { amount: 500, type: "prayers" } ],
status: "locked",
zoneId: "goddess_celestial_garden",
},
// ── Zone 2: Crystal Sanctum ───────────────────────────────────────────────
{
description: "The Crystal Sanctum hums with a resonance older than the stars. Your disciples enter and feel their thoughts sharpen like facets cut from living gemstone. They intone the Canticle of Clarity, letting the crystals remember the sound of their voices.",
durationSeconds: 120,
id: "canticle_of_clarity",
name: "Canticle of Clarity",
prerequisiteIds: [],
rewards: [ { amount: 400, type: "prayers" } ],
status: "locked",
zoneId: "goddess_crystal_sanctum",
},
{
description: "Resonance shards litter the Sanctum's floor — fragments of prayers that crystallised and fell like snow. Your disciples gather them carefully and re-meld them into a votive lens, focusing ancient supplication into a beam of pure devotion.",
durationSeconds: 240,
id: "resonance_mending",
name: "Resonance Mending",
prerequisiteIds: [ "canticle_of_clarity" ],
rewards: [ { amount: 900, type: "prayers" } ],
status: "locked",
zoneId: "goddess_crystal_sanctum",
},
{
description: "Deep within the Sanctum a crystalline mirror reflects not faces but intentions. Your disciples stand before it one by one and let the mirror read the sincerity of their faith. Those found worthy leave a handprint of light on its surface.",
durationSeconds: 360,
id: "mirror_of_intent",
name: "Mirror of Intent",
prerequisiteIds: [ "resonance_mending" ],
rewards: [ { amount: 1300, type: "prayers" } ],
status: "locked",
zoneId: "goddess_crystal_sanctum",
},
{
description: "The Sanctum's great central spire is cracked, bleeding harmonics into the ether. Your disciples brace the spire with voice and will, pouring their prayers into the fracture until crystalline new growth seals the wound and the Sanctum sings whole once more.",
durationSeconds: 480,
id: "spire_restoration",
name: "Spire Restoration",
prerequisiteIds: [ "mirror_of_intent" ],
rewards: [ { amount: 1700, type: "prayers" } ],
status: "locked",
zoneId: "goddess_crystal_sanctum",
},
{
description: "The final chamber of the Crystal Sanctum holds a meditation dais where time moves differently. Your disciples sit in perfect silence for what feels like an eternity and yet an instant, surrendering all thought to the Goddess until their minds become as still and clear as the deepest crystal.",
durationSeconds: 600,
id: "divine_meditation",
name: "Divine Meditation",
prerequisiteIds: [ "spire_restoration" ],
rewards: [ { amount: 2000, type: "prayers" }, { amount: 2, type: "divinity" } ],
status: "locked",
zoneId: "goddess_crystal_sanctum",
},
// ── Zone 3: Astral Cathedral ──────────────────────────────────────────────
{
description: "The Astral Cathedral drifts between stars like a reverent dream. Your disciples board the spectral nave and light the Astral Braziers, whose flames burn in colours that have no earthly name, marking the Cathedral as open for worship once more.",
durationSeconds: 300,
id: "astral_braziers",
name: "Astral Braziers",
prerequisiteIds: [],
rewards: [ { amount: 1500, type: "prayers" } ],
status: "locked",
zoneId: "goddess_astral_cathedral",
},
{
description: "The Cathedral's stained-glass cosmograms are windows into moments the Goddess shaped. Your disciples trace each image and transcribe its cosmic truth onto vellum made from solidified starlight, compiling the first chapter of the Astral Codex.",
durationSeconds: 600,
id: "cosmogram_transcription",
name: "Cosmogram Transcription",
prerequisiteIds: [ "astral_braziers" ],
rewards: [ { amount: 3000, type: "prayers" }, { amount: 2, type: "divinity" } ],
status: "locked",
zoneId: "goddess_astral_cathedral",
},
{
description: "A constellation of broken chandeliers dangles in the Cathedral's void-ceiling. Each crystal holds a frozen hymn. Your disciples ascend on pillars of light and restore the chandeliers, releasing the trapped hymns in a cascade of song that shakes the stars.",
durationSeconds: 900,
id: "hymn_restoration",
name: "Hymn Restoration",
prerequisiteIds: [ "cosmogram_transcription" ],
rewards: [ { amount: 5000, type: "prayers" }, { amount: 5, type: "divinity" } ],
status: "locked",
zoneId: "goddess_astral_cathedral",
},
{
description: "At the Cathedral's altar an astral orrery has gone silent — the celestial spheres no longer dance. Your disciples wind the mechanism with prayers and correct the orbital paths by memory and faith alone, setting the heavens back in motion.",
durationSeconds: 1200,
id: "orrery_alignment",
name: "Orrery Alignment",
prerequisiteIds: [ "hymn_restoration" ],
rewards: [ { amount: 7000, type: "prayers" }, { amount: 8, type: "divinity" } ],
status: "locked",
zoneId: "goddess_astral_cathedral",
},
{
description: "The Revelation Chamber at the Cathedral's peak opens only when mortal faith is pure enough to bear what lies within. Your disciples enter and witness — each in their own way — a truth the Goddess wishes them to carry. They emerge changed, marked with starlight behind their eyes.",
durationSeconds: 1800,
id: "astral_revelation",
name: "Astral Revelation",
prerequisiteIds: [ "orrery_alignment" ],
rewards: [ { amount: 8000, type: "prayers" }, { amount: 10, type: "divinity" } ],
status: "locked",
zoneId: "goddess_astral_cathedral",
},
// ── Zone 4: Empyrean Citadel ──────────────────────────────────────────────
{
description: "The Empyrean Citadel looms above the clouds like judgment carved from gold. Your disciples breach its outer gates with an offering of bound lightning and sacred oil, earning the grudging acknowledgement of the sentinel-spirits within.",
durationSeconds: 600,
id: "citadel_breach",
name: "Citadel Breach",
prerequisiteIds: [],
rewards: [ { amount: 6000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_empyrean_citadel",
},
{
description: "The Citadel's ramparts are patrolled by storm-born wardens who test every soul that walks beneath their gaze. Your disciples answer the wardens' riddles with scripture, earning passage to the inner courtyards where divine war-relics line the walls.",
durationSeconds: 1200,
id: "warden_trial",
name: "Warden Trial",
prerequisiteIds: [ "citadel_breach" ],
rewards: [ { amount: 12_000, type: "prayers" }, { amount: 8, type: "divinity" } ],
status: "locked",
zoneId: "goddess_empyrean_citadel",
},
{
description: "In the Citadel's armoury sleep weapons that have never been drawn — consecrated against a war that has not yet come. Your disciples polish them in prayer, reading the prophecies inscribed on each blade, careful not to wake the fury sleeping within the steel.",
durationSeconds: 2400,
id: "sacred_armoury",
name: "Sacred Armoury",
prerequisiteIds: [ "warden_trial" ],
rewards: [ { amount: 20_000, type: "prayers" }, { amount: 20, type: "divinity" } ],
status: "locked",
zoneId: "goddess_empyrean_citadel",
},
{
description: "A great strategic table in the Citadel's war room projects the Goddess's eternal campaign against entropy and void. Your disciples study the battle-plans, copying formations into sacred diagrams, learning the shape of divine warfare so they may serve as worthy instruments.",
durationSeconds: 3000,
id: "divine_war_plans",
name: "Divine War Plans",
prerequisiteIds: [ "sacred_armoury" ],
rewards: [ { amount: 26_000, type: "prayers" }, { amount: 33, type: "divinity" } ],
status: "locked",
zoneId: "goddess_empyrean_citadel",
},
{
description: "The Empyrean Throne sits at the Citadel's summit, empty but vibrating with latent authority. Your disciples ascend to it and each places a palm against its armrest, pledging their ascent — not to rule, but to serve. The throne acknowledges them. The Citadel opens its deepest vaults.",
durationSeconds: 3600,
id: "empyrean_ascent",
name: "Empyrean Ascent",
prerequisiteIds: [ "divine_war_plans" ],
rewards: [ { amount: 30_000, type: "prayers" }, { amount: 40, type: "divinity" } ],
status: "locked",
zoneId: "goddess_empyrean_citadel",
},
// ── Zone 5: Primordial Springs ────────────────────────────────────────────
{
description: "The Primordial Springs bubble with waters older than the first dawn. Your disciples lower sacred vessels into the steaming pools and fill them carefully, breathing prayers into each vessel so the water remembers why it was made holy.",
durationSeconds: 1200,
id: "sacred_vessel_filling",
name: "Sacred Vessel Filling",
prerequisiteIds: [],
rewards: [ { amount: 25_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_primordial_springs",
},
{
description: "Along the Springs' banks grow healing sedges whose roots drink from the divine water. Your disciples harvest them at the hour when the moons align and prepare healing salves imbued with primordial blessing — offerings that carry the Goddess's mercy to those who suffer.",
durationSeconds: 2400,
id: "primordial_harvest",
name: "Primordial Harvest",
prerequisiteIds: [ "sacred_vessel_filling" ],
rewards: [ { amount: 50_000, type: "prayers" }, { amount: 30, type: "divinity" } ],
status: "locked",
zoneId: "goddess_primordial_springs",
},
{
description: "A fracture in the Springs' bedrock bleeds divine water into the dark earth, wasting its sanctity. Your disciples seal the fracture with consecrated clay and stone, singing over it until the ground hardens into something inviolate, the water once more rising only where it is welcome.",
durationSeconds: 3600,
id: "springs_mending",
name: "Springs Mending",
prerequisiteIds: [ "primordial_harvest" ],
rewards: [ { amount: 75_000, type: "prayers" }, { amount: 80, type: "divinity" } ],
status: "locked",
zoneId: "goddess_primordial_springs",
},
{
description: "The oldest pool in the Springs holds a reflection not of sky, but of the Goddess's memory. Your disciples immerse themselves and experience fragments of creation — vast, terrifying, beautiful. They surface sobbing with joy, minds expanded beyond what they once thought possible.",
durationSeconds: 4800,
id: "memory_immersion",
name: "Memory Immersion",
prerequisiteIds: [ "springs_mending" ],
rewards: [ { amount: 100_000, type: "prayers" }, { amount: 120, type: "divinity" } ],
status: "locked",
zoneId: "goddess_primordial_springs",
},
{
description: "The heart of the Primordial Springs conceals a blessing-font that has not flowed in aeons, its channel blocked by the calcified prayers of forgotten cults. Your disciples clear the channel with patient devotion and receive the Springs' first blessing in living memory — a torrent of sacred water, warm and golden.",
durationSeconds: 7200,
id: "springs_blessing",
name: "Springs Blessing",
prerequisiteIds: [ "memory_immersion" ],
rewards: [ { amount: 120_000, type: "prayers" }, { amount: 150, type: "divinity" } ],
status: "locked",
zoneId: "goddess_primordial_springs",
},
// ── Zone 6: Eternal Firmament ─────────────────────────────────────────────
{
description: "The Eternal Firmament stretches in all directions like an ocean made of sky. Your disciples learn to walk upon it — each step a prayer, each breath a hymn — adjusting their faith until the Firmament recognises their weight as belonging.",
durationSeconds: 1800,
id: "firmament_walking",
name: "Firmament Walking",
prerequisiteIds: [],
rewards: [ { amount: 100_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_eternal_firmament",
},
{
description: "The stars of the Firmament are prayers that have burned long enough to become permanent. Your disciples study the constellations and map the devotions that shaped each one, compiling a chart of eternal worship that guides travellers of faith across the infinite sky.",
durationSeconds: 3600,
id: "stellar_cartography",
name: "Stellar Cartography",
prerequisiteIds: [ "firmament_walking" ],
rewards: [ { amount: 200_000, type: "prayers" }, { amount: 100, type: "divinity" } ],
status: "locked",
zoneId: "goddess_eternal_firmament",
},
{
description: "A storm of doubt rages at the Firmament's centre — a blasphemous tempest seeded by those who turned away from the Goddess long ago. Your disciples enter the storm and unmake it, meeting every howling doubt with a truth louder than despair, until the sky is clear.",
durationSeconds: 7200,
id: "storm_of_doubt",
name: "Storm of Doubt",
prerequisiteIds: [ "stellar_cartography" ],
rewards: [ { amount: 300_000, type: "prayers" }, { amount: 250, type: "divinity" } ],
status: "locked",
zoneId: "goddess_eternal_firmament",
},
{
description: "Along the Firmament's edge are lighthouses of prayer — ancient beacons meant to guide lost souls. Many have gone dark. Your disciples reignite them one by one, climbing their spiral stairs and pouring their faith into cold lamps until warmth blazes across the eternal sky.",
durationSeconds: 10_800,
id: "lighthouse_rekindling",
name: "Lighthouse Rekindling",
prerequisiteIds: [ "storm_of_doubt" ],
rewards: [ { amount: 425_000, type: "prayers" }, { amount: 400, type: "divinity" } ],
status: "locked",
zoneId: "goddess_eternal_firmament",
},
{
description: "The Eternal Firmament has a pinnacle — a point beyond which no mortal has ascended without divine sanction. Your disciples climb there together, their collective faith forming a ladder of light, and at the summit they breathe the Goddess's own air and become, briefly, something more than they were.",
durationSeconds: 14_400,
id: "eternal_ascension",
name: "Eternal Ascension",
prerequisiteIds: [ "lighthouse_rekindling" ],
rewards: [ { amount: 500_000, type: "prayers" }, { amount: 500, type: "divinity" } ],
status: "locked",
zoneId: "goddess_eternal_firmament",
},
// ── Zone 7: Sacred Grove ──────────────────────────────────────────────────
{
description: "The Sacred Grove breathes with a life that predates all other living things. Your disciples enter with bare feet and open hands, learning to hear the prayers the trees have absorbed over endless centuries, their roots drinking from the same source as faith itself.",
durationSeconds: 3600,
id: "grove_listening",
name: "Grove Listening",
prerequisiteIds: [],
rewards: [ { amount: 400_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_sacred_grove",
},
{
description: "The eldest tree in the Grove — the First Witness — carries carvings made by the Goddess herself in the age of making. Your disciples decipher the carvings and learn a lost form of prayer that bypasses language entirely, speaking directly in the tongue of growth and season.",
durationSeconds: 7200,
id: "first_witness",
name: "First Witness",
prerequisiteIds: [ "grove_listening" ],
rewards: [ { amount: 800_000, type: "prayers" }, { amount: 400, type: "divinity" } ],
status: "locked",
zoneId: "goddess_sacred_grove",
},
{
description: "A blight has crept into the Grove's southern reaches — not malice but neglect, where no devotees have walked in generations. Your disciples push back the grey with prayer-walks, coaxing sacred sap back to the withered boughs until green returns to wood that had forgotten colour.",
durationSeconds: 14_400,
id: "grove_restoration",
name: "Grove Restoration",
prerequisiteIds: [ "first_witness" ],
rewards: [ { amount: 1_200_000, type: "prayers" }, { amount: 1000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_sacred_grove",
},
{
description: "The Grove's sacred animals — creatures that have never known fear — approach your disciples with offerings in their mouths: seeds, feathers, drops of luminous water. Your disciples accept each gift with reverence, completing a covenant older than any scripture.",
durationSeconds: 21_600,
id: "animal_covenant",
name: "Animal Covenant",
prerequisiteIds: [ "grove_restoration" ],
rewards: [ { amount: 1_700_000, type: "prayers" }, { amount: 1600, type: "divinity" } ],
status: "locked",
zoneId: "goddess_sacred_grove",
},
{
description: "At the Grove's heart the trees grow in a perfect circle around a clearing of silence so absolute it has its own presence. Your disciples enter the clearing and remain motionless until they feel the Grove breathe with them — one inhale, one exhale, perfectly unified. The Grove accepts them as part of itself.",
durationSeconds: 28_800,
id: "grove_harmony",
name: "Grove Harmony",
prerequisiteIds: [ "animal_covenant" ],
rewards: [ { amount: 2_000_000, type: "prayers" }, { amount: 2000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_sacred_grove",
},
// ── Zone 8: Luminous Expanse ──────────────────────────────────────────────
{
description: "The Luminous Expanse is a plain of solidified light where shadow cannot survive. Your disciples adjust their eyes and their souls to its brilliance, learning to navigate by the gradients of divine radiance rather than landmarks — a new way of seeing that changes them permanently.",
durationSeconds: 7200,
id: "luminous_adjustment",
name: "Luminous Adjustment",
prerequisiteIds: [],
rewards: [ { amount: 1_500_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_luminous_expanse",
},
{
description: "Prismatic shards of concentrated divinity drift through the Expanse like luminous snowfall. Your disciples catch them in prayer-vessels and carefully direct their energy into sacred lanterns that will hold the divine light stable, creating anchors in the blazing landscape.",
durationSeconds: 14_400,
id: "light_anchoring",
name: "Light Anchoring",
prerequisiteIds: [ "luminous_adjustment" ],
rewards: [ { amount: 3_000_000, type: "prayers" }, { amount: 1500, type: "divinity" } ],
status: "locked",
zoneId: "goddess_luminous_expanse",
},
{
description: "Where the Expanse borders older darkness at its northern edge, the light wavers and threatens to retreat. Your disciples stand at the border and push back, pouring devotion into the wavering boundary until the light holds firm and even expands, reclaiming territory from the void.",
durationSeconds: 28_800,
id: "light_boundary",
name: "Light Boundary",
prerequisiteIds: [ "light_anchoring" ],
rewards: [ { amount: 5_000_000, type: "prayers" }, { amount: 3500, type: "divinity" } ],
status: "locked",
zoneId: "goddess_luminous_expanse",
},
{
description: "The Expanse hides within its radiance the Spectra — beings of pure light who guard the Goddess's most dazzling secrets. Your disciples prove their devotion through a trial of silent endurance, sitting within the Spectra's blazing presence without flinching until the beings lower their brilliance and speak.",
durationSeconds: 43_200,
id: "spectra_trial",
name: "Spectra Trial",
prerequisiteIds: [ "light_boundary" ],
rewards: [ { amount: 6_500_000, type: "prayers" }, { amount: 6000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_luminous_expanse",
},
{
description: "The pinnacle of the Luminous Expanse is the Light-Throne — not a seat of rule but a point of maximal proximity to the Goddess's radiant nature. Your disciples ascend and allow the light to pass through them completely, every secret and sorrow illuminated. They transcend what opacity remained in their souls.",
durationSeconds: 57_600,
id: "light_transcendence",
name: "Light Transcendence",
prerequisiteIds: [ "spectra_trial" ],
rewards: [ { amount: 8_000_000, type: "prayers" }, { amount: 8000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_luminous_expanse",
},
// ── Zone 9: Heavenly Forge ────────────────────────────────────────────────
{
description: "The Heavenly Forge roars with divine fire that consumes nothing but impurity. Your disciples tend the forge's first furnace, learning to breathe in the sacred smoke without coughing, adjusting their lungs to air that burns with purpose rather than heat.",
durationSeconds: 14_400,
id: "forge_initiation",
name: "Forge Initiation",
prerequisiteIds: [],
rewards: [ { amount: 6_000_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_heavenly_forge",
},
{
description: "The Forge's celestial anvils are inscribed with the names of everything ever made in their service. Your disciples learn to read the inscriptions, understanding the genealogy of divine craft — how each holy weapon and sacred relic was born from the marriage of prayer and fire.",
durationSeconds: 28_800,
id: "anvil_reading",
name: "Anvil Reading",
prerequisiteIds: [ "forge_initiation" ],
rewards: [ { amount: 12_000_000, type: "prayers" }, { amount: 6000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_heavenly_forge",
},
{
description: "An ancient commission sits unfinished in the Forge — a reliquary meant for a saint who died before it could be delivered. Your disciples complete the reliquary to its original specification, working from divine schematics, honouring both the unnamed saint and the Goddess who commissioned the work.",
durationSeconds: 57_600,
id: "unfinished_reliquary",
name: "Unfinished Reliquary",
prerequisiteIds: [ "anvil_reading" ],
rewards: [ { amount: 20_000_000, type: "prayers" }, { amount: 15_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_heavenly_forge",
},
{
description: "The Forge's master flame — a tongue of divine fire that has burned without pause since the first making — has begun to gutter. Your disciples feed it with their most powerful prayers, speaking them directly into the flame until it roars back to full strength, taller and hotter than before.",
durationSeconds: 72_000,
id: "master_flame",
name: "Master Flame",
prerequisiteIds: [ "unfinished_reliquary" ],
rewards: [ { amount: 26_000_000, type: "prayers" }, { amount: 24_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_heavenly_forge",
},
{
description: "Every master smith of the Heavenly Forge must one day undergo the Final Tempering — plunging their faith into ice-cold divine water after the Forge's hottest blaze, enduring the shock of total spiritual contrast. Your disciples emerge from the tempering with faith hardened to an edge nothing can blunt.",
durationSeconds: 86_400,
id: "forge_mastery",
name: "Forge Mastery",
prerequisiteIds: [ "master_flame" ],
rewards: [ { amount: 30_000_000, type: "prayers" }, { amount: 30_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_heavenly_forge",
},
// ── Zone 10: Oracle Sanctum ───────────────────────────────────────────────
{
description: "The Oracle Sanctum is a place of layered prophecy where every surface reflects a different possible future. Your disciples learn to walk through it without becoming lost in what might be, anchoring their attention firmly to what is — the first discipline of those who would deal with oracles.",
durationSeconds: 28_800,
id: "oracle_discipline",
name: "Oracle Discipline",
prerequisiteIds: [],
rewards: [ { amount: 25_000_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_oracle_sanctum",
},
{
description: "The Sanctum houses the Oracle Pools — basins filled with liquid foresight. Your disciples drink from each in turn, experiencing visions of diverging futures. They must record what they see faithfully and without interpretation, trusting the Goddess to have shown them exactly what she intended.",
durationSeconds: 57_600,
id: "oracle_pools",
name: "Oracle Pools",
prerequisiteIds: [ "oracle_discipline" ],
rewards: [ { amount: 50_000_000, type: "prayers" }, { amount: 25_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_oracle_sanctum",
},
{
description: "Woven through the Sanctum are threads of Fate — visible to those trained enough to see them. Your disciples trace three threads each, following them from past to present to several possible futures, learning to read the Goddess's intent in the way Fate bends and branches.",
durationSeconds: 86_400,
id: "fate_threading",
name: "Fate Threading",
prerequisiteIds: [ "oracle_pools" ],
rewards: [ { amount: 80_000_000, type: "prayers" }, { amount: 60_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_oracle_sanctum",
},
{
description: "A false prophecy has taken root in the Sanctum — a lie shaped so cleverly it has convinced several of its resident seers. Your disciples identify it by the single thread it cannot follow into the future and unmake it carefully, restoring the clarity of the Sanctum's vision without destabilising the truths around it.",
durationSeconds: 129_600,
id: "false_prophecy",
name: "False Prophecy",
prerequisiteIds: [ "fate_threading" ],
rewards: [ { amount: 105_000_000, type: "prayers" }, { amount: 95_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_oracle_sanctum",
},
{
description: "The Oracle Sanctum's innermost chamber holds the Truth Engine — a device of divine construction that takes questions and returns only what is absolutely, cosmically certain. Your disciples present their most earnest question and receive an answer so unambiguous it restructures their understanding of reality. They carry the truth outward as a gift to the Goddess's wider work.",
durationSeconds: 172_800,
id: "oracle_truth",
name: "Oracle Truth",
prerequisiteIds: [ "false_prophecy" ],
rewards: [ { amount: 120_000_000, type: "prayers" }, { amount: 120_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_oracle_sanctum",
},
// ── Zone 11: Seraph's Nest ────────────────────────────────────────────────
{
description: "The Seraph's Nest clings to the underside of a celestial cliff, woven from solidified song and dawn-light. Your disciples earn the right to enter by composing an original hymn on the spot — the Seraphs will accept nothing recited from memory, only the raw prayer of the present moment.",
durationSeconds: 43_200,
id: "seraph_entry",
name: "Seraph Entry",
prerequisiteIds: [],
rewards: [ { amount: 100_000_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_seraphs_nest",
},
{
description: "The Seraphs keep a thousand nests, each containing one egg of potential — prayers so concentrated they have begun to crystallise into new life. Your disciples tend the eggs, maintaining the exact temperature of devotion required, singing lullabies of faith through the long divine night.",
durationSeconds: 86_400,
id: "egg_tending",
name: "Egg Tending",
prerequisiteIds: [ "seraph_entry" ],
rewards: [ { amount: 200_000_000, type: "prayers" }, { amount: 100_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_seraphs_nest",
},
{
description: "The eldest Seraph — a being of six wings and a voice like a choir — requires that your disciples pass the Trial of Wings before it will share its knowledge. The trial demands that disciples face a memory of their own greatest failure and find, within it, the seed of something sacred.",
durationSeconds: 129_600,
id: "trial_of_wings",
name: "Trial of Wings",
prerequisiteIds: [ "egg_tending" ],
rewards: [ { amount: 300_000_000, type: "prayers" }, { amount: 250_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_seraphs_nest",
},
{
description: "The Nest's great Chorus Hall resonates with Seraphic song that the Goddess composed when she first taught angels to feel joy. Your disciples add their voices to the Chorus — not to improve it, but to be worthy of harmonising with something so perfect. To join without diminishing is itself a form of transcendence.",
durationSeconds: 194_400,
id: "seraph_chorus",
name: "Seraph Chorus",
prerequisiteIds: [ "trial_of_wings" ],
rewards: [ { amount: 420_000_000, type: "prayers" }, { amount: 400_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_seraphs_nest",
},
{
description: "At the Nest's sacred apex, the First Feather — shed by the Goddess's own divine form in the age of creation — rests in a cradle of light. Your disciples are allowed to approach it. Each places a single fingertip against its edge and, in that contact, ascends briefly to a state of understanding that cannot be communicated, only carried.",
durationSeconds: 259_200,
id: "seraph_ascension",
name: "Seraph Ascension",
prerequisiteIds: [ "seraph_chorus" ],
rewards: [ { amount: 500_000_000, type: "prayers" }, { amount: 500_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_seraphs_nest",
},
// ── Zone 12: Divine Archive ───────────────────────────────────────────────
{
description: "The Divine Archive holds every prayer ever offered and every silence that preceded one. Your disciples receive their credentials — a small flame that lives in the palm — and begin the impossible task of learning to navigate its endless corridors without being consumed by the weight of accumulated devotion.",
durationSeconds: 57_600,
id: "archive_credentials",
name: "Archive Credentials",
prerequisiteIds: [],
rewards: [ { amount: 400_000_000, type: "prayers" } ],
status: "locked",
zoneId: "goddess_divine_archive",
},
{
description: "The Archive's Restoration Wing holds prayers damaged by time, grief, or the faithlessness of those who offered them. Your disciples take up the work of mending — carefully repairing broken devotions so they can be properly filed, returning dignity to those who prayed even in their darkest hours.",
durationSeconds: 115_200,
id: "archive_restoration",
name: "Archive Restoration",
prerequisiteIds: [ "archive_credentials" ],
rewards: [ { amount: 800_000_000, type: "prayers" }, { amount: 400_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_divine_archive",
},
{
description: "Lost within the Archive's deepest stacks is the Index of Names — the record of every mortal who ever prayed and the Goddess's personal acknowledgement of each one. Your disciples search for it through millions of shelves, guided only by the warmth of faith, and when they find it, they read their own names already written there.",
durationSeconds: 230_400,
id: "index_of_names",
name: "Index of Names",
prerequisiteIds: [ "archive_restoration" ],
rewards: [ { amount: 1_200_000_000, type: "prayers" }, { amount: 1_000_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_divine_archive",
},
{
description: "The Archive has a Forbidden Section — not forbidden by the Goddess but by the knowledge itself, which is too profound for unprepared minds. Your disciples have been prepared. They enter and read the records of prayers answered in ways the supplicants never understood, learning to see the hidden geometry of divine response.",
durationSeconds: 288_000,
id: "forbidden_section",
name: "Forbidden Section",
prerequisiteIds: [ "index_of_names" ],
rewards: [ { amount: 1_700_000_000, type: "prayers" }, { amount: 1_600_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_divine_archive",
},
{
description: "The Archive's final hall contains the Last Entry — a prayer written by the Goddess herself, addressed to no one in particular and everyone who would one day find it. Your disciples read it together in silence. None will speak of its contents, but all of them weep, and none of them can say they are not grateful.",
durationSeconds: 345_600,
id: "archive_completion",
name: "Archive Completion",
prerequisiteIds: [ "forbidden_section" ],
rewards: [ { amount: 2_000_000_000, type: "prayers" }, { amount: 2_000_000, type: "divinity" } ],
status: "locked",
zoneId: "goddess_divine_archive",
},
// ── Zone 13: Consecrated Depths ───────────────────────────────────────────
{
description: "The Consecrated Depths lie beneath even the roots of heaven — a place where divine light has never reached but divine presence fills every shadow. Your disciples descend into the lightless sacred, learning to pray with their whole body rather than voice, for sound dissolves in the Depths' holy dark.",
durationSeconds: 86_400,
id: "depths_descent",
name: "Depths Descent",
prerequisiteIds: [],
rewards: [ { amount: 1_500_000_000, type: "prayers" }, { amount: 1, type: "stardust" } ],
status: "locked",
zoneId: "goddess_consecrated_depths",
},
{
description: "The Depths hold shrines of shadow-stone that absorb rather than reflect light. Your disciples tend them with oil made from crystallised prayers and watch as the shrines drink the offering, releasing in return a low hum that resonates in the chest — the voice of the Goddess's unknowable, patient depth.",
durationSeconds: 172_800,
id: "shadow_shrines",
name: "Shadow Shrines",
prerequisiteIds: [ "depths_descent" ],
rewards: [ { amount: 3_000_000_000, type: "prayers" }, { amount: 1_500_000, type: "divinity" }, { amount: 1, type: "stardust" } ],
status: "locked",
zoneId: "goddess_consecrated_depths",
},
{
description: "Deep in the Consecrated Depths a vast creature sleeps — one of the Goddess's oldest prayers given flesh, too sacred to wake but too potent to leave unattended. Your disciples become its temporary custodians, maintaining the seal of sacred sleep, ensuring its dreams do not become nightmares that bleed into the world above.",
durationSeconds: 259_200,
id: "sacred_sleeper",
name: "Sacred Sleeper",
prerequisiteIds: [ "shadow_shrines" ],
rewards: [ { amount: 5_000_000_000, type: "prayers" }, { amount: 4_000_000, type: "divinity" }, { amount: 2, type: "stardust" } ],
status: "locked",
zoneId: "goddess_consecrated_depths",
},
{
description: "At the Depths' nadir is a wellspring of black-light divinity — the essence of the Goddess's unknowable aspects, the parts of her nature that have no names. Your disciples draw carefully from the wellspring and bring its ineffable essence to the surface, where it enriches all other sacred sites they have tended.",
durationSeconds: 345_600,
id: "black_light_wellspring",
name: "Black-Light Wellspring",
prerequisiteIds: [ "sacred_sleeper" ],
rewards: [ { amount: 7_000_000_000, type: "prayers" }, { amount: 7_000_000, type: "divinity" }, { amount: 2, type: "stardust" } ],
status: "locked",
zoneId: "goddess_consecrated_depths",
},
{
description: "The Depths' revelation does not come as light but as understanding — a slow, total unfolding of the sacred dark, which contains not the absence of the Goddess but her most interior presence. Your disciples surrender to it completely and are remade in her image of what darkness is for: the ground from which all light emerges.",
durationSeconds: 432_000,
id: "depths_revelation",
name: "Depths Revelation",
prerequisiteIds: [ "black_light_wellspring" ],
rewards: [ { amount: 8_000_000_000, type: "prayers" }, { amount: 8_000_000, type: "divinity" }, { amount: 3, type: "stardust" } ],
status: "locked",
zoneId: "goddess_consecrated_depths",
},
// ── Zone 14: Astral Confluence ────────────────────────────────────────────
{
description: "The Astral Confluence is the point where all divine energies intersect — a place of such concentrated sacred power that unshielded minds shatter. Your disciples arrive armoured in years of devotion and take their first readings of the competing streams, learning to stand at the intersection without being torn apart.",
durationSeconds: 172_800,
id: "confluence_arrival",
name: "Confluence Arrival",
prerequisiteIds: [],
rewards: [ { amount: 6_000_000_000, type: "prayers" }, { amount: 2, type: "stardust" } ],
status: "locked",
zoneId: "goddess_astral_confluence",
},
{
description: "Within the Confluence, streams of divinity from different sources run alongside each other without mixing — light-prayer, dark-prayer, fire-prayer, void-prayer, and a dozen others. Your disciples build bridges between the streams using sacred geometry, allowing them to flow as one compound river rather than separate, competing currents.",
durationSeconds: 345_600,
id: "stream_bridging",
name: "Stream Bridging",
prerequisiteIds: [ "confluence_arrival" ],
rewards: [ { amount: 12_000_000_000, type: "prayers" }, { amount: 6_000_000, type: "divinity" }, { amount: 3, type: "stardust" } ],
status: "locked",
zoneId: "goddess_astral_confluence",
},
{
description: "The Confluence holds a Resonance Chamber where the combined divine energies achieve a pitch that can rewrite fundamental truths about the universe. Your disciples use it carefully — adjusting one small truth about the nature of prayer itself, making devotion slightly more possible for all beings everywhere.",
durationSeconds: 432_000,
id: "resonance_chamber",
name: "Resonance Chamber",
prerequisiteIds: [ "stream_bridging" ],
rewards: [ { amount: 20_000_000_000, type: "prayers" }, { amount: 15_000_000, type: "divinity" }, { amount: 4, type: "stardust" } ],
status: "locked",
zoneId: "goddess_astral_confluence",
},
{
description: "The Confluence's most dangerous feature is the Eddy — a back-current of misdirected divinity that has been accumulating since before the current age. Your disciples unmake the Eddy through a twelve-stage ritual that requires each disciple to sacrifice a memory of profound joy, which the Eddy consumes in exchange for its dissolution.",
durationSeconds: 518_400,
id: "confluence_eddy",
name: "Confluence Eddy",
prerequisiteIds: [ "resonance_chamber" ],
rewards: [ { amount: 26_000_000_000, type: "prayers" }, { amount: 24_000_000, type: "divinity" }, { amount: 4, type: "stardust" } ],
status: "locked",
zoneId: "goddess_astral_confluence",
},
{
description: "Alignment at the Confluence means being in perfect harmonic relation with every divine stream simultaneously — a feat so demanding it has been achieved only once before, by the Goddess herself. Your disciples achieve it together, their collective faith acting as a single instrument tuned to all frequencies at once. The universe hums in recognition.",
durationSeconds: 604_800,
id: "confluence_alignment",
name: "Confluence Alignment",
prerequisiteIds: [ "confluence_eddy" ],
rewards: [ { amount: 30_000_000_000, type: "prayers" }, { amount: 30_000_000, type: "divinity" }, { amount: 5, type: "stardust" } ],
status: "locked",
zoneId: "goddess_astral_confluence",
},
// ── Zone 15: Celestial Throne ─────────────────────────────────────────────
{
description: "The Celestial Throne's antechamber stretches for an eternity in both directions — a corridor of mirrors showing the Goddess from every angle at every moment she has ever existed. Your disciples walk it without stopping, eyes forward, learning the discipline of approaching divinity without losing themselves in reflection.",
durationSeconds: 259_200,
id: "throne_approach",
name: "Throne Approach",
prerequisiteIds: [],
rewards: [ { amount: 25_000_000_000, type: "prayers" }, { amount: 5, type: "stardust" } ],
status: "locked",
zoneId: "goddess_celestial_throne",
},
{
description: "The Throne's outer sanctum is guarded by the Tribunal — seven divine entities who assess the worthiness of any who would approach the Throne's inner chambers. Each member of the Tribunal poses a single question that cannot be prepared for. Your disciples answer honestly, and in their honesty, pass.",
durationSeconds: 432_000,
id: "throne_tribunal",
name: "Throne Tribunal",
prerequisiteIds: [ "throne_approach" ],
rewards: [ { amount: 50_000_000_000, type: "prayers" }, { amount: 25_000_000, type: "divinity" }, { amount: 6, type: "stardust" } ],
status: "locked",
zoneId: "goddess_celestial_throne",
},
{
description: "The Throne's inner court is a space of perfect sovereignty where the Goddess's will becomes physical law. Your disciples learn to move within it — each action requiring absolute intentionality, nothing done by habit or reflex, every breath a conscious act of devotion performed in the presence of ultimate authority.",
durationSeconds: 604_800,
id: "inner_court",
name: "Inner Court",
prerequisiteIds: [ "throne_tribunal" ],
rewards: [ { amount: 80_000_000_000, type: "prayers" }, { amount: 60_000_000, type: "divinity" }, { amount: 8, type: "stardust" } ],
status: "locked",
zoneId: "goddess_celestial_throne",
},
{
description: "Surrounding the Throne itself are the Votive Pillars — columns where the greatest prayers in history were once spoken and immediately crystallised. Your disciples read every pillar in sequence, and in reading them, add their own voices to the register of the worthy, speaking prayers that crystallise before the words have fully formed.",
durationSeconds: 734_400,
id: "votive_pillars",
name: "Votive Pillars",
prerequisiteIds: [ "inner_court" ],
rewards: [ { amount: 105_000_000_000, type: "prayers" }, { amount: 100_000_000, type: "divinity" }, { amount: 10, type: "stardust" } ],
status: "locked",
zoneId: "goddess_celestial_throne",
},
{
description: "The Goddess acknowledges those who have walked the full path to her Throne. She does not speak — she is too vast for words — but the Throne's crystalline surface shifts to show your disciples' reflections looking back at them in divine perfection: not who they are, but who they are recognised as. It is enough. It is everything.",
durationSeconds: 864_000,
id: "throne_recognition",
name: "Throne Recognition",
prerequisiteIds: [ "votive_pillars" ],
rewards: [ { amount: 120_000_000_000, type: "prayers" }, { amount: 120_000_000, type: "divinity" }, { amount: 12, type: "stardust" } ],
status: "locked",
zoneId: "goddess_celestial_throne",
},
// ── Zone 16: Infinite Choir ───────────────────────────────────────────────
{
description: "The Infinite Choir sings without end — every voice that has ever offered devotion continuing here past death, past memory, past time. Your disciples enter and hear themselves already singing in it, their future voices reaching back to greet them. They add their present voices to the weave and feel complete for the first time.",
durationSeconds: 345_600,
id: "choir_entry",
name: "Choir Entry",
prerequisiteIds: [],
rewards: [ { amount: 100_000_000_000, type: "prayers" }, { amount: 8, type: "stardust" } ],
status: "locked",
zoneId: "goddess_infinite_choir",
},
{
description: "The Choir's central harmonic — the note on which all other voices converge — has been drifting out of tune for centuries as the mortal worlds below have grown quieter in their devotion. Your disciples find the harmonic's root and pull it gently back into true, feeling every voice in the Choir shift and settle with a resonance that shakes the floor of heaven.",
durationSeconds: 604_800,
id: "harmonic_tuning",
name: "Harmonic Tuning",
prerequisiteIds: [ "choir_entry" ],
rewards: [ { amount: 200_000_000_000, type: "prayers" }, { amount: 100_000_000, type: "divinity" }, { amount: 12, type: "stardust" } ],
status: "locked",
zoneId: "goddess_infinite_choir",
},
{
description: "Within the Choir dwells the Voice of First Praise — the very first sound made in the Goddess's honour, still singing after all this time. Your disciples find it amid the vast chorus and listen without interrupting, learning the original cadence of worship, older than language, older than thought.",
durationSeconds: 777_600,
id: "voice_of_first_praise",
name: "Voice of First Praise",
prerequisiteIds: [ "harmonic_tuning" ],
rewards: [ { amount: 300_000_000_000, type: "prayers" }, { amount: 250_000_000, type: "divinity" }, { amount: 16, type: "stardust" } ],
status: "locked",
zoneId: "goddess_infinite_choir",
},
{
description: "The Choir's outer reaches fade into silence — not the silence of absence, but of potential, where new voices have not yet begun. Your disciples move through these quiet margins and seed them with new prayers, extending the Choir outward so that future devotees will have a place prepared for them when their own voices find the song.",
durationSeconds: 907_200,
id: "choir_extension",
name: "Choir Extension",
prerequisiteIds: [ "voice_of_first_praise" ],
rewards: [ { amount: 425_000_000_000, type: "prayers" }, { amount: 400_000_000, type: "divinity" }, { amount: 20, type: "stardust" } ],
status: "locked",
zoneId: "goddess_infinite_choir",
},
{
description: "Perfection in the Infinite Choir does not mean singing better than all others — it means knowing exactly how your voice completes the whole. Your disciples achieve this knowing: each finds the one pitch that only they can hold, and holds it without pride, without strain, without end. The Choir becomes truly infinite in the moment they join it fully.",
durationSeconds: 1_036_800,
id: "choir_perfection",
name: "Choir Perfection",
prerequisiteIds: [ "choir_extension" ],
rewards: [ { amount: 500_000_000_000, type: "prayers" }, { amount: 500_000_000, type: "divinity" }, { amount: 25, type: "stardust" } ],
status: "locked",
zoneId: "goddess_infinite_choir",
},
// ── Zone 17: The Veil ─────────────────────────────────────────────────────
{
description: "The Veil is the membrane between existence and whatever the Goddess herself dwells within. Your disciples approach it for the first time and feel their perception of reality thin until they can see, flickering at the edge of vision, the shape of something so vast it has no edges. They retreat. They return. They learn to stand near what cannot be comprehended.",
durationSeconds: 432_000,
id: "veil_approach",
name: "Veil Approach",
prerequisiteIds: [],
rewards: [ { amount: 400_000_000_000, type: "prayers" }, { amount: 15, type: "stardust" } ],
status: "locked",
zoneId: "goddess_veil",
},
{
description: "The Veil is not impenetrable — it breathes, and in its exhalations, fragments of the beyond drift through. Your disciples collect these fragments: impossible colours, sounds that are also textures, emotions that have no earthly equivalents. They preserve each one in sacred containers, building a collection of the divine uncontainable.",
durationSeconds: 777_600,
id: "veil_fragments",
name: "Veil Fragments",
prerequisiteIds: [ "veil_approach" ],
rewards: [ { amount: 800_000_000_000, type: "prayers" }, { amount: 400_000_000, type: "divinity" }, { amount: 22, type: "stardust" } ],
status: "locked",
zoneId: "goddess_veil",
},
{
combatPowerRequired: 1_000_000,
description: "The Veil is attended by Threshold Guardians — not hostile, but absolute in their function. Nothing unworthy passes. Your disciples undergo the Guardians' assessment, which involves no questions and no tasks but simply standing still while the Guardians look through them. Those found wanting are sent back without shame. Your disciples are not sent back.",
durationSeconds: 950_400,
id: "threshold_assessment",
name: "Threshold Assessment",
prerequisiteIds: [ "veil_fragments" ],
rewards: [ { amount: 1_200_000_000_000, type: "prayers" }, { amount: 1_000_000_000, type: "divinity" }, { amount: 30, type: "stardust" } ],
status: "locked",
zoneId: "goddess_veil",
},
{
combatPowerRequired: 2_000_000,
description: "The final ritual before crossing the Veil requires that your disciples release everything they have accumulated — not lose it, but hold it loosely enough that they could release it. Every prayer earned, every piece of knowledge gained, every devotion offered: they hold it all at the tips of their fingers and wait to see whether the Goddess calls them through empty-handed. The Goddess is pleased by the willingness alone.",
durationSeconds: 1_123_200,
id: "veil_release",
name: "Veil Release",
prerequisiteIds: [ "threshold_assessment" ],
rewards: [ { amount: 1_700_000_000_000, type: "prayers" }, { amount: 1_600_000_000, type: "divinity" }, { amount: 38, type: "stardust" } ],
status: "locked",
zoneId: "goddess_veil",
},
{
combatPowerRequired: 3_000_000,
description: "Your disciples step through the Veil. Language ends here. What they experience cannot be written and does not need to be. They return — for they must return, there is still work to do — carrying with them the absolute certainty that the Goddess is real, that she is present, and that everything they have ever done in her name was seen and held and loved.",
durationSeconds: 1_296_000,
id: "veil_crossing",
name: "Veil Crossing",
prerequisiteIds: [ "veil_release" ],
rewards: [ { amount: 2_000_000_000_000, type: "prayers" }, { amount: 2_000_000_000, type: "divinity" }, { amount: 50, type: "stardust" } ],
status: "locked",
zoneId: "goddess_veil",
},
// ── Zone 18: Divine Heart ─────────────────────────────────────────────────
{
description: "The Divine Heart is not a place but a state of being — the innermost reality where the Goddess's own nature is laid bare. Your disciples arrive and find that they are already known here, have always been known here, that every prayer they ever offered was heard in this very chamber before it left their lips. They kneel in understanding rather than supplication.",
durationSeconds: 604_800,
id: "heart_arrival",
name: "Heart Arrival",
prerequisiteIds: [],
rewards: [ { amount: 1_500_000_000_000, type: "prayers" }, { amount: 25, type: "stardust" } ],
status: "locked",
zoneId: "goddess_divine_heart",
},
{
description: "The Heart pulses with a rhythm that predates creation and will outlast its end. Your disciples synchronise their own heartbeats to it — not through technique but through surrender — and in those moments of synchrony, understand what the universe is for. The knowledge is too large to keep but leaves an impression that reshapes everything they do thereafter.",
durationSeconds: 950_400,
id: "heart_synchrony",
name: "Heart Synchrony",
prerequisiteIds: [ "heart_arrival" ],
rewards: [ { amount: 3_000_000_000_000, type: "prayers" }, { amount: 1_500_000_000, type: "divinity" }, { amount: 35, type: "stardust" } ],
status: "locked",
zoneId: "goddess_divine_heart",
},
{
combatPowerRequired: 5_000_000,
description: "The Heart contains the Wound — a sorrow the Goddess has carried since she first loved something that could be lost. Your disciples sit with her Wound, not trying to heal it, not offering solutions, simply bearing witness to divine grief with their full presence. In being witnessed, the Wound does not close but becomes bearable. The Goddess is grateful in ways words cannot approach.",
durationSeconds: 1_209_600,
id: "heart_witness",
name: "Heart Witness",
prerequisiteIds: [ "heart_synchrony" ],
rewards: [ { amount: 5_000_000_000_000, type: "prayers" }, { amount: 4_000_000_000, type: "divinity" }, { amount: 50, type: "stardust" } ],
status: "locked",
zoneId: "goddess_divine_heart",
},
{
combatPowerRequired: 8_000_000,
description: "The Heart's deepest chamber holds the Origin Flame — the first act of divine will, still burning, the moment from which all subsequent existence cascades. Your disciples sit before it and add their own lights to it: not to contribute to creation, which is already complete, but to affirm that they are glad to exist within it and would choose to again.",
durationSeconds: 1_468_800,
id: "origin_flame",
name: "Origin Flame",
prerequisiteIds: [ "heart_witness" ],
rewards: [ { amount: 7_000_000_000_000, type: "prayers" }, { amount: 7_000_000_000, type: "divinity" }, { amount: 65, type: "stardust" } ],
status: "locked",
zoneId: "goddess_divine_heart",
},
{
combatPowerRequired: 10_000_000,
description: "Union is not merger. Your disciples do not cease to be themselves — they become fully themselves for the first time, held in the Goddess's complete understanding and love. This is the end of the long pilgrimage: not dissolution but recognition. They are seen. They are known. They are cherished. And they carry that forward into all the infinite work that remains.",
durationSeconds: 1_728_000,
id: "divine_heart_union",
name: "Divine Heart Union",
prerequisiteIds: [ "origin_flame" ],
rewards: [ { amount: 8_000_000_000_000, type: "prayers" }, { amount: 8_000_000_000, type: "divinity" }, { amount: 80, type: "stardust" } ],
status: "locked",
zoneId: "goddess_divine_heart",
},
];
+729
View File
@@ -0,0 +1,729 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- Data file */
/* eslint-disable stylistic/max-len -- Data content */
import type { GoddessUpgrade } from "@elysium/types";
export const defaultGoddessUpgrades: Array<GoddessUpgrade> = [
// ── Prayer Income ────────────────────────────────────────────────────────
{
costDivinity: 0,
costPrayers: 50,
costStardust: 0,
description: "A morning offering to the goddess awakens dormant prayer energy. All prayers/s ×1.25.",
id: "prayer_offering_1",
multiplier: 1.25,
name: "Morning Offering I",
purchased: false,
target: "prayers",
unlocked: true,
},
{
costDivinity: 0,
costPrayers: 200,
costStardust: 0,
description: "Sustained devotion amplifies the flow of prayers from all disciples. All prayers/s ×1.5.",
id: "prayer_offering_2",
multiplier: 1.5,
name: "Morning Offering II",
purchased: false,
target: "prayers",
unlocked: true,
},
{
costDivinity: 1,
costPrayers: 1000,
costStardust: 0,
description: "The prayers now carry a resonance that doubles their effect across the whole order. All prayers/s ×2.",
id: "prayer_offering_3",
multiplier: 2,
name: "Morning Offering III",
purchased: false,
target: "prayers",
unlocked: true,
},
{
costDivinity: 2,
costPrayers: 5000,
costStardust: 0,
description: "The prayers reach into the divine substrate itself and triple its yield. All prayers/s ×3.",
id: "prayer_offering_4",
multiplier: 3,
name: "Morning Offering IV",
purchased: false,
target: "prayers",
unlocked: true,
},
{
costDivinity: 20,
costPrayers: 50_000,
costStardust: 0,
description: "A divine spark ignites every prayer, amplifying them fivefold at the source. All prayers/s ×5.",
id: "divine_spark_1",
multiplier: 5,
name: "Divine Spark I",
purchased: false,
target: "prayers",
unlocked: true,
},
{
costDivinity: 100,
costPrayers: 500_000,
costStardust: 1,
description: "The spark becomes a flame that burns across all prayer — eternal and self-sustaining. All prayers/s ×25.",
id: "divine_spark_2",
multiplier: 25,
name: "Divine Spark II",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 250,
costPrayers: 2_500_000,
costStardust: 2,
description: "The goddess herself hums through every prayer channel, multiplying output fivefold again. All prayers/s ×50.",
id: "divine_spark_3",
multiplier: 50,
name: "Divine Spark III",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 600,
costPrayers: 12_000_000,
costStardust: 4,
description: "An unbroken stream of holy resonance carries prayers beyond mortal comprehension. All prayers/s ×100.",
id: "resonant_hymn_1",
multiplier: 100,
name: "Resonant Hymn I",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 1500,
costPrayers: 60_000_000,
costStardust: 8,
description: "A hymn that has never stopped since creation pours through every soul in the order. All prayers/s ×200.",
id: "resonant_hymn_2",
multiplier: 200,
name: "Resonant Hymn II",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 4000,
costPrayers: 300_000_000,
costStardust: 15,
description: "The divine frequency locks into the cosmic constant, rewriting the rules of prayer. All prayers/s ×500.",
id: "resonant_hymn_3",
multiplier: 500,
name: "Resonant Hymn III",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 10_000,
costPrayers: 1_500_000_000,
costStardust: 30,
description: "The hymn ascends beyond sound into pure divine intention. All prayers/s ×1000.",
id: "eternal_chorus_1",
multiplier: 1000,
name: "Eternal Chorus I",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 25_000,
costPrayers: 8_000_000_000,
costStardust: 60,
description: "The chorus of the faithful echoes through dimensions unseen. All prayers/s ×2500.",
id: "eternal_chorus_2",
multiplier: 2500,
name: "Eternal Chorus II",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 60_000,
costPrayers: 40_000_000_000,
costStardust: 120,
description: "The goddess weeps tears of divine light — each one a thousandfold prayer. All prayers/s ×5000.",
id: "divine_tears",
multiplier: 5000,
name: "Divine Tears",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 150_000,
costPrayers: 200_000_000_000,
costStardust: 250,
description: "The prayers of the entire goddess expansion merge into a single radiant beam. All prayers/s ×10000.",
id: "radiant_convergence",
multiplier: 10_000,
name: "Radiant Convergence",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 400_000,
costPrayers: 1_000_000_000_000,
costStardust: 500,
description: "The infinite expanse of prayer reaches completion — a perfect loop of divine worship. All prayers/s ×25000.",
id: "prayer_apotheosis",
multiplier: 25_000,
name: "Prayer Apotheosis",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 1_000_000,
costPrayers: 5_000_000_000_000,
costStardust: 1000,
description: "The last prayer merges with the first in an eternal cycle with no beginning or end. All prayers/s ×50000.",
id: "omega_devotion",
multiplier: 50_000,
name: "Omega Devotion",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 2_500_000,
costPrayers: 25_000_000_000_000,
costStardust: 2000,
description: "Every soul in the cosmos utters your name. All prayers/s ×100000.",
id: "cosmic_worship",
multiplier: 100_000,
name: "Cosmic Worship",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 6_000_000,
costPrayers: 120_000_000_000_000,
costStardust: 4000,
description: "Beyond even the gods, the universe itself becomes your congregation. All prayers/s ×250000.",
id: "universal_faith",
multiplier: 250_000,
name: "Universal Faith",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 15_000_000,
costPrayers: 600_000_000_000_000,
costStardust: 8000,
description: "The goddess is all. The prayers are all. There is nothing that does not pray. All prayers/s ×500000.",
id: "all_is_prayer",
multiplier: 500_000,
name: "All Is Prayer",
purchased: false,
target: "prayers",
unlocked: false,
},
{
costDivinity: 40_000_000,
costPrayers: 3_000_000_000_000_000,
costStardust: 15_000,
description: "The final prayer — the one that was always being prayed, before the first word was ever spoken. All prayers/s ×1000000.",
id: "the_last_prayer",
multiplier: 1_000_000,
name: "The Last Prayer",
purchased: false,
target: "prayers",
unlocked: false,
},
// ── Disciple-specific ────────────────────────────────────────────────────
{
costDivinity: 0,
costPrayers: 50,
costStardust: 0,
description: "Novices meditate on the basic prayers and double their output.",
discipleId: "novice",
id: "novice_blessing",
multiplier: 2,
name: "Novice Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 0,
costPrayers: 200,
costStardust: 0,
description: "Initiates channel deeper devotion and double their prayer generation.",
discipleId: "initiate",
id: "initiate_blessing",
multiplier: 2,
name: "Initiate Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 1,
costPrayers: 1000,
costStardust: 0,
description: "Acolytes unlock deeper devotion rites that double their prayer output.",
discipleId: "acolyte",
id: "acolyte_blessing",
multiplier: 2,
name: "Acolyte Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 2,
costPrayers: 5000,
costStardust: 0,
description: "Devotees achieve deeper attunement with the divine and double their output.",
discipleId: "devotee",
id: "devotee_blessing",
multiplier: 2,
name: "Devotee Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 5,
costPrayers: 25_000,
costStardust: 0,
description: "Adepts reach mastery of the first prayer form, doubling their generation.",
discipleId: "adept",
id: "adept_blessing",
multiplier: 2,
name: "Adept Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 12,
costPrayers: 150_000,
costStardust: 0,
description: "Priests achieve full communion with the divine and double their prayer output.",
discipleId: "priest",
id: "priest_blessing",
multiplier: 2,
name: "Priest Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 30,
costPrayers: 1_000_000,
costStardust: 0,
description: "High priests channel the goddess's voice directly and double their prayer generation.",
discipleId: "high_priest",
id: "high_priest_blessing",
multiplier: 2,
name: "High Priest Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 80,
costPrayers: 7_000_000,
costStardust: 0,
description: "Divine scholars unlock the deepest understanding of prayer mechanics and double their output.",
discipleId: "divine_scholar",
id: "divine_scholar_blessing",
multiplier: 2,
name: "Divine Scholar Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 200,
costPrayers: 50_000_000,
costStardust: 0,
description: "Holy champions have mastered the art of sacred battle and doubled their fervour.",
discipleId: "holy_champion",
id: "holy_champion_blessing",
multiplier: 2,
name: "Holy Champion Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 500,
costPrayers: 350_000_000,
costStardust: 1,
description: "Celestial adepts have ascended beyond mortal devotion, drawing twice the divine resonance.",
discipleId: "celestial_adept",
id: "celestial_adept_blessing",
multiplier: 2,
name: "Celestial Adept Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 1200,
costPrayers: 1_750_000_000,
costStardust: 2,
description: "Seraphic masters commune directly with the source, generating prayers at twice the rate.",
discipleId: "seraphic_master",
id: "seraphic_master_blessing",
multiplier: 2,
name: "Seraphic Master Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 3000,
costPrayers: 8_000_000_000,
costStardust: 4,
description: "Divine invokers have woven themselves into the prayer lattice, doubling every invocation.",
discipleId: "divine_invoker",
id: "divine_invoker_blessing",
multiplier: 2,
name: "Divine Invoker Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 8000,
costPrayers: 40_000_000_000,
costStardust: 8,
description: "Astral templars carry the goddess's word across realms, amplifying their prayer twofold.",
discipleId: "astral_templar",
id: "astral_templar_blessing",
multiplier: 2,
name: "Astral Templar Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 20_000,
costPrayers: 200_000_000_000,
costStardust: 15,
description: "Empyrean heralds broadcast divine truth across the heavens, generating prayers at twice the scale.",
discipleId: "empyrean_herald",
id: "empyrean_herald_blessing",
multiplier: 2,
name: "Empyrean Herald Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
{
costDivinity: 50_000,
costPrayers: 1_000_000_000_000,
costStardust: 30,
description: "Primordial heralds echo from before the dawn of time, their prayers resounding doubly through eternity.",
discipleId: "primordial_herald",
id: "primordial_herald_blessing",
multiplier: 2,
name: "Primordial Herald Blessing",
purchased: false,
target: "disciple",
unlocked: false,
},
// ── Global Income ────────────────────────────────────────────────────────
{
costDivinity: 0,
costPrayers: 100,
costStardust: 0,
description: "Divine inspiration flows through the entire order. All production ×1.25.",
id: "divine_inspiration_1",
multiplier: 1.25,
name: "Divine Inspiration I",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 1,
costPrayers: 500,
costStardust: 0,
description: "Deeper inspiration resonates with every disciple's faith. All production ×1.5.",
id: "divine_inspiration_2",
multiplier: 1.5,
name: "Divine Inspiration II",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 3,
costPrayers: 2500,
costStardust: 0,
description: "The inspiration reaches its fullest expression — doubling everything the order produces. All production ×2.",
id: "divine_inspiration_3",
multiplier: 2,
name: "Divine Inspiration III",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 10,
costPrayers: 15_000,
costStardust: 0,
description: "Inspiration floods the entire divine order with fivefold power. All production ×5.",
id: "divine_inspiration_4",
multiplier: 5,
name: "Divine Inspiration IV",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 30,
costPrayers: 100_000,
costStardust: 0,
description: "The goddess's own inspiration touches every soul in the order. All production ×10.",
id: "divine_inspiration_5",
multiplier: 10,
name: "Divine Inspiration V",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 80,
costPrayers: 750_000,
costStardust: 1,
description: "The goddess's will reshapes the order itself, elevating every act of worship. All production ×25.",
id: "celestial_mandate_1",
multiplier: 25,
name: "Celestial Mandate I",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 250,
costPrayers: 5_000_000,
costStardust: 3,
description: "A celestial decree multiplies the fruits of every prayer, deed, and devotion. All production ×50.",
id: "celestial_mandate_2",
multiplier: 50,
name: "Celestial Mandate II",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 800,
costPrayers: 30_000_000,
costStardust: 8,
description: "The fabric of the divine order itself vibrates at the goddess's frequency. All production ×100.",
id: "divine_frequency",
multiplier: 100,
name: "Divine Frequency",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 2500,
costPrayers: 200_000_000,
costStardust: 20,
description: "All boundaries between sacred and mundane dissolve — everything becomes an act of worship. All production ×250.",
id: "sacred_dissolution",
multiplier: 250,
name: "Sacred Dissolution",
purchased: false,
target: "global",
unlocked: false,
},
{
costDivinity: 8000,
costPrayers: 1_200_000_000,
costStardust: 50,
description: "The goddess breathes life into every output of the divine order without exception. All production ×500.",
id: "breath_of_the_goddess",
multiplier: 500,
name: "Breath of the Goddess",
purchased: false,
target: "global",
unlocked: false,
},
// ── Consecration ─────────────────────────────────────────────────────────
{
costDivinity: 2,
costPrayers: 500,
costStardust: 0,
description: "Enhanced consecration rites boost divine income after each rebirth. Consecration production multiplier ×1.25.",
id: "consecration_power_1",
multiplier: 1.25,
name: "Consecration Power I",
purchased: false,
target: "consecration",
unlocked: false,
},
{
costDivinity: 10,
costPrayers: 5000,
costStardust: 0,
description: "Deeper consecration rites push the rebirth multiplier further. Consecration production multiplier ×1.5.",
id: "consecration_power_2",
multiplier: 1.5,
name: "Consecration Power II",
purchased: false,
target: "consecration",
unlocked: false,
},
{
costDivinity: 35,
costPrayers: 30_000,
costStardust: 0,
description: "The consecration ritual reaches its apex, doubling what each sacred rebirth returns. Consecration production multiplier ×2.",
id: "consecration_power_3",
multiplier: 2,
name: "Consecration Power III",
purchased: false,
target: "consecration",
unlocked: false,
},
{
costDivinity: 120,
costPrayers: 200_000,
costStardust: 1,
description: "Consecration transcends ritual and becomes a fundamental law of the divine order. Consecration production multiplier ×3.",
id: "consecration_law_1",
multiplier: 3,
name: "Consecration Law I",
purchased: false,
target: "consecration",
unlocked: false,
},
{
costDivinity: 400,
costPrayers: 1_500_000,
costStardust: 3,
description: "Each rebirth now unleashes a torrent of divine energy fivefold beyond its former scope. Consecration production multiplier ×5.",
id: "consecration_law_2",
multiplier: 5,
name: "Consecration Law II",
purchased: false,
target: "consecration",
unlocked: false,
},
{
costDivinity: 1500,
costPrayers: 10_000_000,
costStardust: 8,
description: "The ultimate consecration insight — every cycle of rebirth rings with tenfold divine reward. Consecration production multiplier ×10.",
id: "consecration_apotheosis",
multiplier: 10,
name: "Consecration Apotheosis",
purchased: false,
target: "consecration",
unlocked: false,
},
// ── Combat ───────────────────────────────────────────────────────────────
{
costDivinity: 0,
costPrayers: 75,
costStardust: 0,
description: "Sacred combat training strengthens every disciple's fighting spirit. Combat power ×1.25.",
id: "sacred_combat_1",
multiplier: 1.25,
name: "Sacred Combat I",
purchased: false,
target: "boss",
unlocked: false,
},
{
costDivinity: 1,
costPrayers: 300,
costStardust: 0,
description: "Advanced combat rites push disciples' power further. Combat power ×1.5.",
id: "sacred_combat_2",
multiplier: 1.5,
name: "Sacred Combat II",
purchased: false,
target: "boss",
unlocked: false,
},
{
costDivinity: 3,
costPrayers: 1500,
costStardust: 0,
description: "The highest combat discipline doubles every disciple's power in divine battles. Combat power ×2.",
id: "sacred_combat_3",
multiplier: 2,
name: "Sacred Combat III",
purchased: false,
target: "boss",
unlocked: false,
},
{
costDivinity: 10,
costPrayers: 10_000,
costStardust: 0,
description: "Divine wrath is channelled through the fists of every warrior in the order. Combat power ×3.",
id: "divine_wrath_1",
multiplier: 3,
name: "Divine Wrath I",
purchased: false,
target: "boss",
unlocked: false,
},
{
costDivinity: 40,
costPrayers: 80_000,
costStardust: 1,
description: "Wrath given form — the goddess's judgement multiplies the strike of every disciple fivefold. Combat power ×5.",
id: "divine_wrath_2",
multiplier: 5,
name: "Divine Wrath II",
purchased: false,
target: "boss",
unlocked: false,
},
{
costDivinity: 150,
costPrayers: 600_000,
costStardust: 3,
description: "The full fury of the goddess incarnate flows through every blade, fist, and prayer-strike. Combat power ×10.",
id: "wrath_incarnate",
multiplier: 10,
name: "Wrath Incarnate",
purchased: false,
target: "boss",
unlocked: false,
},
// ── Utility ──────────────────────────────────────────────────────────────
{
costDivinity: 3,
costPrayers: 1000,
costStardust: 0,
description: "Unlock the Auto-Disciple toggle. When enabled, the tick engine will automatically recruit the highest-tier disciple you can afford.",
id: "auto_disciple",
multiplier: 1,
name: "Autonomous Devotion",
purchased: false,
target: "global",
unlocked: false,
},
];
+191
View File
@@ -0,0 +1,191 @@
/**
* @file Game data definitions.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Data content */
import type { GoddessZone } from "@elysium/types";
export const defaultGoddessZones: Array<GoddessZone> = [
{
description:
"A realm of endless bloom where divine flowers grow in patterns that mirror the stars above. This is where newly awakened disciples take their first steps into the goddess's domain — and where the hardest part of the journey quietly begins.",
emoji: "🌸",
id: "goddess_celestial_garden",
name: "The Celestial Garden",
status: "locked",
unlockBossId: null,
unlockQuestId: null,
},
{
description:
"A vast temple of living crystal whose facets hold every divine truth ever recorded. The scholars here do not distinguish between knowing something and becoming it.",
emoji: "💎",
id: "goddess_crystal_sanctum",
name: "The Crystal Sanctum",
status: "locked",
unlockBossId: "heavenly_warden",
unlockQuestId: "first_prayer",
},
{
description:
"A cathedral suspended in the astral plane, its spires reaching into realms that have no name. The choir that fills it has been singing the same hymn since before mortals learned to speak.",
emoji: "✨",
id: "goddess_astral_cathedral",
name: "The Astral Cathedral",
status: "locked",
unlockBossId: "sanctum_keeper",
unlockQuestId: "divine_meditation",
},
{
description:
"The fortress of the celestial host — a bastion of divine authority so immense that mortal minds instinctively refuse to estimate its size. The warriors here have never known defeat.",
emoji: "🏰",
id: "goddess_empyrean_citadel",
name: "The Empyrean Citadel",
status: "locked",
unlockBossId: "cathedral_warden",
unlockQuestId: "astral_revelation",
},
{
description:
"The wellspring of all creation — the point from which every soul, every star, and every prayer ultimately originates. Standing here is not comfortable. It is, however, true.",
emoji: "🌊",
id: "goddess_primordial_springs",
name: "The Primordial Springs",
status: "locked",
unlockBossId: "citadel_guardian",
unlockQuestId: "empyrean_ascent",
},
{
description:
"The highest reach of the divine realm — where the goddess herself resides, and where all divine law originates. Everything below this point is a reflection of what exists here. Nothing here reflects anything else.",
emoji: "👑",
id: "goddess_eternal_firmament",
name: "The Eternal Firmament",
status: "locked",
unlockBossId: "wellspring_warden",
unlockQuestId: "springs_blessing",
},
{
description:
"An ancient grove where divine trees have grown for longer than the firmament has existed. Their roots drink directly from the primordial springs, and their canopy touches realms that have no name. Pilgrims do not enter this grove. They are invited.",
emoji: "🌿",
id: "goddess_sacred_grove",
name: "The Sacred Grove",
status: "locked",
unlockBossId: "the_goddess_avatar",
unlockQuestId: "eternal_ascension",
},
{
description:
"A realm of pure, unfiltered divine radiance — light made absolute, stripped of shadow, warmth, or mercy. Those who walk here must learn to see without their eyes, because the light does not illuminate. It simply is.",
emoji: "☀️",
id: "goddess_luminous_expanse",
name: "The Luminous Expanse",
status: "locked",
unlockBossId: "grove_sovereign",
unlockQuestId: "grove_harmony",
},
{
description:
"The forge where the goddess shaped the first stars and the last laws of existence. The heat here is not heat — it is conviction made manifest. Every tool ever used to build a world was made here, and every one of them remembers.",
emoji: "🔥",
id: "goddess_heavenly_forge",
name: "The Heavenly Forge",
status: "locked",
unlockBossId: "light_titan",
unlockQuestId: "light_transcendence",
},
{
description:
"The sanctum of the divine oracle — the seat of all prophecy, all foresight, and all terrible knowledge that cannot be unlearned. The oracle does not grant visions here. The visions grant themselves, and they do not ask permission.",
emoji: "🔮",
id: "goddess_oracle_sanctum",
name: "The Oracle Sanctum",
status: "locked",
unlockBossId: "forge_master",
unlockQuestId: "forge_mastery",
},
{
description:
"The nesting ground of the seraphim — creatures that were never mortal, never made, and have no concept of a time before themselves. The nest is not a physical place. It is a state of being that those who reach it will never fully leave.",
emoji: "🪶",
id: "goddess_seraphs_nest",
name: "The Seraph's Nest",
status: "locked",
unlockBossId: "grand_oracle",
unlockQuestId: "oracle_truth",
},
{
description:
"The repository of all divine knowledge — every truth the goddess has ever uttered, every law she has written, every act of creation she has witnessed. The archive is infinite, and it is still growing. The librarians here have forgotten how long they have served.",
emoji: "📜",
id: "goddess_divine_archive",
name: "The Divine Archive",
status: "locked",
unlockBossId: "supreme_seraph",
unlockQuestId: "seraph_ascension",
},
{
description:
"The depths that exist beneath the divine realm — the sacred abyss from which the goddess drew the first darkness that gives light its meaning. Nothing down here is evil. It is simply the part of creation that was never meant to be seen.",
emoji: "🕳️",
id: "goddess_consecrated_depths",
name: "The Consecrated Depths",
status: "locked",
unlockBossId: "archive_guardian",
unlockQuestId: "archive_completion",
},
{
description:
"The point where all divine currents converge — the confluence of every law, every prayer, every act of faith ever offered. Standing here is not a spiritual experience. It is a mathematical one. You are the solution to an equation the goddess has been solving since the beginning.",
emoji: "🌌",
id: "goddess_astral_confluence",
name: "The Astral Confluence",
status: "locked",
unlockBossId: "depths_sovereign",
unlockQuestId: "depths_revelation",
},
{
description:
"The throne from which the goddess first looked down upon creation and chose to love it. It has not been sat in since. To approach it is to feel the weight of that choice pressing against you — the full, impossible gravity of a god who decided the universe was worth keeping.",
emoji: "⚡",
id: "goddess_celestial_throne",
name: "The Celestial Throne",
status: "locked",
unlockBossId: "confluence_arbiter",
unlockQuestId: "confluence_alignment",
},
{
description:
"The choir of infinite voices — every soul that has ever achieved divinity, still singing the hymn that carried them there. The music here does not end. It has never ended. It is, in the strictest sense, the sound of eternity doing what eternity does.",
emoji: "🎵",
id: "goddess_infinite_choir",
name: "The Infinite Choir",
status: "locked",
unlockBossId: "throne_guardian",
unlockQuestId: "throne_recognition",
},
{
description:
"The veil that separates the divine realm from whatever lies beyond it — the last border between what can be known and what the goddess herself has chosen not to look at directly. Crossing it is not forbidden. It is simply unprecedented.",
emoji: "🌫️",
id: "goddess_veil",
name: "The Veil",
status: "locked",
unlockBossId: "choir_conductor",
unlockQuestId: "choir_perfection",
},
{
description:
"The heart of the divine — the absolute centre of the goddess's being, the point from which all her love and all her law and all her creation ultimately originates. You have walked the entire length of her domain to stand here. She has been waiting. She is not surprised.",
emoji: "💖",
id: "goddess_divine_heart",
name: "The Divine Heart",
status: "locked",
unlockBossId: "veil_guardian",
unlockQuestId: "veil_crossing",
},
];
+73 -1
View File
@@ -9,14 +9,25 @@ import { defaultAdventurers } from "./adventurers.js";
import { defaultBosses } from "./bosses.js";
import { defaultEquipment } from "./equipment.js";
import { defaultExplorations } from "./explorations.js";
import { defaultGoddessAchievements } from "./goddessAchievements.js";
import { defaultGoddessBosses } from "./goddessBosses.js";
import { defaultGoddessDisciples } from "./goddessDisciples.js";
import { defaultGoddessEquipment } from "./goddessEquipment.js";
import { defaultGoddessExplorationAreas } from "./goddessExplorations.js";
import { defaultGoddessQuests } from "./goddessQuests.js";
import { defaultGoddessUpgrades } from "./goddessUpgrades.js";
import { defaultGoddessZones } from "./goddessZones.js";
import { defaultQuests } from "./quests.js";
import { currentSchemaVersion } from "./schemaVersion.js";
import { defaultUpgrades } from "./upgrades.js";
import { defaultZones } from "./zones.js";
import type {
ApotheosisData,
ConsecrationData,
EnlightenmentData,
ExplorationState,
GameState,
GoddessState,
Player,
PrestigeData,
TranscendenceData,
@@ -62,6 +73,65 @@ const initialExploration: ExplorationState = {
materials: [],
};
const initialConsecration: ConsecrationData = {
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
};
const initialEnlightenment: EnlightenmentData = {
count: 0,
purchasedUpgradeIds: [],
stardust: 0,
stardustCombatMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustMetaMultiplier: 1,
stardustPrayersMultiplier: 1,
};
/**
* Builds a fresh initial goddess state for a player who has just completed their
* first Apotheosis. All goddess content is locked until progressed through the realm.
* @returns A clean GoddessState with all default data.
*/
const initialGoddessState = (): GoddessState => {
return {
achievements: structuredClone(defaultGoddessAchievements),
baseClickPower: 1,
bosses: structuredClone(defaultGoddessBosses),
consecration: { ...initialConsecration },
disciples: structuredClone(defaultGoddessDisciples),
enlightenment: { ...initialEnlightenment },
equipment: structuredClone(defaultGoddessEquipment),
exploration: {
areas: defaultGoddessExplorationAreas.map((area) => {
return {
id: area.id,
status:
area.zoneId === "goddess_celestial_garden"
? ("available" as const)
: ("locked" as const),
};
}),
craftedCombatMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedPrayersMultiplier: 1,
craftedRecipeIds: [],
materials: [],
},
lastTickAt: Date.now(),
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: structuredClone(defaultGoddessQuests),
totalPrayersEarned: 0,
upgrades: structuredClone(defaultGoddessUpgrades),
zones: structuredClone(defaultGoddessZones),
};
};
/**
* Builds an initial game state for a new player.
* @param player - The player data from Discord OAuth.
@@ -76,6 +146,8 @@ const initialGameState = (
achievements: structuredClone(defaultAchievements),
adventurers: structuredClone(defaultAdventurers),
apotheosis: { ...initialApotheosis },
autoBoss: false,
autoQuest: false,
baseClickPower: 1,
bosses: structuredClone(defaultBosses),
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
@@ -103,4 +175,4 @@ const initialGameState = (
};
};
export { initialExploration, initialGameState };
export { initialExploration, initialGameState, initialGoddessState };
+15 -6
View File
@@ -92,20 +92,20 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
{
category: "income",
description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
"The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
id: "income_10",
multiplier: 500,
multiplier: 200,
name: "Eternal Rune I",
runestonesCost: 30_000,
runestonesCost: 15_000,
},
{
category: "income",
description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
"Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
id: "income_11",
multiplier: 1000,
multiplier: 500,
name: "Eternal Rune II",
runestonesCost: 80_000,
runestonesCost: 35_000,
},
// ── Click Power ───────────────────────────────────────────────────────────
{
@@ -210,6 +210,15 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
runestonesCost: 1200,
},
// ── 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",
description:
File diff suppressed because it is too large Load Diff
+95 -14
View File
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "verdant_vale",
},
{
bonus: { type: "combat_power", value: 1.08 },
bonus: { type: "combat_power", value: 1.2 },
description:
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
id: "elder_bark_shield",
@@ -75,7 +75,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "frozen_peaks",
},
{
bonus: { type: "gold_income", value: 1.1 },
bonus: { type: "gold_income", value: 1.15 },
description:
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
id: "void_fragment_amulet",
@@ -101,7 +101,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "shadow_marshes",
},
{
bonus: { type: "combat_power", value: 1.1 },
bonus: { type: "combat_power", value: 1.15 },
description:
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
id: "cursed_focus",
@@ -127,7 +127,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "volcanic_depths",
},
{
bonus: { type: "combat_power", value: 1.12 },
bonus: { type: "combat_power", value: 1.2 },
description:
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
id: "elemental_ore_ingot",
@@ -193,7 +193,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 8: abyssal_trench
{
bonus: { type: "combat_power", value: 1.15 },
bonus: { type: "combat_power", value: 1.25 },
description:
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
id: "pressure_forged_core",
@@ -231,7 +231,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "infernal_court",
},
{
bonus: { type: "essence_income", value: 1.15 },
bonus: { type: "essence_income", value: 1.2 },
description:
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
id: "soul_bound_catalyst",
@@ -271,7 +271,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 11: void_sanctum
{
bonus: { type: "combat_power", value: 1.18 },
bonus: { type: "combat_power", value: 1.28 },
description:
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
id: "null_field_generator",
@@ -309,7 +309,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "eternal_throne",
},
{
bonus: { type: "combat_power", value: 1.2 },
bonus: { type: "combat_power", value: 1.3 },
description:
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
id: "eternity_bound_ring",
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 13: primordial_chaos
{
bonus: { type: "click_power", value: 1.2 },
bonus: { type: "click_power", value: 1.22 },
description:
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
id: "chaos_lens",
@@ -375,7 +375,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 15: reality_forge
{
bonus: { type: "combat_power", value: 1.22 },
bonus: { type: "combat_power", value: 1.35 },
description:
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
id: "reality_ingot",
@@ -387,7 +387,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "reality_forge",
},
{
bonus: { type: "click_power", value: 1.22 },
bonus: { type: "click_power", value: 1.25 },
description:
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
id: "universe_seed",
@@ -427,7 +427,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 17: primeval_sanctum
{
bonus: { type: "combat_power", value: 1.25 },
bonus: { type: "combat_power", value: 1.4 },
description:
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
id: "ancient_memory_array",
@@ -439,7 +439,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum",
},
{
bonus: { type: "click_power", value: 1.25 },
bonus: { type: "click_power", value: 1.28 },
description:
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
id: "first_artefact",
@@ -451,7 +451,88 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum",
},
// ── Cross-zone recipes ─────────────────────────────────────────────────────
{
bonus: { type: "gold_income", value: 1.28 },
description:
"Verdant sap from the oldest trees, refined in ember crystal heat and bound by legendary ore from the volcanic forges. The resulting tincture fuses the forest's patient growth with fire's relentless drive — gold accumulates with unusual enthusiasm.",
id: "verdant_pyre_seal",
name: "Verdant Pyre Seal",
requiredMaterials: [
{ materialId: "verdant_sap", quantity: 8 },
{ materialId: "ember_crystal", quantity: 6 },
{ materialId: "legendary_ore", quantity: 2 },
],
zoneId: "volcanic_depths",
},
{
bonus: { type: "click_power", value: 1.22 },
description:
"A void shard frozen into glacial ice and then submerged in shadow essence — the cold of nothing meeting the dark of everything. The resulting weave sharpens strikes with an emptiness that the shadows themselves cannot resist.",
id: "voidfrost_weave",
name: "Voidfrost Weave",
requiredMaterials: [
{ materialId: "glacial_ice", quantity: 8 },
{ materialId: "void_shard", quantity: 3 },
{ materialId: "shadow_essence", quantity: 5 },
],
zoneId: "shadow_marshes",
},
{
bonus: { type: "essence_income", value: 1.28 },
description:
"A choir shard from the celestial reaches lowered into the crushing dark of the abyssal trench and set alongside an ancient tooth. The celestial harmonic does not stop in the deep — it deepens. Essence flows toward it from every direction simultaneously.",
id: "choir_of_the_deep",
name: "Choir of the Deep",
requiredMaterials: [
{ materialId: "celestial_dust", quantity: 8 },
{ materialId: "choir_shard", quantity: 2 },
{ materialId: "ancient_tooth", quantity: 2 },
{ materialId: "pressure_gem", quantity: 5 },
],
zoneId: "abyssal_trench",
},
{
bonus: { type: "click_power", value: 1.38 },
description:
"A primeval relic submerged at the absolute boundary of existence alongside omega crystals and boundary shards — the first and last thing, unified. Every action your guild takes through it is simultaneously the most ancient and most final thing that has ever happened. It does not miss.",
id: "primal_omega_lens",
name: "Primal Omega Lens",
requiredMaterials: [
{ materialId: "primeval_relic", quantity: 2 },
{ materialId: "boundary_shard", quantity: 4 },
{ materialId: "omega_crystal", quantity: 2 },
],
zoneId: "the_absolute",
},
{
bonus: { type: "combat_power", value: 1.65 },
description:
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
id: "eternal_omega",
name: "Eternal Omega",
requiredMaterials: [
{ materialId: "crown_fragment", quantity: 6 },
{ materialId: "eternity_splinter", quantity: 2 },
{ materialId: "boundary_shard", quantity: 4 },
{ materialId: "omega_crystal", quantity: 2 },
],
zoneId: "the_absolute",
},
// Zone 18: the_absolute
{
bonus: { type: "click_power", value: 1.3 },
description:
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
id: "absolute_focus",
name: "Absolute Focus",
requiredMaterials: [
{ materialId: "absolute_fragment", quantity: 8 },
{ materialId: "omega_crystal", quantity: 3 },
],
zoneId: "the_absolute",
},
{
bonus: { type: "gold_income", value: 1.3 },
description:
@@ -465,7 +546,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "the_absolute",
},
{
bonus: { type: "combat_power", value: 1.3 },
bonus: { type: "combat_power", value: 1.55 },
description:
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
id: "omega_convergence",
+1 -1
View File
@@ -8,4 +8,4 @@
/**
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
*/
export const currentSchemaVersion = 1;
export const currentSchemaVersion = 2;
+15 -15
View File
@@ -11,7 +11,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Income multipliers ──────────────────────────────────────────────────────
{
category: "income",
cost: 5,
cost: 2,
description:
"The echoes of past runs linger, amplifying your guild's income by 25%.",
id: "echo_income_1",
@@ -20,7 +20,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 10,
cost: 4,
description:
"Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2",
@@ -29,7 +29,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 20,
cost: 8,
description:
"The harmony of multiple timelines surges through your guild, doubling its income.",
id: "echo_income_3",
@@ -38,7 +38,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 40,
cost: 16,
description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4",
@@ -47,7 +47,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "income",
cost: 80,
cost: 32,
description:
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
id: "echo_income_5",
@@ -58,7 +58,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Combat multipliers ──────────────────────────────────────────────────────
{
category: "combat",
cost: 5,
cost: 2,
description:
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
id: "echo_combat_1",
@@ -67,7 +67,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "combat",
cost: 15,
cost: 6,
description:
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
id: "echo_combat_2",
@@ -76,7 +76,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "combat",
cost: 35,
cost: 12,
description:
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
id: "echo_combat_3",
@@ -87,7 +87,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Prestige threshold reductions ──────────────────────────────────────────
{
category: "prestige_threshold",
cost: 8,
cost: 3,
description:
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
id: "echo_prestige_threshold_1",
@@ -96,7 +96,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "prestige_threshold",
cost: 20,
cost: 6,
description:
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
id: "echo_prestige_threshold_2",
@@ -107,7 +107,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Prestige runestone multipliers ─────────────────────────────────────────
{
category: "prestige_runestones",
cost: 8,
cost: 3,
description:
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
id: "echo_prestige_runestones_1",
@@ -116,7 +116,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "prestige_runestones",
cost: 20,
cost: 6,
description:
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
id: "echo_prestige_runestones_2",
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
category: "echo_meta",
cost: 10,
cost: 15,
description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 25,
cost: 45,
description:
"Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2",
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 50,
cost: 100,
description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3",
+167 -22
View File
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
{
costCrystals: 100,
costCrystals: 50,
costEssence: 0,
costGold: 0,
description:
@@ -104,7 +104,7 @@ export const defaultUpgrades: Array<Upgrade> = [
description:
"Forge partnerships with mage guilds across the realm. All income +50%.",
id: "essence_guild",
multiplier: 1.5,
multiplier: 2,
name: "Essence Guild",
purchased: false,
target: "global",
@@ -162,6 +162,34 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
unlocked: false,
},
{
adventurerId: "peasant",
costCrystals: 0,
costEssence: 20,
costGold: 0,
description:
"Organised labour guilds and proper scheduling make peasants ten times more productive.",
id: "peasant_2",
multiplier: 10,
name: "Guild Organisation",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "peasant",
costCrystals: 50,
costEssence: 0,
costGold: 0,
description:
"Magical augmentation through crystalline resonance supercharges even the humblest worker.",
id: "peasant_3",
multiplier: 50,
name: "Crystal Augmentation",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "militia",
costCrystals: 0,
@@ -181,7 +209,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 2,
costGold: 5000,
description: "Ancient books of magic double mage output.",
id: "mage_1",
id: "apprentice_1",
multiplier: 2,
name: "Arcane Tomes",
purchased: false,
@@ -194,7 +222,7 @@ export const defaultUpgrades: Array<Upgrade> = [
costEssence: 3,
costGold: 8000,
description: "Sacred ceremonies double the output of your clerics.",
id: "cleric_1",
id: "acolyte_1",
multiplier: 2,
name: "Holy Rites",
purchased: false,
@@ -269,23 +297,10 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
unlocked: false,
},
{
adventurerId: "shadow_assassin",
costCrystals: 0,
costEssence: 50,
costGold: 0,
description: "Mastery of the shadow arts doubles assassin effectiveness.",
id: "shadow_assassin_1",
multiplier: 2,
name: "Shadow Arts",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "arcane_scholar",
costCrystals: 0,
costEssence: 150,
costEssence: 1000,
costGold: 0,
description: "Access to forbidden libraries doubles scholar output.",
id: "arcane_scholar_1",
@@ -295,10 +310,37 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
unlocked: false,
},
{
adventurerId: "shadow_assassin",
costCrystals: 0,
costEssence: 5000,
costGold: 0,
description: "Mastery of the shadow arts doubles assassin effectiveness.",
id: "shadow_assassin_1",
multiplier: 2,
name: "Shadow Arts",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "dark_templar",
costCrystals: 0,
costEssence: 25_000,
costGold: 0,
description:
"A sworn oath to the darkness of the marshes doubles templar output.",
id: "dark_templar_1",
multiplier: 2,
name: "Templar's Oath",
purchased: false,
target: "adventurer",
unlocked: false,
},
{
adventurerId: "void_walker",
costCrystals: 0,
costEssence: 300,
costEssence: 100_000,
costGold: 0,
description:
"Walking through the void itself doubles the output of your void walkers.",
@@ -312,7 +354,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{
adventurerId: "celestial_guard",
costCrystals: 0,
costEssence: 750,
costEssence: 500_000,
costGold: 0,
description:
"A blessing from the celestials themselves doubles guard output.",
@@ -326,7 +368,7 @@ export const defaultUpgrades: Array<Upgrade> = [
{
adventurerId: "divine_champion",
costCrystals: 0,
costEssence: 2000,
costEssence: 2_000_000,
costGold: 0,
description: "An unbreakable oath to the divine doubles champion output.",
id: "divine_champion_1",
@@ -417,7 +459,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
{
costCrystals: 10_000_000,
costCrystals: 50_000_000,
costEssence: 0,
costGold: 0,
description: "Transcend mortal limits through void energy. All income x3.",
@@ -454,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
{
costCrystals: 3000,
costEssence: 0,
costGold: 0,
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
id: "crystal_pulse",
multiplier: 1.5,
name: "Crystal Pulse",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 20_000,
costEssence: 0,
costGold: 0,
description:
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
id: "crystal_surge",
multiplier: 2,
name: "Crystal Surge",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 150_000,
costEssence: 0,
costGold: 0,
description: "Your guild's operations are saturated with crystalline power. All income x3.",
id: "crystal_tempest",
multiplier: 3,
name: "Crystal Tempest",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 0,
costEssence: 5_000_000,
@@ -767,4 +846,70 @@ export const defaultUpgrades: Array<Upgrade> = [
target: "adventurer",
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,
},
];
+45 -5
View File
@@ -7,22 +7,33 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { logger as honoLogger } from "hono/logger";
import { aboutRouter } from "./routes/about.js";
import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js";
import { consecrationRouter } from "./routes/consecration.js";
import { craftRouter } from "./routes/craft.js";
import { debugRouter } from "./routes/debug.js";
import { enlightenmentRouter } from "./routes/enlightenment.js";
import { exploreRouter } from "./routes/explore.js";
import { frontendRouter } from "./routes/frontend.js";
import { gameRouter } from "./routes/game.js";
import { goddessBossRouter } from "./routes/goddessBoss.js";
import { goddessCraftRouter } from "./routes/goddessCraft.js";
import { goddessExploreRouter } from "./routes/goddessExplore.js";
import { goddessUpgradeRouter } from "./routes/goddessUpgrade.js";
import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js";
import { timersRouter } from "./routes/timers.js";
import { transcendenceRouter } from "./routes/transcendence.js";
import { connectGateway } from "./services/gateway.js";
import { logger } from "./services/logger.js";
const app = new Hono();
app.use("*", logger());
app.use("*", honoLogger());
app.use(
"*",
cors({
@@ -33,6 +44,8 @@ app.use(
);
app.route("/about", aboutRouter);
app.route("/debug", debugRouter);
app.route("/fe", frontendRouter);
app.route("/auth", authRouter);
app.route("/game", gameRouter);
app.route("/boss", bossRouter);
@@ -41,15 +54,42 @@ app.route("/craft", craftRouter);
app.route("/prestige", prestigeRouter);
app.route("/transcendence", transcendenceRouter);
app.route("/apotheosis", apotheosisRouter);
app.route("/goddess-boss", goddessBossRouter);
app.route("/consecration", consecrationRouter);
app.route("/enlightenment", enlightenmentRouter);
app.route("/goddess-upgrade", goddessUpgradeRouter);
app.route("/goddess-craft", goddessCraftRouter);
app.route("/goddess-explore", goddessExploreRouter);
app.route("/leaderboards", leaderboardRouter);
app.route("/profile", profileRouter);
app.route("/timers", timersRouter);
app.get("/health", (context) => {
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);
serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
});
try {
serve({ fetch: app.fetch, port: port }, () => {
process.stdout.write(`Elysium API running on port ${String(port)}\n`);
connectGateway();
});
} catch (error) {
void logger.error(
"server_startup",
error instanceof Error
? error
: new Error(String(error)),
);
}
+12 -1
View File
@@ -6,6 +6,7 @@
*/
import { verifyToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { MiddlewareHandler } from "hono";
@@ -33,7 +34,17 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
try {
const payload = verifyToken(token);
context.set("discordId", payload.discordId);
} catch {
} catch (error) {
const isExpiredToken
= error instanceof Error && error.message === "Token has expired";
if (!isExpiredToken) {
void logger.error(
"auth_middleware",
error instanceof Error
? error
: new Error(String(error)),
);
}
return context.json({ error: "Invalid or expired token" }, 401);
}
+19 -6
View File
@@ -7,6 +7,7 @@
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono";
import { logger } from "../services/logger.js";
import type { AboutResponse, GiteaRelease } from "@elysium/types";
// eslint-disable-next-line capitalized-comments -- v8 ignore
@@ -46,12 +47,24 @@ const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
const aboutRouter = new Hono();
aboutRouter.get("/", async(context) => {
const releases = await fetchReleases();
const body: AboutResponse = {
apiVersion,
releases,
};
return context.json(body);
try {
const releases = await fetchReleases();
const body: AboutResponse = {
apiVersion,
releases,
};
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 };
+97 -82
View File
@@ -5,6 +5,8 @@
* @author Naomi Carrigan
*/
/* 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 */
import { Hono } from "hono";
import { prisma } from "../db/client.js";
@@ -13,6 +15,7 @@ import {
buildPostApotheosisState,
isEligibleForApotheosis,
} from "../services/apotheosis.js";
import { logger } from "../services/logger.js";
import {
grantApotheosisRole,
postMilestoneWebhook,
@@ -25,94 +28,106 @@ const apotheosisRouter = new Hono<HonoEnvironment>();
apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async(context) => {
const discordId = context.get("discordId");
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 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;
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 (!isEligibleForApotheosis(state)) {
return context.json(
{
error:
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
},
400,
);
}
if (!isEligibleForApotheosis(state)) {
return context.json(
{
error:
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
},
400,
);
}
// 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,
// Capture current-run stats before the nuclear reset
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
transcendence: updatedState.transcendence?.count ?? 0,
});
/* 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);
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 };
+21 -1
View File
@@ -15,6 +15,8 @@ import {
fetchDiscordUser,
} from "../services/discord.js";
import { signToken } from "../services/jwt.js";
import { logger } from "../services/logger.js";
import { grantElysianRole } from "../services/webhook.js";
import type { Player } from "@elysium/types";
const authRouter = new Hono();
@@ -91,7 +93,15 @@ authRouter.get("/callback", async(context) => {
},
});
const inGuild = await grantElysianRole(player.discordId);
await prisma.player.update({
data: { inGuild },
where: { discordId: player.discordId },
});
const jwtToken = signToken(player.discordId);
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
/* v8 ignore next -- @preserve */
@@ -101,16 +111,20 @@ authRouter.get("/callback", async(context) => {
);
}
const inGuild = await grantElysianRole(discordUser.id);
const updated = await prisma.player.update({
data: {
avatar: discordUser.avatar,
discriminator: discordUser.discriminator,
inGuild: inGuild,
username: discordUser.username,
},
where: { discordId: discordUser.id },
});
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
/* v8 ignore next -- @preserve */
@@ -118,7 +132,13 @@ authRouter.get("/callback", async(context) => {
return context.redirect(
`${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
/* v8 ignore next -- @preserve */
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
+297 -234
View File
@@ -8,6 +8,8 @@
/* eslint-disable max-statements -- Boss handler requires many statements */
/* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
import { createHmac } from "node:crypto";
import {
computeSetBonuses,
getActiveCompanionBonus,
@@ -17,11 +19,31 @@ import {
import { Hono } from "hono";
import { defaultBosses } from "../data/bosses.js";
import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { defaultExplorations } from "../data/explorations.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
/**
* Computes the HMAC-SHA256 of data using the given secret.
* @param data - The data string to sign.
* @param secret - The HMAC secret key.
* @returns The hex-encoded HMAC digest.
*/
const computeHmac = (data: string, secret: string): string => {
return createHmac("sha256", secret).update(data).
digest("hex");
};
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
* Must be kept in sync with prestigeCombatBase in apps/web/src/engine/tick.ts.
*/
const prestigeCombatBase = 4;
const bossRouter = new Hono<HonoEnvironment>();
bossRouter.use("*", authMiddleware);
@@ -36,8 +58,7 @@ const calculatePartyStats = (
}
}
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count);
// Apply equipped weapon's combat bonus
// eslint-disable-next-line capitalized-comments -- v8 ignore
@@ -121,254 +142,296 @@ const calculatePartyStats = (
};
bossRouter.post("/challenge", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<{ bossId: string }>();
try {
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
if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const 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;
}
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400);
}
// 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 record = await prisma.gameState.findUnique({ where: { discordId } });
const slotAlreadyEquipped = state.equipment.some((item) => {
return item.type === equipment.type && item.equipped;
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;
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward;
const crystalAward = boss.crystalReward * crystalMult;
state.resources.crystals = state.resources.crystals + crystalAward;
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) {
equipment.equipped = true;
if (upgrade) {
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";
// Unlock exploration areas for the newly unlocked zone
for (const area of state.exploration?.areas ?? []) {
const areaDefinition = defaultExplorations.find((explorationArea) => {
return explorationArea.id === area.id;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
area.status = "available";
}
}
const updatedZoneBosses = state.bosses.filter((b) => {
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: crystalAward,
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 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 — look up authoritative bounty from static data
const staticBoss = defaultBosses.find((b) => {
return b.id === body.bossId;
const now = Date.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 capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
const secret = process.env.ANTI_CHEAT_SECRET;
const updatedSignature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
rewards = {
bountyRunestones: bountyRunestones,
crystals: boss.crystalReward,
equipmentIds: boss.equipmentRewards,
essence: boss.essenceReward,
gold: boss.goldReward,
upgradeIds: boss.upgradeRewards,
const { bossId } = body;
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
const bossMaxHp = boss.maxHp;
const bossNewHp = bossUpdatedHp;
const response: BossChallengeResponse = {
bossDPS,
bossHpAtBattleEnd,
bossHpBefore,
bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
};
} 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 });
}
if (rewards !== undefined) {
response.rewards = rewards;
}
if (casualties !== undefined) {
response.casualties = casualties;
}
if (updatedSignature !== undefined) {
response.signature = updatedSignature;
}
}
const now = Date.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 },
});
const bossMaxHp = boss.maxHp;
const bossNewHp = bossUpdatedHp;
const response: BossChallengeResponse = {
bossDPS,
bossHpAtBattleEnd,
bossHpBefore,
bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
};
if (rewards !== undefined) {
response.rewards = rewards;
return context.json(response);
} catch (error) {
void logger.error(
"boss_challenge",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
if (casualties !== undefined) {
response.casualties = casualties;
}
return context.json(response);
});
export { bossRouter };
+188
View File
@@ -0,0 +1,188 @@
/**
* @file Consecration routes handling consecration resets and divinity upgrade purchases.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
import { Hono } from "hono";
import { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import {
buildPostConsecrationState,
calculateConsecrationThreshold,
computeConsecrationDivinityMultipliers,
isEligibleForConsecration,
} from "../services/consecration.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
BuyConsecrationUpgradeRequest,
ConsecrationResponse,
GameState,
} from "@elysium/types";
const consecrationRouter = new Hono<HonoEnvironment>();
consecrationRouter.use("*", authMiddleware);
consecrationRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
if (!isEligibleForConsecration(state)) {
const thresholdMultiplier
= state.goddess.enlightenment.stardustConsecrationThresholdMultiplier;
const required = calculateConsecrationThreshold(
state.goddess.consecration.count,
thresholdMultiplier,
);
return context.json(
{
error: `Not eligible for consecration — earn ${required.toLocaleString()} total prayers first`,
},
400,
);
}
const { divinityEarned, updatedGoddess } = buildPostConsecrationState(state);
const updatedConsecrationCount = updatedGoddess.consecration.count;
const updatedState: GameState = {
...state,
goddess: updatedGoddess,
resources: {
...state.resources,
prayers: 0,
},
};
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 },
});
void logger.metric("consecration", 1, { discordId, updatedConsecrationCount });
const response: ConsecrationResponse = {
divinityEarned: divinityEarned,
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newConsecrationCount: updatedConsecrationCount,
};
return context.json(response);
} catch (error) {
void logger.error(
"consecration",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
consecrationRouter.post("/buy-upgrade", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<BuyConsecrationUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = defaultConsecrationUpgrades.find((consecrationUpgrade) => {
return consecrationUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown consecration upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
const { purchasedUpgradeIds, divinity } = state.goddess.consecration;
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (divinity < upgrade.divinityCost) {
return context.json({ error: "Not enough divinity" }, 400);
}
const updatedDivinity = divinity - upgrade.divinityCost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers = computeConsecrationDivinityMultipliers(updatedPurchasedIds);
const updatedState: GameState = {
...state,
goddess: {
...state.goddess,
consecration: {
...state.goddess.consecration,
divinity: updatedDivinity,
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("consecration_upgrade_purchased", 1, { discordId, upgradeId });
return context.json({
divinityRemaining: updatedDivinity,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
});
} catch (error) {
void logger.error(
"consecration_buy_upgrade",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { consecrationRouter };
+115 -80
View File
@@ -11,6 +11,8 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
CraftRecipeRequest,
@@ -63,94 +65,127 @@ const recomputeCraftedMultipliers = (
};
craftRouter.post("/", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<CraftRecipeRequest>();
try {
const discordId = context.get("discordId");
const body = await context.req.json<CraftRecipeRequest>();
const { recipeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400);
}
const { recipeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!recipeId) {
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 recipe = defaultRecipes.find((r) => {
return r.id === recipeId;
});
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,
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
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;
if (state.dailyChallenges !== undefined) {
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
state.dailyChallenges,
"crafting",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
}
// Deduct materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => {
return m.materialId === requirement.materialId;
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 },
});
if (material) {
material.quantity = material.quantity - requirement.quantity;
}
void logger.metric("recipe_crafted", 1, { discordId, recipeId });
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const { materials } = state.exploration;
const {
craftedGoldMultiplier,
craftedEssenceMultiplier,
craftedClickMultiplier,
craftedCombatMultiplier,
} = updatedMultipliers;
const response: CraftRecipeResponse = {
bonusType,
bonusValue,
craftedClickMultiplier,
craftedCombatMultiplier,
craftedEssenceMultiplier,
craftedGoldMultiplier,
materials,
recipeId,
};
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 };
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
/**
* @file Enlightenment routes handling enlightenment resets and stardust upgrade purchases.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
import { Hono } from "hono";
import { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import {
buildPostEnlightenmentState,
computeEnlightenmentMultipliers,
isEligibleForEnlightenment,
} from "../services/enlightenment.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
BuyEnlightenmentUpgradeRequest,
EnlightenmentResponse,
GameState,
} from "@elysium/types";
const enlightenmentRouter = new Hono<HonoEnvironment>();
enlightenmentRouter.use("*", authMiddleware);
enlightenmentRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
if (!isEligibleForEnlightenment(state)) {
return context.json(
{
error: "Not eligible for enlightenment — defeat the Divine Heart Sovereign first",
},
400,
);
}
const { stardustEarned, updatedGoddess } = buildPostEnlightenmentState(state);
const updatedEnlightenmentCount = updatedGoddess.enlightenment.count;
const updatedState: GameState = {
...state,
goddess: updatedGoddess,
resources: {
...state.resources,
prayers: 0,
},
};
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 },
});
void logger.metric("enlightenment", 1, { discordId, updatedEnlightenmentCount });
const response: EnlightenmentResponse = {
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newEnlightenmentCount: updatedEnlightenmentCount,
stardustEarned: stardustEarned,
};
return context.json(response);
} catch (error) {
void logger.error(
"enlightenment",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
enlightenmentRouter.post("/buy-upgrade", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<BuyEnlightenmentUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = defaultEnlightenmentUpgrades.find((enlightenmentUpgrade) => {
return enlightenmentUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown enlightenment upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
const { purchasedUpgradeIds, stardust } = state.goddess.enlightenment;
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (stardust < upgrade.cost) {
return context.json({ error: "Not enough stardust" }, 400);
}
const updatedStardust = stardust - upgrade.cost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds);
const updatedState: GameState = {
...state,
goddess: {
...state.goddess,
enlightenment: {
...state.goddess.enlightenment,
purchasedUpgradeIds: updatedPurchasedIds,
stardust: updatedStardust,
...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("enlightenment_upgrade_purchased", 1, { discordId, upgradeId });
return context.json({
purchasedUpgradeIds: updatedPurchasedIds,
stardustRemaining: updatedStardust,
...updatedMultipliers,
});
} catch (error) {
void logger.error(
"enlightenment_buy_upgrade",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { enlightenmentRouter };
+336 -250
View File
@@ -7,13 +7,16 @@
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable max-lines -- Route file requires multiple handlers */
import { Hono } from "hono";
import { defaultExplorations } from "../data/explorations.js";
import { initialExploration } from "../data/initialState.js";
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 {
ExploreClaimableResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
@@ -48,281 +51,292 @@ const pickNothingMessage = (): string => {
return nothingMessages[index] ?? nothingMessages[0] ?? "";
};
exploreRouter.post("/start", async(context) => {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreStartRequest>();
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;
// Backfill exploration state for old saves that predate this feature
if (!state.exploration) {
state.exploration = structuredClone(initialExploration);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = defaultExplorations.find((areaItem) => {
return areaItem.id === area.id;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!areaData) {
continue;
}
const zone = state.zones.find((z) => {
return z.id === areaData.zoneId;
});
if (zone?.status === "unlocked") {
area.status = "available";
}
exploreRouter.get("/claimable", async(context) => {
try {
const discordId = context.get("discordId");
const areaId = context.req.query("areaId");
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
}
const zone = state.zones.find((z) => {
return z.id === explorationArea.zoneId;
});
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const explorationArea = defaultExplorations.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found in state" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const anyInProgress = state.exploration.areas.some((a) => {
return a.status === "in_progress";
});
if (anyInProgress) {
return context.json(
{ error: "An exploration is already in progress" },
400,
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.exploration) {
const response: ExploreClaimableResponse = { claimable: false };
return context.json(response);
}
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area || area.status !== "in_progress") {
const response: ExploreClaimableResponse = { claimable: false };
return context.json(response);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
const claimable = Date.now() >= expiresAt;
const response: ExploreClaimableResponse = { claimable };
return context.json(response);
} catch (error) {
void logger.error(
"explore_claimable",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400);
}
const now = Date.now();
area.status = "in_progress";
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>();
exploreRouter.post("/start", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreStartRequest>();
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 { 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 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 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 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);
}
// Backfill exploration state for old saves that predate this feature
if (!state.exploration) {
state.exploration = structuredClone(initialExploration);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = defaultExplorations.find((areaItem) => {
return areaItem.id === area.id;
});
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!areaData) {
continue;
}
const zone = state.zones.find((z) => {
return z.id === areaData.zoneId;
});
if (zone?.status === "unlocked") {
area.status = "available";
}
}
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const zone = state.zones.find((z) => {
return z.id === explorationArea.zoneId;
});
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const now = Date.now();
const area = state.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json(
{ error: "Exploration area not found in state" },
404,
);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
const anyInProgress = state.exploration.areas.some((a) => {
return a.status === "in_progress";
});
if (anyInProgress) {
return context.json(
{ error: "An exploration is already in progress" },
400,
);
}
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400);
}
area.status = "available";
area.completedOnce = true;
const now = Date.now();
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
area.status = "in_progress";
area.startedAt = now;
area.endsAt = endsAt;
// 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 },
});
const response: ExploreCollectResponse = {
event: null,
foundNothing: true,
materialsFound: [],
nothingMessage: pickNothingMessage(),
const response: ExploreStartResponse = {
areaId,
endsAt,
};
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
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
const event = explorationArea.events[eventIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
exploreRouter.post("/collect", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<ExploreCollectRequest>();
// Apply event effects and build the result summary
let goldChange = 0;
let essenceChange = 0;
let materialGained: { materialId: string; quantity: number } | null = null;
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);
}
if (event.effect.type === "gold_gain") {
// Gold gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.gold = state.resources.gold + amount;
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount;
} else if (event.effect.type === "gold_loss") {
// Gold loss — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
state.resources.gold = state.resources.gold - amount;
goldChange = -amount;
} else if (event.effect.type === "essence_gain") {
// Essence gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.essence = state.resources.essence + amount;
essenceChange = amount;
} else if (event.effect.type === "material_gain") {
const { materialId } = event.effect;
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 quantity = event.effect.quantity ?? 1;
if (materialId !== undefined && materialId !== "") {
const existing = state.exploration.materials.find((m) => {
return m.materialId === materialId;
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({
/* 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;
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialGained = { materialId, quantity };
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 13 -- @preserve */
const response: ExploreCollectResponse = {
event: null,
foundNothing: true,
materialsFound: [],
nothingMessage: pickNothingMessage(),
};
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
/* 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);
}
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
}
// 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;
}
}
// Apply event effects and build the result summary
let goldChange = 0;
let essenceChange = 0;
let materialGained: { materialId: string; quantity: number } | null = null;
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;
if (event.effect.type === "gold_gain") {
// Gold gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.gold = state.resources.gold + amount;
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
goldChange = amount;
} else if (event.effect.type === "gold_loss") {
// Gold loss — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
state.resources.gold = state.resources.gold - amount;
goldChange = -amount;
} else if (event.effect.type === "essence_gain") {
// Essence gain — amount may be undefined in edge cases
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.essence = state.resources.essence + amount;
essenceChange = amount;
} else if (event.effect.type === "material_gain") {
const { materialId } = event.effect;
// 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) => {
return m.materialId === materialId;
});
@@ -331,25 +345,97 @@ exploreRouter.post("/collect", async(context) => {
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialsFound.push({ materialId, quantity });
break;
materialGained = { materialId, quantity };
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* 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 };
+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 };
File diff suppressed because it is too large Load Diff
+415
View File
@@ -0,0 +1,415 @@
/**
* @file Goddess boss challenge route handling divine combat mechanics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
/* eslint-disable max-statements -- Boss handler requires many statements */
/* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
import { createHmac } from "node:crypto";
import {
computeGoddessSetBonuses,
type GameState,
type GoddessBossChallengeResponse,
} from "@elysium/types";
import { Hono } from "hono";
import { defaultGoddessBosses } from "../data/goddessBosses.js";
import { defaultGoddessEquipmentSets } from "../data/goddessEquipmentSets.js";
import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
/**
* Computes the HMAC-SHA256 of data using the given secret.
* @param data - The data string to sign.
* @param secret - The HMAC secret key.
* @returns The hex-encoded HMAC digest.
*/
const computeHmac = (data: string, secret: string): string => {
return createHmac("sha256", secret).update(data).
digest("hex");
};
const goddessBossRouter = new Hono<HonoEnvironment>();
goddessBossRouter.use("*", authMiddleware);
const calculateDiscipleStats = (
goddess: NonNullable<GameState["goddess"]>,
): { partyDPS: number; partyMaxHp: number } => {
let globalMultiplier = 1;
for (const upgrade of goddess.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier = globalMultiplier * upgrade.multiplier;
}
}
// Apply consecration production multiplier as a combat boost
const consecrationCombatMultiplier
= goddess.consecration.divinityCombatMultiplier ?? 1;
const { stardustCombatMultiplier } = goddess.enlightenment;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const equipmentCombatMultiplier = goddess.equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const equippedItemIds = goddess.equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
});
const { combatMultiplier: setCombatMultiplier } = computeGoddessSetBonuses(
equippedItemIds,
defaultGoddessEquipmentSets,
);
let partyDPS = 0;
let partyMaxHp = 0;
for (const disciple of goddess.disciples) {
if (disciple.count === 0) {
continue;
}
let discipleMultiplier = 1;
for (const upgrade of goddess.upgrades) {
if (
upgrade.purchased
&& upgrade.target === "disciple"
&& upgrade.discipleId === disciple.id
) {
discipleMultiplier = discipleMultiplier * upgrade.multiplier;
}
}
const discipleContribution
= disciple.combatPower
* disciple.count
* discipleMultiplier
* globalMultiplier;
partyDPS = partyDPS + discipleContribution;
const discipleHp = disciple.level * 50 * disciple.count;
partyMaxHp = partyMaxHp + discipleHp;
}
const { craftedCombatMultiplier } = goddess.exploration;
partyDPS = partyDPS
* equipmentCombatMultiplier
* setCombatMultiplier
* consecrationCombatMultiplier
* stardustCombatMultiplier
* craftedCombatMultiplier;
return { partyDPS, partyMaxHp };
};
goddessBossRouter.post("/challenge", async(context) => {
try {
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
if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const 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.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
const { goddess } = state;
const boss = goddess.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.consecrationRequirement > goddess.consecration.count) {
return context.json({ error: "Consecration requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculateDiscipleStats(goddess);
if (
partyDPS === 0
|| partyMaxHp === 0
|| !Number.isFinite(partyDPS)
|| !Number.isFinite(partyMaxHp)
) {
return context.json(
{ error: "Your disciples have no combat power" },
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: GoddessBossChallengeResponse["rewards"];
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
let casualties: GoddessBossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossUpdatedHp = 0;
const bossDamageDealt = bossDPS * timeToKillBoss;
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
boss.status = "defeated";
boss.currentHp = 0;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
// eslint-disable-next-line unicorn/consistent-destructuring -- mutation requires direct property access on state.resources
state.resources.prayers = (state.resources.prayers ?? 0) + boss.prayersReward;
goddess.totalPrayersEarned
= goddess.totalPrayersEarned + boss.prayersReward;
goddess.lifetimePrayersEarned
= goddess.lifetimePrayersEarned + boss.prayersReward;
goddess.consecration.divinity
= goddess.consecration.divinity + boss.divinityReward;
goddess.enlightenment.stardust
= goddess.enlightenment.stardust + boss.stardustReward;
goddess.lifetimeBossesDefeated
= goddess.lifetimeBossesDefeated + 1;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = goddess.upgrades.find((u) => {
return u.id === upgradeId;
});
if (upgrade) {
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 = goddess.equipment.find((item) => {
return item.id === equipmentId;
});
if (equipment) {
equipment.owned = true;
const slotAlreadyEquipped = goddess.equipment.some((item) => {
return item.type === equipment.type && item.equipped;
});
if (!slotAlreadyEquipped) {
equipment.equipped = true;
}
}
}
// Unlock next boss in the same zone
const zoneBosses = goddess.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.consecrationRequirement <= goddess.consecration.count
) {
const nextBossInState = goddess.bosses.find((b) => {
return b.id === nextZoneBoss.id;
});
if (nextBossInState) {
nextBossInState.status = "available";
}
}
// Unlock zones whose conditions are now both satisfied
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
for (const zone of goddess.zones) {
if (zone.status === "unlocked") {
continue;
}
if (zone.unlockBossId !== body.bossId) {
continue;
}
const questSatisfied
= zone.unlockQuestId === null
|| goddess.quests.some((q) => {
return q.id === zone.unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
continue;
}
zone.status = "unlocked";
// Unlock exploration areas for the newly unlocked zone
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 9 -- @preserve */
for (const area of goddess.exploration.areas) {
const areaDefinition = defaultGoddessExplorationAreas.find((explorationArea) => {
return explorationArea.id === area.id;
});
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
area.status = "available";
}
}
const updatedZoneBosses = goddess.bosses.filter((b) => {
return b.zoneId === zone.id;
});
const [ firstUpdatedBoss ] = updatedZoneBosses;
if (
firstUpdatedBoss
&& firstUpdatedBoss.consecrationRequirement <= goddess.consecration.count
) {
firstUpdatedBoss.status = "available";
}
}
// First-kill divinity bounty — only awarded once
const staticBoss = defaultGoddessBosses.find((b) => {
return b.id === body.bossId;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const bountyDivinity
= boss.bountyDivinityClaimed === true
? 0
: staticBoss?.bountyDivinity ?? 0;
if (bountyDivinity > 0) {
boss.bountyDivinityClaimed = true;
}
goddess.consecration.divinity
= goddess.consecration.divinity + bountyDivinity;
rewards = {
bountyDivinity: bountyDivinity,
divinity: boss.divinityReward,
equipmentIds: boss.equipmentRewards,
prayers: boss.prayersReward,
stardust: boss.stardustReward,
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;
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
const casualtyFraction = (1 - victoryProgress) * 0.6;
casualties = [];
for (const disciple of goddess.disciples) {
if (disciple.count === 0) {
continue;
}
const killed = Math.floor(disciple.count * casualtyFraction);
if (killed > 0) {
disciple.count = Math.max(1, disciple.count - killed);
casualties.push({ discipleId: disciple.id, killed: killed });
}
}
}
const now = Date.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 },
});
const secret = process.env.ANTI_CHEAT_SECRET;
const updatedSignature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
const { bossId } = body;
void logger.metric("goddess_boss_challenge", 1, { bossId, discordId, won });
const bossMaxHp = boss.maxHp;
const bossNewHp = bossUpdatedHp;
const response: GoddessBossChallengeResponse = {
bossDPS,
bossHpAtBattleEnd,
bossHpBefore,
bossMaxHp,
bossNewHp,
partyDPS,
partyHpRemaining,
partyMaxHp,
won,
};
if (rewards !== undefined) {
response.rewards = rewards;
}
if (casualties !== undefined) {
response.casualties = casualties;
}
if (updatedSignature !== undefined) {
response.signature = updatedSignature;
}
return context.json(response);
} catch (error) {
void logger.error(
"goddess_boss_challenge",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { goddessBossRouter };
+173
View File
@@ -0,0 +1,173 @@
/**
* @file Goddess crafting route handling divine recipe crafting.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable max-statements -- Route handler requires many statements */
/* eslint-disable complexity -- Route handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
import { Hono } from "hono";
import { defaultGoddessCraftingRecipes } from "../data/goddessCrafting.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 {
GoddessCraftRequest,
GoddessCraftResponse,
GameState,
} from "@elysium/types";
const goddessCraftRouter = new Hono<HonoEnvironment>();
goddessCraftRouter.use("*", authMiddleware);
const recomputeGoddessCraftedMultipliers = (
craftedRecipeIds: Array<string>,
): {
craftedPrayersMultiplier: number;
craftedDivinityMultiplier: number;
craftedCombatMultiplier: number;
} => {
return {
craftedCombatMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
}).reduce((mult, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return mult * recipe.bonus.value;
}, 1),
craftedDivinityMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
}).reduce((mult, recipe) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return mult * recipe.bonus.value;
}, 1),
craftedPrayersMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
}).reduce((mult, recipe) => {
return mult * recipe.bonus.value;
}, 1),
};
};
goddessCraftRouter.post("/", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<GoddessCraftRequest>();
const { recipeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400);
}
const recipe = defaultGoddessCraftingRecipes.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.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
if (state.goddess.exploration.craftedRecipeIds.includes(recipeId)) {
return context.json({ error: "Recipe already crafted" }, 400);
}
// Verify the player has all required sacred materials
for (const requirement of recipe.requiredMaterials) {
const material = state.goddess.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 sacred materials
for (const requirement of recipe.requiredMaterials) {
const material = state.goddess.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.goddess.exploration.craftedRecipeIds.push(recipeId);
const updatedMultipliers = recomputeGoddessCraftedMultipliers(
state.goddess.exploration.craftedRecipeIds,
);
state.goddess.exploration.craftedPrayersMultiplier
= updatedMultipliers.craftedPrayersMultiplier;
state.goddess.exploration.craftedDivinityMultiplier
= updatedMultipliers.craftedDivinityMultiplier;
state.goddess.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("goddess_recipe_crafted", 1, { discordId, recipeId });
const bonusType = recipe.bonus.type;
const bonusValue = recipe.bonus.value;
const { materials } = state.goddess.exploration;
const {
craftedPrayersMultiplier,
craftedDivinityMultiplier,
craftedCombatMultiplier,
} = updatedMultipliers;
const response: GoddessCraftResponse = {
bonusType,
bonusValue,
craftedCombatMultiplier,
craftedDivinityMultiplier,
craftedPrayersMultiplier,
materials,
recipeId,
};
return context.json(response);
} catch (error) {
void logger.error(
"goddess_craft",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { goddessCraftRouter };
+418
View File
@@ -0,0 +1,418 @@
/**
* @file Goddess exploration routes handling divine area exploration mechanics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable max-lines -- Route file requires multiple handlers */
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
import { Hono } from "hono";
import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.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 {
GoddessExploreClaimableResponse,
GoddessExploreCollectEventResult,
GoddessExploreCollectRequest,
GoddessExploreCollectResponse,
GoddessExploreStartRequest,
GoddessExploreStartResponse,
GameState,
} from "@elysium/types";
const goddessExploreRouter = new Hono<HonoEnvironment>();
goddessExploreRouter.use("*", authMiddleware);
const nothingProbability = 0.2;
const nothingMessages = [
"Your disciples searched every corner of the divine realm but found nothing of value.",
"The sacred area yielded nothing remarkable this time.",
"Your disciples returned empty-handed from the divine realm.",
"A wasted journey — the sacred area proved barren.",
"Nothing to show for the devotion. Perhaps next time.",
];
/**
* Returns a random "nothing found" message.
* @returns A random message string.
*/
const pickNothingMessage = (): string => {
const index = Math.floor(Math.random() * nothingMessages.length);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return nothingMessages[index] ?? nothingMessages[0] ?? "";
};
goddessExploreRouter.get("/claimable", async(context) => {
try {
const discordId = context.get("discordId");
const areaId = context.req.query("areaId");
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultGoddessExplorationAreas.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.goddess) {
const response: GoddessExploreClaimableResponse = { claimable: false };
return context.json(response);
}
const area = state.goddess.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area || area.status !== "in_progress") {
const response: GoddessExploreClaimableResponse = { claimable: false };
return context.json(response);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
const claimable = Date.now() >= expiresAt;
const response: GoddessExploreClaimableResponse = { claimable };
return context.json(response);
} catch (error) {
void logger.error(
"goddess_explore_claimable",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
goddessExploreRouter.post("/start", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<GoddessExploreStartRequest>();
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 = defaultGoddessExplorationAreas.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.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
const zone = state.goddess.zones.find((z) => {
return z.id === explorationArea.zoneId;
});
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const area = state.goddess.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json(
{ error: "Exploration area not found in state" },
404,
);
}
const anyInProgress = state.goddess.exploration.areas.some((a) => {
return a.status === "in_progress";
});
if (anyInProgress) {
return context.json(
{ error: "An exploration is already in progress" },
400,
);
}
if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400);
}
const now = Date.now();
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
area.status = "in_progress";
area.startedAt = now;
area.endsAt = endsAt;
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: GoddessExploreStartResponse = {
areaId,
endsAt,
};
return context.json(response);
} catch (error) {
void logger.error(
"goddess_explore_start",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
goddessExploreRouter.post("/collect", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<GoddessExploreCollectRequest>();
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 = defaultGoddessExplorationAreas.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.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
const area = state.goddess.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({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: GoddessExploreCollectResponse = {
event: null,
foundNothing: true,
materialsFound: [],
nothingMessage: pickNothingMessage(),
};
return context.json(response);
}
// 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
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
// Apply event effects and build the result summary
let prayersChange = 0;
let materialGained: { materialId: string; quantity: number } | null = null;
if (event.effect.type === "prayers_gain") {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.prayers = (state.resources.prayers ?? 0) + amount;
state.goddess.totalPrayersEarned = state.goddess.totalPrayersEarned + amount;
prayersChange = amount;
} else if (event.effect.type === "prayers_loss") {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const amount = Math.min(state.resources.prayers ?? 0, event.effect.amount ?? 0);
state.resources.prayers = (state.resources.prayers ?? 0) - amount;
prayersChange = -amount;
} else if (event.effect.type === "divinity_gain") {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.goddess.consecration.divinity = state.goddess.consecration.divinity + amount;
} else if (event.effect.type === "sacred_material_gain") {
const { materialId } = event.effect;
// 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.goddess.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity = existing.quantity + quantity;
} else {
state.goddess.exploration.materials.push({ materialId, quantity });
}
materialGained = { materialId, quantity };
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 13 -- @preserve */
}
} else if (event.effect.type === "disciple_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const fraction = event.effect.fraction ?? 0.05;
for (const disciple of state.goddess.disciples) {
const lost = Math.floor(disciple.count * fraction);
if (lost > 0) {
disciple.count = Math.max(0, disciple.count - lost);
}
}
}
let discipleLostCount = 0;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
if (event.effect.type === "disciple_loss") {
const fraction = event.effect.fraction ?? 0.05;
for (const disciple of state.goddess.disciples) {
const lost = Math.floor(disciple.count * fraction);
discipleLostCount = discipleLostCount + lost;
}
}
const eventResult: GoddessExploreCollectEventResult = {
discipleLostCount: discipleLostCount,
materialGained: materialGained,
prayersChange: prayersChange,
text: event.text,
};
// Roll for sacred 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.goddess.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity = existing.quantity + quantity;
} else {
state.goddess.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: GoddessExploreCollectResponse = {
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
} catch (error) {
void logger.error(
"goddess_explore_collect",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { goddessExploreRouter };
+125
View File
@@ -0,0 +1,125 @@
/**
* @file Goddess upgrade purchase route.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
/* eslint-disable max-statements -- Route handler requires many statements */
/* eslint-disable complexity -- Route handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
import { Hono } from "hono";
import { defaultGoddessUpgrades } from "../data/goddessUpgrades.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 {
BuyGoddessUpgradeRequest,
BuyGoddessUpgradeResponse,
GameState,
} from "@elysium/types";
const goddessUpgradeRouter = new Hono<HonoEnvironment>();
goddessUpgradeRouter.use("*", authMiddleware);
goddessUpgradeRouter.post("/buy", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<BuyGoddessUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgradeTemplate = defaultGoddessUpgrades.find((goddessUpgrade) => {
return goddessUpgrade.id === upgradeId;
});
if (!upgradeTemplate) {
return context.json({ error: "Unknown goddess upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.goddess) {
return context.json({ error: "Goddess realm not unlocked" }, 400);
}
const upgrade = state.goddess.upgrades.find((u) => {
return u.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Upgrade not found in goddess state" }, 404);
}
if (!upgrade.unlocked) {
return context.json({ error: "Upgrade is not yet unlocked" }, 400);
}
if (upgrade.purchased) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
const currentPrayers = state.resources.prayers ?? 0;
const currentDivinity = state.goddess.consecration.divinity;
const currentStardust = state.goddess.enlightenment.stardust;
if (currentPrayers < upgradeTemplate.costPrayers) {
return context.json({ error: "Not enough prayers" }, 400);
}
if (currentDivinity < upgradeTemplate.costDivinity) {
return context.json({ error: "Not enough divinity" }, 400);
}
if (currentStardust < upgradeTemplate.costStardust) {
return context.json({ error: "Not enough stardust" }, 400);
}
upgrade.purchased = true;
const updatedPrayers = currentPrayers - upgradeTemplate.costPrayers;
const updatedDivinity = currentDivinity - upgradeTemplate.costDivinity;
const updatedStardust = currentStardust - upgradeTemplate.costStardust;
state.resources.prayers = updatedPrayers;
state.goddess.consecration.divinity = updatedDivinity;
state.goddess.enlightenment.stardust = updatedStardust;
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("goddess_upgrade_purchased", 1, { discordId, upgradeId });
const response: BuyGoddessUpgradeResponse = {
divinityRemaining: updatedDivinity,
prayersRemaining: updatedPrayers,
stardustRemaining: updatedStardust,
};
return context.json(response);
} catch (error) {
void logger.error(
"goddess_upgrade_buy",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { goddessUpgradeRouter };
+69 -58
View File
@@ -9,6 +9,7 @@
import { Hono } from "hono";
import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
@@ -58,70 +59,80 @@ const resolveTitleName = (titleId: string | null): string => {
};
leaderboardRouter.get("/", async(context) => {
const category = context.req.query("category") ?? "totalGold";
const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
try {
const category = context.req.query("category") ?? "totalGold";
const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
if (!validCategories.has(category)) {
return context.json({ error: "Invalid category" }, 400);
}
if (!validCategories.has(category)) {
return context.json({ error: "Invalid category" }, 400);
}
const [ players, gameStates ] = await Promise.all([
prisma.player.findMany(),
gameStateCategories.has(category)
? prisma.gameState.findMany()
: Promise.resolve([]),
]);
const [ players, gameStates ] = await Promise.all([
prisma.player.findMany(),
gameStateCategories.has(category)
? prisma.gameState.findMany()
: Promise.resolve([]),
]);
const stateMap = new Map(
gameStates.map((gs) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
return [ gs.discordId, gs.state as unknown as GameState ];
}),
);
const stateMap = new Map(
gameStates.map((gs) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
return [ gs.discordId, gs.state as unknown as GameState ];
}),
);
const entries = players.
filter((player) => {
return parseShowOnLeaderboards(player.profileSettings);
}).
map((player) => {
let value = 0;
if (category === "totalGold") {
value = player.lifetimeGoldEarned;
} else if (category === "bossesDefeated") {
value = player.lifetimeBossesDefeated;
} else if (category === "questsCompleted") {
value = player.lifetimeQuestsCompleted;
} else if (category === "achievementsUnlocked") {
value = player.lifetimeAchievementsUnlocked;
} else {
const state = stateMap.get(player.discordId);
if (category === "prestigeCount") {
value = state?.prestige.count ?? 0;
} else if (category === "transcendenceCount") {
value = state?.transcendence?.count ?? 0;
} else if (category === "apotheosisCount") {
value = state?.apotheosis?.count ?? 0;
const entries = players.
filter((player) => {
return parseShowOnLeaderboards(player.profileSettings);
}).
map((player) => {
let value = 0;
if (category === "totalGold") {
value = player.lifetimeGoldEarned;
} else if (category === "bossesDefeated") {
value = player.lifetimeBossesDefeated;
} else if (category === "questsCompleted") {
value = player.lifetimeQuestsCompleted;
} else if (category === "achievementsUnlocked") {
value = player.lifetimeAchievementsUnlocked;
} else {
const state = stateMap.get(player.discordId);
if (category === "prestigeCount") {
value = state?.prestige.count ?? 0;
} else if (category === "transcendenceCount") {
value = state?.transcendence?.count ?? 0;
} else if (category === "apotheosisCount") {
value = state?.apotheosis?.count ?? 0;
}
}
}
return {
activeTitle: resolveTitleName(player.activeTitle),
avatar: player.avatar ?? null,
characterName: player.characterName,
discordId: player.discordId,
username: player.username,
value: value,
};
}).
sort((a, b) => {
return b.value - a.value;
}).
slice(0, limit).
map((entry, index) => {
return { ...entry, rank: index + 1 };
});
return {
activeTitle: resolveTitleName(player.activeTitle),
avatar: player.avatar ?? null,
characterName: player.characterName,
discordId: player.discordId,
username: player.username,
value: value,
};
}).
sort((a, b) => {
return b.value - a.value;
}).
slice(0, limit).
map((entry, index) => {
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 };
+222 -163
View File
@@ -6,13 +6,16 @@
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
import { Hono } from "hono";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import {
buildPostPrestigeState,
calculatePrestigeThreshold,
computeRunestoneMultipliers,
isEligibleForPrestige,
} from "../services/prestige.js";
@@ -25,190 +28,246 @@ const prestigeRouter = new Hono<HonoEnvironment>();
prestigeRouter.use("*", authMiddleware);
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) {
return context.json({ error: "No save found" }, 404);
}
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!isEligibleForPrestige(state)) {
return context.json(
{
error: "Not eligible for prestige — collect 1,000,000 total gold first",
if (!isEligibleForPrestige(state)) {
const thresholdMultiplier
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
const required = calculatePrestigeThreshold(
state.prestige.count,
thresholdMultiplier,
);
return context.json(
{
error: `Not eligible for prestige — collect ${required.toLocaleString()} 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
let updatedDailyChallenges = state.dailyChallenges;
let challengeCrystals = 0;
if (updatedDailyChallenges) {
const result = updateChallengeProgress(
updatedDailyChallenges,
// 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();
const { updatedAt } = record;
/*
* Use the record's current updatedAt as an optimistic lock — if another
* concurrent prestige request already committed, this update will match
* 0 rows and we can safely reject the duplicate without a double webhook.
*/
const updateResult = await prisma.gameState.updateMany({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: finalState as object, updatedAt: now },
where: { discordId, updatedAt },
});
if (updateResult.count === 0) {
return context.json({ error: "Prestige already in progress" }, 409);
}
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 });
const playerRecord = await prisma.player.findUnique({
select: { profileSettings: true },
where: { discordId },
});
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
const playerSettings = playerRecord?.profileSettings as
Record<string, unknown> | null | undefined;
const announcementsEnabled
= playerSettings?.enablePrestigeAnnouncements !== false;
if (announcementsEnabled) {
void postMilestoneWebhook(discordId, "prestige", {
// 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",
1,
error instanceof Error
? error
: new Error(String(error)),
);
updatedDailyChallenges = result.updatedChallenges;
challengeCrystals = result.crystalsAwarded;
return context.json({ error: "Internal server error" }, 500);
}
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) => {
const discordId = context.get("discordId");
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
try {
const discordId = context.get("discordId");
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
return prestigeUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown prestige upgrade" }, 404);
}
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
return prestigeUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown prestige upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
const { purchasedUpgradeIds, runestones } = state.prestige;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
const { purchasedUpgradeIds, runestones } = state.prestige;
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (runestones < upgrade.runestonesCost) {
return context.json({ error: "Not enough runestones" }, 400);
}
if (runestones < upgrade.runestonesCost) {
return context.json({ error: "Not enough runestones" }, 400);
}
const updatedRunestones = runestones - upgrade.runestonesCost;
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedRunestones = runestones - upgrade.runestonesCost;
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedState: GameState = {
...state,
prestige: {
...state.prestige,
const updatedState: GameState = {
...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,
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);
return context.json({
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
runestonesRemaining: updatedRunestones,
...multipliers,
});
runestonesRemaining: updatedRunestones,
...multipliers,
});
} catch (error) {
void logger.error(
"prestige_buy_upgrade",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { prestigeRouter };
+185 -162
View File
@@ -20,6 +20,7 @@ import { Hono } from "hono";
import { gameTitles } from "../data/titles.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import { parseUnlockedTitles } from "../services/titles.js";
import type { HonoEnvironment } from "../types/hono.js";
@@ -46,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
: "suffix";
return {
enableNotifications: rawObject.enableNotifications === true,
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
enableSounds: rawObject.enableSounds === true,
numberFormat: numberFormat,
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
@@ -81,190 +83,211 @@ const resolveTitle = (id: string): { id: string; name: string } => {
};
profileRouter.get("/:discordId", async(context) => {
const { discordId } = context.req.param();
try {
const { discordId } = context.req.param();
const [ player, gameStateRecord ] = await Promise.all([
prisma.player.findUnique({ where: { discordId } }),
prisma.gameState.findUnique({ where: { discordId } }),
]);
const [ player, gameStateRecord ] = await Promise.all([
prisma.player.findUnique({ where: { discordId } }),
prisma.gameState.findUnique({ where: { discordId } }),
]);
if (!player) {
return context.json({ error: "Player not found" }, 404);
}
if (!player) {
return context.json({ error: "Player not found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = gameStateRecord?.state as unknown as GameState | undefined;
const prestigeCount = state?.prestige.count ?? 0;
const transcendenceCount = state?.transcendence?.count ?? 0;
const apotheosisCount = state?.apotheosis?.count ?? 0;
const profileSettings = parseProfileSettings(player.profileSettings);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = gameStateRecord?.state as unknown as GameState | undefined;
const prestigeCount = state?.prestige.count ?? 0;
const transcendenceCount = state?.transcendence?.count ?? 0;
const apotheosisCount = state?.apotheosis?.count ?? 0;
const profileSettings = parseProfileSettings(player.profileSettings);
const bossesDefeated
= state?.bosses.filter((boss) => {
return boss.status === "defeated";
}).length ?? 0;
const questsCompleted
= state?.quests.filter((quest) => {
return quest.status === "completed";
}).length ?? 0;
const bossesDefeated
= state?.bosses.filter((boss) => {
return boss.status === "defeated";
}).length ?? 0;
const questsCompleted
= state?.quests.filter((quest) => {
return quest.status === "completed";
}).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
/* v8 ignore next 3 -- @preserve */
for (const adventurer of state.adventurers) {
adventurersRecruited = adventurersRecruited + adventurer.count;
}
}
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
return achievement.unlockedAt !== null;
}).length;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
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 unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
const unlockedTitles = unlockedTitleIds.map((id) => {
return resolveTitle(id);
});
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({
achievementsUnlocked: achievementsUnlocked,
activeTitle: player.activeTitle,
adventurersRecruited: adventurersRecruited,
apotheosisCount: apotheosisCount,
avatar: player.avatar,
bio: player.bio ?? "",
bossesDefeated: bossesDefeated,
characterClass: player.characterClass,
characterName: player.characterName,
characterRace: player.characterRace ?? "",
completedChapters: completedChapters,
createdAt: player.createdAt,
currentRunClicks: state?.player.totalClicks ?? 0,
currentRunGold: state?.player.totalGoldEarned ?? 0,
equippedItems: equippedItems,
guildDescription: player.guildDescription,
guildName: player.guildName,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
prestigeCount: prestigeCount,
profileSettings: profileSettings,
pronouns: player.pronouns ?? "",
questsCompleted: questsCompleted,
totalClicks: player.lifetimeClicks,
totalGoldEarned: player.lifetimeGoldEarned,
transcendenceCount: transcendenceCount,
unlockedTitles: unlockedTitles,
username: player.username,
});
const completedChapters = state?.story?.completedChapters ?? [];
return context.json({
achievementsUnlocked: achievementsUnlocked,
activeTitle: player.activeTitle,
adventurersRecruited: adventurersRecruited,
apotheosisCount: apotheosisCount,
avatar: player.avatar,
bio: player.bio ?? "",
bossesDefeated: bossesDefeated,
characterClass: player.characterClass,
characterName: player.characterName,
characterRace: player.characterRace ?? "",
completedChapters: completedChapters,
createdAt: player.createdAt,
currentRunClicks: state?.player.totalClicks ?? 0,
currentRunGold: state?.player.totalGoldEarned ?? 0,
equippedItems: equippedItems,
guildDescription: player.guildDescription,
guildName: player.guildName,
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
prestigeCount: prestigeCount,
profileSettings: profileSettings,
pronouns: player.pronouns ?? "",
questsCompleted: questsCompleted,
totalClicks: player.lifetimeClicks,
totalGoldEarned: player.lifetimeGoldEarned,
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) => {
const discordId = context.get("discordId");
const body = await context.req.json<UpdateProfileRequest>();
try {
const discordId = context.get("discordId");
const body = await context.req.json<UpdateProfileRequest>();
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.characterName) {
return context.json({ error: "Character name cannot be empty" }, 400);
}
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!body.characterName) {
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 === "") {
return context.json({ error: "Character name cannot be empty" }, 400);
}
if (characterName === "") {
return context.json({ error: "Character name cannot be empty" }, 400);
}
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* 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)
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix";
const profileSettings: ProfileSettings = {
enableNotifications: body.profileSettings.enableNotifications ?? false,
enableSounds: body.profileSettings.enableSounds ?? false,
numberFormat: numberFormat,
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
showApotheosis: body.profileSettings.showApotheosis ?? true,
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
showPrestige: body.profileSettings.showPrestige ?? true,
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true,
};
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix";
const profileSettings: ProfileSettings = {
enableNotifications: body.profileSettings.enableNotifications ?? false,
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
enableSounds: body.profileSettings.enableSounds ?? false,
numberFormat: numberFormat,
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
showApotheosis: body.profileSettings.showApotheosis ?? true,
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
showPrestige: body.profileSettings.showPrestige ?? true,
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
showTotalGold: body.profileSettings.showTotalGold ?? true,
showTranscendence: body.profileSettings.showTranscendence ?? true,
};
const activeTitle
= typeof body.activeTitle === "string"
? body.activeTitle.slice(0, 64)
: undefined;
const activeTitle
= typeof body.activeTitle === "string"
? body.activeTitle.slice(0, 64)
: undefined;
const updated = await prisma.player.update({
data: {
bio: bio,
characterClass: characterClass,
characterName: characterName,
characterRace: characterRace,
guildDescription: guildDescription,
guildName: guildName,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
profileSettings: profileSettings as object,
pronouns: pronouns,
...activeTitle === undefined
? {}
: { activeTitle },
},
where: { discordId },
});
const updated = await prisma.player.update({
data: {
bio: bio,
characterClass: characterClass,
characterName: characterName,
characterRace: characterRace,
guildDescription: guildDescription,
guildName: guildName,
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
profileSettings: profileSettings as object,
pronouns: pronouns,
...activeTitle === undefined
? {}
: { activeTitle },
},
where: { discordId },
});
return context.json({
activeTitle: updated.activeTitle,
bio: updated.bio,
characterClass: updated.characterClass,
characterName: updated.characterName,
characterRace: updated.characterRace,
guildDescription: updated.guildDescription,
guildName: updated.guildName,
profileSettings: profileSettings,
pronouns: updated.pronouns,
});
return context.json({
activeTitle: updated.activeTitle,
bio: updated.bio,
characterClass: updated.characterClass,
characterName: updated.characterName,
characterRace: updated.characterRace,
guildDescription: updated.guildDescription,
guildName: updated.guildName,
profileSettings: profileSettings,
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 };
+127
View File
@@ -0,0 +1,127 @@
/**
* @file Public read-only timer API for external tooling (bots, automations, etc.).
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Hono } from "hono";
import { defaultExplorations } from "../data/explorations.js";
import { prisma } from "../db/client.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
const timersRouter = new Hono<HonoEnvironment>();
const explorationNameMap = new Map(
defaultExplorations.map((area) => {
return [ area.id, area.name ];
}),
);
/**
* Extracts active quest timers from a game state.
* @param state - The player's game state.
* @param now - The current timestamp in milliseconds.
* @returns An array of active quest timer objects.
*/
const getQuestTimers = (
state: GameState,
now: number,
): Array<{
endsAt: number;
name: string;
questId: string;
timeLeft: number;
}> => {
return state.quests.
filter((quest) => {
return quest.status === "active" && quest.startedAt !== undefined;
}).
map((quest) => {
const durationMs = quest.durationSeconds * 1000;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const endsAt = (quest.startedAt ?? 0) + durationMs;
return {
endsAt: endsAt,
name: quest.name,
questId: quest.id,
timeLeft: Math.max(0, endsAt - now),
};
});
};
/**
* Extracts active exploration timers from a game state.
* @param state - The player's game state.
* @param now - The current timestamp in milliseconds.
* @returns An array of active exploration timer objects.
*/
const getExplorationTimers = (
state: GameState,
now: number,
): Array<{
areaId: string;
endsAt: number;
name: string;
timeLeft: number;
}> => {
return (state.exploration?.areas ?? []).
filter((area) => {
return area.status === "in_progress" && area.endsAt !== undefined;
}).
map((area) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const endsAt = area.endsAt ?? 0;
return {
areaId: area.id,
endsAt: endsAt,
name: explorationNameMap.get(area.id) ?? area.id,
timeLeft: Math.max(0, endsAt - now),
};
});
};
/**
* Returns active quest and exploration timers for a given player.
* This endpoint is public and read-only — no authentication required.
* Rate limiting is enforced at the infrastructure level.
*/
timersRouter.get("/:userId", async(context) => {
try {
const { userId } = context.req.param();
if (userId.length === 0 || !/^\d+$/u.test(userId)) {
return context.json({ error: "Invalid user ID" }, 400);
}
const record = await prisma.gameState.findUnique({
where: { discordId: userId },
});
if (record === null) {
return context.json({ error: "Player not found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
const now = Date.now();
return context.json({
explorations: getExplorationTimers(state, now),
quests: getQuestTimers(state, now),
});
} catch (error) {
void logger.error(
"timers",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { timersRouter };
+171 -141
View File
@@ -6,10 +6,12 @@
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
import { Hono } from "hono";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import {
buildPostTranscendenceState,
computeTranscendenceMultipliers,
@@ -24,168 +26,196 @@ const transcendenceRouter = new Hono<HonoEnvironment>();
transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async(context) => {
const discordId = context.get("discordId");
try {
const discordId = context.get("discordId");
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!isEligibleForTranscendence(state)) {
return context.json(
{
error: "Not eligible for transcendence — defeat The Absolute One first",
if (!isEligibleForTranscendence(state)) {
return context.json(
{
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
error: "Not eligible for transcendence — defeat The Absolute One first",
},
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) => {
const discordId = context.get("discordId");
const body = await context.req.json<BuyEchoUpgradeRequest>();
try {
const discordId = context.get("discordId");
const body = await context.req.json<BuyEchoUpgradeRequest>();
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const { upgradeId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!upgradeId) {
return context.json({ error: "upgradeId is required" }, 400);
}
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
return transcendenceUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown echo upgrade" }, 404);
}
// eslint-disable-next-line stylistic/max-len -- Variable name mirrors the data source for clarity
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
return transcendenceUpgrade.id === upgradeId;
});
if (!upgrade) {
return context.json({ error: "Unknown echo upgrade" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
const state = record.state as unknown as GameState;
if (!state.transcendence) {
return context.json({ error: "No transcendence data found" }, 400);
}
if (!state.transcendence) {
return context.json({ error: "No transcendence data found" }, 400);
}
const { purchasedUpgradeIds, echoes } = state.transcendence;
const { purchasedUpgradeIds, echoes } = state.transcendence;
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (purchasedUpgradeIds.includes(upgradeId)) {
return context.json({ error: "Upgrade already purchased" }, 400);
}
if (echoes < upgrade.cost) {
return context.json({ error: "Not enough echoes" }, 400);
}
if (echoes < upgrade.cost) {
return context.json({ error: "Not enough echoes" }, 400);
}
const updatedEchoes = echoes - upgrade.cost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers
= computeTranscendenceMultipliers(updatedPurchasedIds);
const updatedEchoes = echoes - upgrade.cost;
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
const updatedMultipliers
= computeTranscendenceMultipliers(updatedPurchasedIds);
const updatedState: GameState = {
...state,
transcendence: {
...state.transcendence,
echoes: updatedEchoes,
const updatedState: GameState = {
...state,
transcendence: {
...state.transcendence,
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,
...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 },
});
return context.json({
echoesRemaining: updatedEchoes,
purchasedUpgradeIds: updatedPurchasedIds,
...updatedMultipliers,
});
});
} catch (error) {
void logger.error(
"transcendence_buy_upgrade",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { transcendenceRouter };
+11 -1
View File
@@ -4,7 +4,7 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { initialGameState } from "../data/initialState.js";
import { initialGameState, initialGoddessState } from "../data/initialState.js";
import {
defaultTranscendenceUpgrades,
} from "../data/transcendenceUpgrades.js";
@@ -47,6 +47,15 @@ const buildPostApotheosisState = (
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
const freshState = initialGameState(currentState.player, characterName);
// Goddess state: initialised on first apotheosis, preserved on subsequent resets
let goddessSpread: object = {};
if (apotheosisCount === 1) {
goddessSpread = { goddess: initialGoddessState() };
} else if (currentState.goddess !== undefined) {
goddessSpread = { goddess: currentState.goddess };
}
const updatedState: GameState = {
...freshState,
lastTickAt: Date.now(),
@@ -60,6 +69,7 @@ const buildPostApotheosisState = (
...currentState.story
? { story: currentState.story }
: {},
...goddessSpread,
};
return { updatedApotheosisData, updatedState };
+201
View File
@@ -0,0 +1,201 @@
/**
* @file Consecration service handling eligibility checks and post-consecration state building.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Function requires many steps */
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
import { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js";
import { initialGoddessState } from "../data/initialState.js";
import type { ConsecrationData, GameState } from "@elysium/types";
/**
* Base prayers threshold for the first consecration.
*/
const baseConsecrationThreshold = 50_000;
/**
* Divisor used in the divinity yield formula.
*/
const divinityYieldDivisor = 1000;
/**
* Calculates the prayers threshold required for the next consecration.
* Formula: BASE * (count + 1)^2 * thresholdMultiplier.
* @param consecrationCount - The number of consecrations completed so far.
* @param thresholdMultiplier - An optional stardust-upgrade multiplier applied to the threshold.
* @returns The prayers amount required to consecrate.
*/
const calculateConsecrationThreshold = (
consecrationCount: number,
thresholdMultiplier = 1,
): number => {
return (
baseConsecrationThreshold
* Math.pow(consecrationCount + 1, 2)
* thresholdMultiplier
);
};
/**
* Returns true if the player is eligible to consecrate:
* the total prayers earned in the current run must meet the threshold.
* @param state - The current game state.
* @returns Whether the player is eligible for consecration.
*/
const isEligibleForConsecration = (state: GameState): boolean => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (state.goddess === undefined) {
return false;
}
const thresholdMultiplier
= state.goddess.enlightenment.stardustConsecrationThresholdMultiplier;
const threshold = calculateConsecrationThreshold(
state.goddess.consecration.count,
thresholdMultiplier,
);
return state.goddess.totalPrayersEarned >= threshold;
};
/**
* Calculates the divinity yield from a consecration.
* Formula: MAX(1, FLOOR(SQRT(totalPrayersEarned / divisor) * divinityMultiplier)).
* @param totalPrayersEarned - Total prayers earned in the current consecration run.
* @param divinityMultiplier - Multiplier from stardust upgrades applied to divinity yield.
* @returns The divinity earned.
*/
const calculateDivinityYield = (
totalPrayersEarned: number,
divinityMultiplier: number,
): number => {
return Math.max(
1,
Math.floor(
Math.sqrt(totalPrayersEarned / divinityYieldDivisor) * divinityMultiplier,
),
);
};
/**
* Computes the consecration production multiplier from the count.
* Each consecration adds 25% to the production multiplier.
* @param count - The number of consecrations completed.
* @returns The computed production multiplier as a number.
*/
const computeConsecrationProductionMultiplier = (count: number): number => {
const bonus = count * 0.25;
return 1 + bonus;
};
const getCategoryMultiplier = (
purchasedUpgradeIds: Array<string>,
category: string,
): number => {
return defaultConsecrationUpgrades.filter((upgrade) => {
return (
upgrade.category === category
&& purchasedUpgradeIds.includes(upgrade.id)
);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
};
/**
* Computes all three divinity-upgrade multipliers from the purchased upgrade IDs.
* @param purchasedUpgradeIds - The array of purchased consecration upgrade IDs.
* @returns An object containing the three divinity multiplier values.
*/
const computeConsecrationDivinityMultipliers = (
purchasedUpgradeIds: Array<string>,
): Pick<
ConsecrationData,
| "divinityCombatMultiplier"
| "divinityDisciplesMultiplier"
| "divinityPrayersMultiplier"
> => {
return {
divinityCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
divinityDisciplesMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "disciples"),
divinityPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"),
};
};
/**
* Builds the updated goddess state after a consecration reset.
* Resets the current run (bosses, quests, disciples, upgrades, zones, exploration crafting).
* Preserves: equipment, achievements, consecration data (updated), enlightenment, lifetime stats, sacred materials.
* @param state - The current game state before consecration.
* @returns The divinity earned and the updated goddess state.
*/
const buildPostConsecrationState = (
state: GameState,
): { divinityEarned: number; updatedGoddess: NonNullable<GameState["goddess"]> } => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists
const goddess = state.goddess as NonNullable<GameState["goddess"]>;
const divinityMultiplier
= goddess.enlightenment.stardustConsecrationDivinityMultiplier;
const divinityEarned = calculateDivinityYield(
goddess.totalPrayersEarned,
divinityMultiplier,
);
const updatedCount = goddess.consecration.count + 1;
const updatedDivinity = goddess.consecration.divinity + divinityEarned;
const productionMultiplier = computeConsecrationProductionMultiplier(updatedCount);
const updatedConsecration: ConsecrationData = {
...goddess.consecration,
count: updatedCount,
divinity: updatedDivinity,
lastConsecratedAt: Date.now(),
productionMultiplier: productionMultiplier,
...computeConsecrationDivinityMultipliers(
goddess.consecration.purchasedUpgradeIds,
),
};
const freshGoddess = initialGoddessState();
const updatedGoddess: NonNullable<GameState["goddess"]> = {
...freshGoddess,
achievements: goddess.achievements,
bosses: freshGoddess.bosses.map((b) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const existing = goddess.bosses.find((gb) => {
return gb.id === b.id;
});
return {
...b,
bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false,
};
}),
consecration: updatedConsecration,
enlightenment: goddess.enlightenment,
equipment: goddess.equipment,
exploration: {
...freshGoddess.exploration,
materials: goddess.exploration.materials,
},
lastTickAt: Date.now(),
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
lifetimePrayersEarned: goddess.lifetimePrayersEarned + goddess.totalPrayersEarned,
lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted,
totalPrayersEarned: 0,
};
return { divinityEarned, updatedGoddess };
};
export {
buildPostConsecrationState,
calculateConsecrationThreshold,
calculateDivinityYield,
computeConsecrationDivinityMultipliers,
computeConsecrationProductionMultiplier,
isEligibleForConsecration,
};
+20 -5
View File
@@ -71,8 +71,11 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result;
};
const challengeTypes: Array<DailyChallengeType> = [
"clicks",
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
"crafting",
];
const progressionChallengeTypes: Array<DailyChallengeType> = [
"bossesDefeated",
"questsCompleted",
"prestige",
@@ -80,7 +83,10 @@ const challengeTypes: Array<DailyChallengeType> = [
/**
* Generates 3 daily challenges for the given date string, deterministically.
* Picks one challenge from 3 different randomly-selected types.
* Always includes a "clicks" challenge and a "crafting" challenge (both
* completable regardless of zone/boss progression), then picks 1 more from
* the progression types. This ensures stuck players always have 2 completable
* challenges available.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects.
*/
@@ -88,8 +94,17 @@ const generateDailyChallenges = (
dateString: string,
): Array<DailyChallenge> => {
const seed = dateSeed(dateString);
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
slice(0, 3);
const selectedTypes: Array<DailyChallengeType> = [
"clicks",
...shuffleWithSeed(
[ ...nonProgressionChallengeTypes ],
seed + 500,
).slice(0, 1),
...shuffleWithSeed(
[ ...progressionChallengeTypes ],
seed,
).slice(0, 1),
];
return selectedTypes.map((type, index) => {
const templates = dailyChallengeTemplates.filter((template) => {
+81 -39
View File
@@ -5,6 +5,10 @@
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
import { logger } from "./logger.js";
const discordClientId = "1479551654264049908";
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
interface DiscordTokenResponse {
access_token: string;
@@ -30,38 +34,42 @@ interface DiscordUser {
const exchangeCode = async(
code: string,
): Promise<DiscordTokenResponse> => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (
clientId === undefined || clientId === ""
|| clientSecret === undefined || clientSecret === ""
|| redirectUri === undefined || redirectUri === ""
) {
if (clientSecret === undefined || clientSecret === "") {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({
client_id: clientId,
client_id: discordClientId,
client_secret: clientSecret,
code: code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
redirect_uri: discordRedirectUri,
});
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
body: parameters.toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
method: "POST",
});
try {
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
body: parameters.toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
method: "POST",
});
if (!response.ok) {
throw new Error(`Discord token exchange failed: ${response.statusText}`);
if (!response.ok) {
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 +81,60 @@ const exchangeCode = async(
const fetchDiscordUser = async(
accessToken: string,
): Promise<DiscordUser> => {
const response = await fetch("https://discord.com/api/v10/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
try {
const response = await fetch("https://discord.com/api/v10/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(`Discord user fetch failed: ${response.statusText}`);
if (!response.ok) {
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>);
/**
* Fetches a Discord user's profile by their Discord ID using the bot token.
* Returns null on any failure so callers are never blocked by Discord API issues.
* @param discordId - The Discord user ID to look up.
* @returns The Discord user object, or null if the fetch fails.
*/
const fetchDiscordUserById = async(
discordId: string,
): Promise<DiscordUser | null> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return null;
}
try {
const response = await fetch(
`https://discord.com/api/v10/users/${discordId}`,
{ headers: { Authorization: `Bot ${botToken}` } },
);
if (!response.ok) {
return null;
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
return await (response.json() as Promise<DiscordUser>);
} catch (error) {
void logger.error(
"discord_fetch_user_by_id",
error instanceof Error
? error
: new Error(String(error)),
);
return null;
}
};
/**
@@ -91,19 +143,9 @@ const fetchDiscordUser = async(
* @throws {Error} If OAuth environment variables are missing.
*/
const buildOAuthUrl = (): string => {
const clientId = process.env.DISCORD_CLIENT_ID;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
if (
clientId === undefined || clientId === ""
|| redirectUri === undefined || redirectUri === ""
) {
throw new Error("Discord OAuth environment variables are required");
}
const parameters = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
client_id: discordClientId,
redirect_uri: discordRedirectUri,
response_type: "code",
scope: "identify",
});
@@ -112,4 +154,4 @@ const buildOAuthUrl = (): string => {
};
export type { DiscordTokenResponse, DiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
+137
View File
@@ -0,0 +1,137 @@
/**
* @file Enlightenment service handling eligibility checks and post-enlightenment state building.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
import { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js";
import { initialGoddessState } from "../data/initialState.js";
import type { EnlightenmentData, GameState } from "@elysium/types";
/**
* ID of the final goddess boss — must be defeated to unlock Enlightenment.
*/
const finalGoddessBossId = "divine_heart_sovereign";
const getCategoryMultiplier = (
purchasedIds: Array<string>,
category: string,
): number => {
return defaultEnlightenmentUpgrades.filter((upgrade) => {
return upgrade.category === category && purchasedIds.includes(upgrade.id);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
};
/**
* Computes all five stardust multipliers from the purchased enlightenment upgrade IDs.
* @param purchasedUpgradeIds - The array of purchased enlightenment upgrade IDs.
* @returns An object containing all five stardust multiplier values.
*/
const computeEnlightenmentMultipliers = (
purchasedUpgradeIds: Array<string>,
): Omit<EnlightenmentData, "count" | "stardust" | "purchasedUpgradeIds"> => {
return {
stardustCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
stardustConsecrationDivinityMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_divinity"),
stardustConsecrationThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_threshold"),
stardustMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "stardust_meta"),
stardustPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"),
};
};
/**
* Returns true when the player is eligible for Enlightenment:
* they must have defeated the final goddess boss at least once.
* @param state - The current game state.
* @returns Whether the player is eligible for Enlightenment.
*/
const isEligibleForEnlightenment = (state: GameState): boolean => {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (state.goddess === undefined) {
return false;
}
return state.goddess.bosses.some((boss) => {
return boss.id === finalGoddessBossId && boss.status === "defeated";
});
};
/**
* Calculates the stardust yield from an Enlightenment.
* Formula: MAX(1, FLOOR(SQRT(consecrationCount) * metaMultiplier)).
* @param consecrationCount - The number of consecrations completed before this Enlightenment.
* @param metaMultiplier - Multiplier from prior enlightenment upgrades applied to stardust yield.
* @returns The stardust earned.
*/
const calculateStardustYield = (
consecrationCount: number,
metaMultiplier: number,
): number => {
return Math.max(1, Math.floor(Math.sqrt(consecrationCount) * metaMultiplier));
};
/**
* Builds the updated goddess state after an Enlightenment — a full goddess reset.
* Wipes everything including consecration, preserving only equipment, achievements, and enlightenment data.
* @param state - The current game state before enlightenment.
* @returns The stardust earned and the updated goddess state.
*/
const buildPostEnlightenmentState = (
state: GameState,
): { stardustEarned: number; updatedGoddess: NonNullable<GameState["goddess"]> } => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists
const goddess = state.goddess as NonNullable<GameState["goddess"]>;
const metaMultiplier = goddess.enlightenment.stardustMetaMultiplier;
const stardustEarned = calculateStardustYield(
goddess.consecration.count,
metaMultiplier,
);
const updatedCount = goddess.enlightenment.count + 1;
const updatedStardust = goddess.enlightenment.stardust + stardustEarned;
const updatedPurchasedIds = goddess.enlightenment.purchasedUpgradeIds;
const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds);
const updatedEnlightenment: EnlightenmentData = {
count: updatedCount,
purchasedUpgradeIds: updatedPurchasedIds,
stardust: updatedStardust,
...updatedMultipliers,
};
const freshGoddess = initialGoddessState();
const updatedGoddess: NonNullable<GameState["goddess"]> = {
...freshGoddess,
achievements: goddess.achievements,
bosses: freshGoddess.bosses.map((b) => {
const existing = goddess.bosses.find((gb) => {
return gb.id === b.id;
});
return {
...b,
bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false,
};
}),
enlightenment: updatedEnlightenment,
equipment: goddess.equipment,
lastTickAt: Date.now(),
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
lifetimePrayersEarned: goddess.lifetimePrayersEarned,
lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted,
totalPrayersEarned: 0,
};
return { stardustEarned, updatedGoddess };
};
export {
buildPostEnlightenmentState,
calculateStardustYield,
computeEnlightenmentMultipliers,
isEligibleForEnlightenment,
};
+182
View File
@@ -0,0 +1,182 @@
/**
* @file Discord Gateway WebSocket client for listening to guild member events.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */
import { prisma } from "../db/client.js";
import { logger } from "./logger.js";
const discordGuildId = "1354624415861833870";
/**
* Discord Gateway opcodes used by this client.
*/
const gatewayOpcodes = {
dispatch: 0,
heartbeat: 1,
heartbeatAck: 11,
hello: 10,
identify: 2,
} as const;
/**
* GUILD_MEMBERS privileged intent bitmask.
*/
/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */
const guildMembersIntent = 1 << 1;
/**
* Updates the inGuild flag for a player when they join the configured guild.
* No-ops silently if the Discord user has no player record.
* @param discordId - The Discord user ID of the member who joined.
* @param guildId - The ID of the guild they joined.
* @returns A promise that resolves when the update attempt completes.
*/
const handleGuildMemberAdd = async(
discordId: string,
guildId: string,
): Promise<void> => {
if (guildId !== discordGuildId) {
return;
}
try {
await prisma.player.updateMany({
data: { inGuild: true },
where: { discordId },
});
} catch (error) {
void logger.error(
"gateway_member_add",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
/**
* Updates the inGuild flag for a player when they leave the configured guild.
* No-ops silently if the Discord user has no player record.
* @param discordId - The Discord user ID of the member who left.
* @param guildId - The ID of the guild they left.
* @returns A promise that resolves when the update attempt completes.
*/
const handleGuildMemberRemove = async(
discordId: string,
guildId: string,
): Promise<void> => {
if (guildId !== discordGuildId) {
return;
}
try {
await prisma.player.updateMany({
data: { inGuild: false },
where: { discordId },
});
} catch (error) {
void logger.error(
"gateway_member_remove",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase
/* v8 ignore next 95 -- @preserve */
/**
* Connects to the Discord Gateway and listens for guild member events.
* Reconnects automatically on close or error.
* Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal.
*/
const connectGateway = (): void => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
void logger.log("info", "Gateway: no bot token configured, skipping");
return;
}
const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json");
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let lastSequence: number | null = null;
const stopHeartbeat = (): void => {
if (heartbeatInterval !== null) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
};
ws.addEventListener("message", (event) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */
const payload = JSON.parse(event.data as string) as {
op: number;
d: unknown;
s: number | null;
t: string | null;
};
if (payload.s !== null) {
lastSequence = payload.s;
}
if (payload.op === gatewayOpcodes.hello) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */
const helloData = payload.d as { heartbeat_interval: number };
const heartbeatMs = helloData.heartbeat_interval;
heartbeatInterval = setInterval(() => {
ws.send(JSON.stringify({
d: lastSequence,
op: gatewayOpcodes.heartbeat,
}));
}, heartbeatMs);
ws.send(JSON.stringify({
d: {
intents: guildMembersIntent,
properties: { browser: "elysium", device: "elysium", os: "linux" },
token: botToken,
},
op: gatewayOpcodes.identify,
}));
}
if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */
const data = payload.d as { user?: { id: string }; guild_id?: string };
const discordId = data.user?.id;
const guildId = data.guild_id;
if (discordId === undefined || guildId === undefined) {
return;
}
if (payload.t === "GUILD_MEMBER_ADD") {
void handleGuildMemberAdd(discordId, guildId);
} else if (payload.t === "GUILD_MEMBER_REMOVE") {
void handleGuildMemberRemove(discordId, guildId);
}
}
});
ws.addEventListener("close", () => {
stopHeartbeat();
void logger.log("info", "Gateway: connection closed, reconnecting in 5s");
setTimeout(connectGateway, 5000);
});
ws.addEventListener("error", (event) => {
const message
= event instanceof ErrorEvent
? event.message
: "WebSocket error";
void logger.error("gateway_error", new Error(message));
stopHeartbeat();
ws.close();
});
};
export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove };
+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 };
+102 -13
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan
*/
/* 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 { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import type {
@@ -14,14 +15,21 @@ import type {
} from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000;
const thresholdScaleFactor = 5;
const runestonesPerPrestigeLevel = 10;
const runestonesPerPrestigeLevel = 20;
const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25;
/*
* Hard cap on the base runestone yield (before multipliers) to prevent
* extreme AFK accumulation from producing game-breaking runestone counts.
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige.
*/
const maxBaseRunestones = 200;
/**
* Calculates the gold threshold required for the next prestige.
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
* meaningful even as the production multiplier scales.
* @param prestigeCount - The current number of prestiges completed.
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
* @returns The gold amount required to prestige.
@@ -32,7 +40,7 @@ const calculatePrestigeThreshold = (
): number => {
return (
basePrestigeGoldThreshold
* Math.pow(thresholdScaleFactor, prestigeCount)
* Math.pow(prestigeCount + 1, 2.5)
* thresholdMultiplier
);
};
@@ -106,7 +114,9 @@ interface RunestoneParameters {
/**
* Calculates how many runestones the player earns from a prestige.
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
* Formula: min(floor(cbrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL, MAX_BASE) * multipliers.
* Uses cube root for stronger diminishing returns than sqrt, and caps the base before multipliers
* to prevent extended AFK sessions from producing runestone windfalls.
* @param parameters - The parameters for the runestone calculation.
* @param parameters.totalGoldEarned - The total gold earned in the current run.
* @param parameters.prestigeCount - The current prestige count.
@@ -122,9 +132,11 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
echoRunestoneMultiplier = 1,
} = parameters;
const threshold = calculatePrestigeThreshold(prestigeCount);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel;
const base = Math.min(
Math.floor(Math.cbrt(totalGoldEarned / threshold))
* runestonesPerPrestigeLevel,
maxBaseRunestones,
);
const runestoneMult = getCategoryMultiplier(
purchasedUpgradeIds,
"runestones",
@@ -134,19 +146,20 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
/**
* Calculates the new prestige production multiplier.
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
* @param prestigeCount - The new prestige count.
* @returns The production multiplier for the new prestige level.
*/
const calculateProductionMultiplier = (
prestigeCount: number,
): number => {
return Math.pow(1.15, prestigeCount);
return Math.pow(1.3, prestigeCount);
};
/**
* Returns the milestone runestone bonus for the given prestige count.
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param prestigeCount - The prestige count after the current prestige.
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
*/
@@ -155,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
return 0;
}
const milestoneNumber = prestigeCount / milestoneInterval;
return milestoneNumber * milestoneRunestonesPerInterval;
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval;
};
/**
@@ -176,6 +189,7 @@ const buildPostPrestigeState = (
} => {
const {
autoPrestigeEnabled,
autoPrestigeMaxRunestonesOnly,
count: currentPrestigeCount,
purchasedUpgradeIds,
runestones: currentRunestones,
@@ -202,13 +216,88 @@ const buildPostPrestigeState = (
...autoPrestigeEnabled === undefined
? {}
: { autoPrestigeEnabled },
...autoPrestigeMaxRunestonesOnly === undefined
? {}
: { autoPrestigeMaxRunestonesOnly },
};
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 = {
...freshState,
// Achievements are permanent — earned achievements survive all prestiges
achievements: currentState.achievements,
/*
* Preserve automation preferences across prestige — the player explicitly
* opted into these settings and would not expect them to silently reset.
*/
autoAdventurer: currentState.autoAdventurer ?? false,
autoBoss: currentState.autoBoss ?? false,
autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed,
lastTickAt: Date.now(),
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
...currentState.codex === undefined
? {}
+1 -1
View File
@@ -20,7 +20,7 @@ const finalBossId = "the_absolute_one";
/**
* Base constant used in the echo yield formula.
*/
const echoFormulaConstant = 853;
const echoFormulaConstant = 224;
const getCategoryMultiplier = (
purchasedIds: Array<string>,
+77 -14
View File
@@ -5,8 +5,59 @@
* @author Naomi Carrigan
*/
/* 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";
/**
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
* triggering desktop or mobile push notifications.
*/
const suppressNotifications = 4096;
/**
* The Discord role ID for the Elysian role granted to all Elysium players.
*/
const discordGuildId = "1354624415861833870";
const elysianRoleId = "1486144823684628490";
const apotheosisRoleId = "1479966598210129991";
/**
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
* Fails silently so role grant errors do not affect the auth flow.
* @param discordId - The Discord user ID to grant the role to.
* @returns True if the player is in the guild and the role was granted, false otherwise.
*/
const grantElysianRole = async(discordId: string): Promise<boolean> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return false;
}
try {
const response = await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
return response.ok || response.status === 204;
} catch (error) {
void logger.error(
"webhook_elysian_role",
error instanceof Error
? error
: new Error(String(error)),
);
return false;
}
};
/**
* Grants the apotheosis Discord role to the given player if configured.
* Fails silently so role grant errors do not affect the game action.
@@ -15,26 +66,29 @@ const discordApi = "https://discord.com/api/v10";
*/
const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
if (
botToken === undefined || botToken === ""
|| guildId === undefined || guildId === ""
|| roleId === undefined || roleId === ""
) {
if (botToken === undefined || botToken === "") {
return;
}
try {
await fetch(
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
{
headers: { Authorization: `Bot ${botToken}` },
method: "PUT",
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
} catch {
} 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
}
};
@@ -77,13 +131,22 @@ const postMilestoneWebhook = async(
try {
await fetch(webhookUrl, {
body: JSON.stringify({ content }),
body: JSON.stringify({
content: content,
flags: suppressNotifications,
}),
headers: { "Content-Type": "application/json" },
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
}
};
export { grantApotheosisRole, postMilestoneWebhook };
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };
+45 -3
View File
@@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({
verifyToken: vi.fn(),
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
},
}));
describe("authMiddleware", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
const makeApp = async () => {
const { authMiddleware } = await import("../../src/middleware/auth.js");
const { verifyToken } = await import("../../src/services/jwt.js");
const { logger } = await import("../../src/services/logger.js");
const app = new Hono<{ Variables: { discordId: string } }>();
app.use("*", authMiddleware);
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
return { app, verifyToken };
return { app, logger, verifyToken };
};
it("returns 401 when Authorization header is missing", async () => {
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
expect(body.discordId).toBe("user_123");
});
it("returns 401 when verifyToken throws", async () => {
const { app, verifyToken } = await makeApp();
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Invalid token");
});
@@ -54,5 +62,39 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_token" },
}));
expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
});
it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error";
});
const res = await app.fetch(new Request("http://localhost/test", {
headers: { Authorization: "Bearer bad_token" },
}));
expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
});
it("returns 401 without logging when token has expired", async () => {
const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Token has expired");
});
const res = await app.fetch(new Request("http://localhost/test", {
headers: { Authorization: "Bearer expired_token" },
}));
expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
});
+12
View File
@@ -80,6 +80,18 @@ describe("apotheosis route", () => {
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 () => {
// Need all 15 transcendence upgrades purchased for eligibility
const allUpgradeIds = [
+9
View File
@@ -113,5 +113,14 @@ describe("auth route", () => {
const location = res.headers.get("Location") ?? "";
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");
});
});
});
+110
View File
@@ -293,4 +293,114 @@ describe("boss route", () => {
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("handles zone unlock gracefully when exploration state is undefined", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [],
exploration: undefined,
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks exploration areas when a zone is unlocked on boss defeat", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [],
exploration: {
areas: [{ id: "test_area", status: "locked" as const }],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
// Exploration area should remain locked — no matching defaultExploration for "test_area"
const area = savedState?.exploration?.areas.find((a) => a.id === "test_area");
expect(area?.status).toBe("locked");
});
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("omits signature in response when ANTI_CHEAT_SECRET is not set", async () => {
delete process.env.ANTI_CHEAT_SECRET;
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeUndefined();
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(500);
});
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
const state = makeState({
bosses: [makeBoss({
bountyRunestonesClaimed: true,
currentHp: 100,
damagePerSecond: 1,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyRunestones).toBe(0);
});
});
+295
View File
@@ -0,0 +1,295 @@
/* 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() },
},
}));
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),
metric: vi.fn().mockResolvedValue(undefined),
},
}));
const DISCORD_ID = "test_discord_id";
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as string[],
...overrides,
});
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
count: 0,
stardust: 0,
purchasedUpgradeIds: [] as string[],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
...overrides,
});
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
areas: [] as Array<{ id: string; status: string }>,
materials: [] as Array<{ materialId: string; quantity: number }>,
craftedRecipeIds: [] as string[],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
...overrides,
});
/**
* Creates a minimal GoddessState that has met the first consecration threshold (50 000 prayers).
*/
const makeGoddessStateEligible = (overrides: Record<string, unknown> = {}) => ({
zones: [] as Array<{ id: string; status: string }>,
bosses: [] as Array<{ id: string; status: string }>,
quests: [] as Array<{ id: string; status: string }>,
disciples: [] as Array<{ id: string; count: number }>,
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number }>,
achievements: [] as Array<{ id: string; completed: boolean }>,
consecration: makeConsecration(),
enlightenment: makeEnlightenment(),
exploration: makeGoddessExploration(),
totalPrayersEarned: 50_000, // Meets base threshold for count=0
lifetimePrayersEarned: 50_000,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
...overrides,
});
/**
* Creates a minimal GoddessState that has NOT met the consecration threshold.
*/
const makeGoddessStateIneligible = (overrides: Record<string, unknown> = {}) => ({
...makeGoddessStateEligible(),
totalPrayersEarned: 0,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
goddess: makeGoddessStateEligible() as GameState["goddess"],
...overrides,
} as GameState);
describe("consecration route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { consecrationRouter } = await import("../../src/routes/consecration.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/consecration", consecrationRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/consecration${path}`, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
it("returns 400 when goddess realm is not unlocked", async () => {
const state = makeState({ goddess: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Goddess realm not unlocked");
});
it("returns 400 with threshold message when not eligible", async () => {
const state = makeState({
goddess: makeGoddessStateIneligible() as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toMatch(/Not eligible for consecration/u);
expect(body.error).toMatch(/50,000/u);
});
it("returns 200 with divinityEarned and newConsecrationCount on success", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { divinityEarned: number; newConsecrationCount: number };
expect(body.newConsecrationCount).toBe(1);
expect(body.divinityEarned).toBeGreaterThanOrEqual(1);
});
it("applies the threshold multiplier when checking eligibility", async () => {
// threshold multiplier of 2 means we need 100 000 prayers for count=0 but only have 50 000
const state = makeState({
goddess: makeGoddessStateIneligible({
totalPrayersEarned: 50_000,
enlightenment: makeEnlightenment({ stardustConsecrationThresholdMultiplier: 2 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
// threshold should be 100 000 with multiplier 2
expect(body.error).toMatch(/100,000/u);
});
it("returns 500 when database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when 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);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("upgradeId is required");
});
it("returns 404 for an unknown upgrade", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade_id" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Unknown consecration upgrade");
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(404);
});
it("returns 400 when goddess realm is not unlocked", async () => {
const state = makeState({ goddess: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Goddess realm not unlocked");
});
it("returns 400 when upgrade is already purchased", async () => {
const state = makeState({
goddess: makeGoddessStateEligible({
consecration: makeConsecration({
divinity: 100,
purchasedUpgradeIds: [ "divine_prayers_1" ],
}),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Upgrade already purchased");
});
it("returns 400 when not enough divinity", async () => {
// divine_prayers_1 costs 5 divinity — give the player 0
const state = makeState({
goddess: makeGoddessStateEligible({
consecration: makeConsecration({ divinity: 0 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Not enough divinity");
});
it("returns 200 with updated multipliers on successful purchase", async () => {
// divine_prayers_1 costs 5 divinity and is in the "prayers" category
const state = makeState({
goddess: makeGoddessStateEligible({
consecration: makeConsecration({ divinity: 100 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(200);
const body = await res.json() as {
divinityRemaining: number;
purchasedUpgradeIds: string[];
divinityPrayersMultiplier: number;
divinityDisciplesMultiplier: number;
divinityCombatMultiplier: number;
};
expect(body.divinityRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("divine_prayers_1");
expect(body.divinityPrayersMultiplier).toBeGreaterThan(1);
});
it("returns 500 when database throws an Error during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(500);
});
it("returns 500 when 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: "divine_prayers_1" });
expect(res.status).toBe(500);
});
});
});
+40
View File
@@ -143,4 +143,44 @@ describe("craft route", () => {
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
});
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
const state = makeState({
dailyChallenges: {
date: "2024-01-15",
challenges: [
{
completed: false,
id: "2024-01-15_crafting",
label: "Craft 1 recipe",
progress: 0,
rewardCrystals: 75,
target: 1,
type: "crafting",
},
],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: GameState };
};
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
expect(updateArg.data.state.resources.crystals).toBe(75);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
});
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
/* 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() },
},
}));
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();
}),
}));
const DISCORD_ID = "test_discord_id";
// stardust_prayers_1 costs 2 stardust
const TEST_UPGRADE_ID = "stardust_prayers_1";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [{ id: "goddess_celestial_garden", name: "Celestial Garden", description: "", emoji: "🌸", status: "unlocked", unlockBossId: null, unlockQuestId: null }],
bosses: [
{
id: "divine_heart_sovereign",
name: "Divine Heart Sovereign",
description: "",
status: "defeated",
maxHp: 1000,
currentHp: 0,
damagePerSecond: 10,
prayersReward: 100,
divinityReward: 1,
stardustReward: 1,
upgradeRewards: [],
equipmentRewards: [],
consecrationRequirement: 0,
zoneId: "goddess_celestial_garden",
bountyDivinity: 5,
},
],
quests: [],
disciples: [],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 10,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 10,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("enlightenment route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { enlightenmentRouter } = await import("../../src/routes/enlightenment.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/enlightenment", enlightenmentRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/enlightenment${path}`, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns 400 when not eligible (final boss not defeated)", async () => {
const goddess = makeGoddessState();
// Override final boss to available (not defeated)
goddess.bosses[0]!.status = "available";
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns 200 with stardustEarned and newEnlightenmentCount on success", async () => {
const goddess = makeGoddessState();
goddess.consecration.count = 4; // sqrt(4)*1 = 2 stardust
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { stardustEarned: number; newEnlightenmentCount: number };
expect(body.newEnlightenmentCount).toBe(1);
expect(body.stardustEarned).toBeGreaterThanOrEqual(1);
});
it("returns 500 when the database throws an Error", 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);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
});
it("returns 404 for unknown upgrade", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when upgrade is already purchased", async () => {
const goddess = makeGoddessState();
goddess.enlightenment.purchasedUpgradeIds = [TEST_UPGRADE_ID];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough stardust", async () => {
const goddess = makeGoddessState();
goddess.enlightenment.stardust = 0; // stardust_prayers_1 costs 2
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 200 with updated multipliers on success", async () => {
const goddess = makeGoddessState();
goddess.enlightenment.stardust = 10;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(200);
const body = await res.json() as { stardustRemaining: number; purchasedUpgradeIds: string[] };
expect(body.stardustRemaining).toBe(8); // 10 - 2
expect(body.purchasedUpgradeIds).toContain(TEST_UPGRADE_ID);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_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("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(500);
});
});
});
+135
View File
@@ -77,6 +77,99 @@ describe("explore route", () => {
body: JSON.stringify(body),
}));
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/explore/claimable"
: `http://localhost/explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("nonexistent_area");
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when no exploration state exists", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area is not in_progress", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when exploration is still in progress", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=true when exploration is complete", async () => {
const state = makeState({
exploration: {
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postStart({});
@@ -153,6 +246,22 @@ describe("explore route", () => {
expect(body.endsAt).toBeGreaterThan(Date.now());
});
it("persists endsAt to the DB state on exploration start", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { areaId: string; endsAt: number };
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]?.[0];
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test accesses nested mock data */
const savedState = (updateCall?.data as { state?: { exploration?: { areas?: Array<{ id: string; endsAt?: number }> } } }).state;
const savedArea = savedState?.exploration?.areas?.find((a) => {
return a.id === TEST_AREA_ID;
});
expect(savedArea?.endsAt).toBe(body.endsAt);
});
it("backfills exploration state for old saves without exploration", async () => {
const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
@@ -406,5 +515,31 @@ describe("explore route", () => {
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
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");
});
});
});
+329 -1
View File
@@ -19,8 +19,12 @@ vi.mock("../../src/middleware/auth.js", () => ({
}),
}));
vi.mock("../../src/services/discord.js", () => ({
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
}));
const DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1;
const CURRENT_SCHEMA_VERSION = 2;
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
@@ -200,6 +204,75 @@ describe("game route", () => {
expect(body.offlineGold).toBeGreaterThan(0);
expect(body.offlineEssence).toBeGreaterThan(0);
});
it("syncs updated avatar from Discord into the returned state", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("new_hash");
});
it("continues loading when the avatar DB update fails", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
});
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
});
it("keeps stored avatar when Discord returns null", async () => {
const todayUTC = new Date().toISOString().slice(0, 10);
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
const res = await app.fetch(new Request("http://localhost/game/load"));
expect(res.status).toBe(200);
const body = await res.json() as { state: GameState };
expect(body.state.player.avatar).toBe("stored_hash");
});
});
describe("POST /save", () => {
@@ -404,6 +477,28 @@ describe("game route", () => {
expect(body.savedAt).toBeGreaterThan(0);
});
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
const prevUpgrades = [
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
] as GameState["upgrades"];
const prevState = makeState({
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
upgrades: prevUpgrades,
});
const incomingState = makeState({
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
upgrades: [
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
});
it("validates companion when active companion is legitimately unlocked", async () => {
const prevState = makeState();
const stateWithCompanion = makeState({
@@ -418,6 +513,227 @@ describe("game route", () => {
const res = await save({ state: stateWithCompanion });
expect(res.status).toBe(200);
});
it("passes through goddess when incoming has goddess and previous does not", async () => {
const goddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const prevState = makeState();
const incomingState = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess).toBeDefined();
});
it("does not add goddess to result when neither previous nor incoming has goddess", async () => {
const prevState = makeState();
const incomingState = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess).toBeUndefined();
});
it("preserves previous goddess when incoming save lacks goddess", async () => {
const goddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const prevState = makeState({ goddess });
const incomingState = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess).toEqual(goddess);
});
it("caps goddess totalPrayersEarned at previous value (server-only field)", async () => {
const prevGoddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 100,
upgrades: [],
zones: [],
};
const incomingGoddess: GameState["goddess"] = {
...prevGoddess,
totalPrayersEarned: 9999,
};
const prevState = makeState({ goddess: prevGoddess });
const incomingState = makeState({ goddess: incomingGoddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess?.totalPrayersEarned).toBe(100);
});
it("restores goddess boss defeated status when incoming tries to un-defeat it", async () => {
const prevGoddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [{ id: "divine_sentinel", status: "defeated", currentHp: 0, maxHp: 5000 }] as GameState["goddess"]["bosses"],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const incomingGoddess: GameState["goddess"] = {
...prevGoddess,
bosses: [{ id: "divine_sentinel", status: "available", currentHp: 5000, maxHp: 5000 }] as GameState["goddess"]["bosses"],
};
const prevState = makeState({ goddess: prevGoddess });
const incomingState = makeState({ goddess: incomingGoddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
const boss = savedState.goddess?.bosses.find((b) => b.id === "divine_sentinel");
expect(boss?.status).toBe("defeated");
});
it("restores goddess quest completed status when incoming tries to un-complete it", async () => {
const prevGoddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [{ id: "offering_ritual", status: "completed" }] as GameState["goddess"]["quests"],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const incomingGoddess: GameState["goddess"] = {
...prevGoddess,
quests: [{ id: "offering_ritual", status: "available" }] as GameState["goddess"]["quests"],
};
const prevState = makeState({ goddess: prevGoddess });
const incomingState = makeState({ goddess: incomingGoddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
const quest = savedState.goddess?.quests.find((q) => q.id === "offering_ritual");
expect(quest?.status).toBe("completed");
});
});
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", () => {
@@ -450,5 +766,17 @@ describe("game route", () => {
const body = await res.json() as { signature: string | undefined };
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);
});
});
});
+560
View File
@@ -0,0 +1,560 @@
/* 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() },
},
}));
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),
metric: vi.fn().mockResolvedValue(undefined),
},
}));
const DISCORD_ID = "test_discord_id";
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as string[],
...overrides,
});
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
count: 0,
stardust: 0,
purchasedUpgradeIds: [] as string[],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
...overrides,
});
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
areas: [] as Array<{ id: string; status: string }>,
materials: [] as Array<{ materialId: string; quantity: number }>,
craftedRecipeIds: [] as string[],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
...overrides,
});
const makeGoddessBoss = (overrides: Record<string, unknown> = {}) => ({
id: "test_goddess_boss",
name: "Test Goddess Boss",
description: "A test boss",
status: "available",
maxHp: 100,
currentHp: 100,
damagePerSecond: 1,
prayersReward: 50,
divinityReward: 2,
stardustReward: 0,
upgradeRewards: [] as string[],
equipmentRewards: [] as string[],
consecrationRequirement: 0,
zoneId: "test_goddess_zone",
bountyDivinity: 5,
...overrides,
});
const makeDisciple = (overrides: Record<string, unknown> = {}) => ({
id: "test_disciple",
name: "Test Disciple",
class: "oracle" as const,
level: 10,
baseCost: 100,
prayersPerSecond: 1,
divinityPerSecond: 0,
combatPower: 10_000,
count: 1,
unlocked: true,
...overrides,
});
const makeGoddessState = (overrides: Record<string, unknown> = {}) => ({
zones: [] as Array<{ id: string; status: string; unlockBossId: string | null; unlockQuestId: string | null }>,
bosses: [ makeGoddessBoss() ],
quests: [] as Array<{ id: string; status: string }>,
disciples: [ makeDisciple() ],
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; discipleId?: string }>,
achievements: [] as Array<{ id: string; completed: boolean }>,
consecration: makeConsecration(),
enlightenment: makeEnlightenment(),
exploration: makeGoddessExploration(),
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
goddess: makeGoddessState() as GameState["goddess"],
...overrides,
} as GameState);
describe("goddessBoss route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessBossRouter } = await import("../../src/routes/goddessBoss.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-boss", goddessBossRouter);
});
const challenge = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-boss/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when bossId is missing", async () => {
const res = await challenge({});
expect(res.status).toBe(400);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(404);
});
it("returns 400 when goddess realm is not unlocked", async () => {
const state = makeState({ goddess: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Goddess realm not unlocked");
});
it("returns 404 when boss is not found in goddess state", async () => {
const state = makeState({
goddess: makeGoddessState({ bosses: [] }) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Boss not found");
});
it("returns 400 when boss status is defeated", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ status: "defeated" }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Boss is not currently available");
});
it("returns 400 when boss status is locked", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ status: "locked" }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
});
it("accepts in_progress boss status", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ status: "in_progress" }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
});
it("returns 403 when consecration requirement is not met", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ consecrationRequirement: 3 }) ],
consecration: makeConsecration({ count: 0 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(403);
const body = await res.json() as { error: string };
expect(body.error).toBe("Consecration requirement not met");
});
it("returns 400 when party has no combat power (all disciples have count 0)", async () => {
const state = makeState({
goddess: makeGoddessState({
disciples: [ makeDisciple({ count: 0 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Your disciples have no combat power");
});
it("returns 400 when party has no combat power (disciples array is empty)", async () => {
const state = makeState({
goddess: makeGoddessState({
disciples: [],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
});
it("returns 200 with rewards when party wins", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1, level: 10 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { prayers: number; divinity: number } };
expect(body.won).toBe(true);
expect(body.rewards.prayers).toBe(50);
expect(body.rewards.divinity).toBe(2);
});
it("returns 200 with bountyDivinity when first kill", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 50, maxHp: 50, damagePerSecond: 1, id: "celestial_sprite" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "celestial_sprite" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyDivinity).toBeGreaterThan(0);
});
it("returns 0 bountyDivinity when already claimed", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "celestial_sprite", bountyDivinityClaimed: true }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "celestial_sprite" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyDivinity).toBe(0);
});
it("unlocks upgrade rewards on win", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ upgradeRewards: [ "test_upgrade" ] }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
upgrades: [ { id: "test_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[] } };
expect(body.won).toBe(true);
expect(body.rewards.upgradeIds).toContain("test_upgrade");
});
it("unlocks next zone boss when boss is defeated", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 0 }),
],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock next boss if consecration requirement not met", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 5 }),
],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
consecration: makeConsecration({ count: 0 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks goddess zone when boss and quest conditions are both met", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
],
quests: [ { id: "test_quest", status: "completed" } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock zone when quest condition is not satisfied", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
],
quests: [ { id: "test_quest", status: "active" } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks zone when unlockQuestId is null", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips zone that is already unlocked", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "unlocked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips zone whose unlockBossId does not match the defeated boss", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "other_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("applies global upgrade multiplier to party DPS", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
upgrades: [ { id: "global_u", purchased: true, target: "global", multiplier: 100_000 } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("applies disciple-specific upgrade multiplier", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ id: "test_disciple", combatPower: 1, count: 1, level: 10 }) ],
upgrades: [
{ id: "disciple_u", purchased: true, target: "disciple", multiplier: 100_000, discipleId: "test_disciple" },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips unpurchased upgrades", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100_000, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
upgrades: [
{ id: "not_bought", purchased: false, target: "global", multiplier: 100_000 },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(false);
});
it("returns 200 with casualties when party loses", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [
makeGoddessBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 }),
],
disciples: [
makeDisciple({ combatPower: 1, count: 10, level: 1 }),
makeDisciple({ id: "zero_disciple", combatPower: 0, count: 0, level: 1 }),
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; casualties: Array<{ discipleId: string }> };
expect(body.won).toBe(false);
expect(Array.isArray(body.casualties)).toBe(true);
});
it("includes HMAC signature in response 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 challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("omits signature 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 challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeUndefined();
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_goddess_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_goddess_boss" });
expect(res.status).toBe(500);
});
});
+193
View File
@@ -0,0 +1,193 @@
/* 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() },
},
}));
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();
}),
}));
const DISCORD_ID = "test_discord_id";
// prayer_amplifier requires: divine_petal×3, prayer_crystal×2; bonus: gold_income 1.1
const TEST_RECIPE_ID = "prayer_amplifier";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [],
bosses: [],
quests: [],
disciples: [],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 0,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [
{ materialId: "divine_petal", quantity: 5 },
{ materialId: "prayer_crystal", quantity: 5 },
],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("goddessCraft route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessCraftRouter } = await import("../../src/routes/goddessCraft.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-craft", goddessCraftRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-craft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when recipeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown recipe", async () => {
const res = await post({ recipeId: "nonexistent_recipe" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when recipe is already crafted", async () => {
const goddess = makeGoddessState();
goddess.exploration.craftedRecipeIds = [TEST_RECIPE_ID];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough material (first requirement)", async () => {
const goddess = makeGoddessState();
goddess.exploration.materials = [
{ materialId: "divine_petal", quantity: 1 }, // needs 3
{ materialId: "prayer_crystal", quantity: 5 },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when material is completely absent", async () => {
const goddess = makeGoddessState();
goddess.exploration.materials = []; // neither material present
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 200 with updated multipliers and materials on success", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const body = await res.json() as {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedPrayersMultiplier: number;
craftedDivinityMultiplier: number;
craftedCombatMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
};
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
expect(body.bonusValue).toBe(1.1);
expect(body.craftedPrayersMultiplier).toBeGreaterThan(1);
});
it("returns 500 when the database throws an Error", 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);
});
});
+619
View File
@@ -0,0 +1,619 @@
/* 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, afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState, GoddessExplorationArea } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: 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();
}),
}));
// Custom test areas exercising event types not present in the real data
const PRAYERS_LOSS_AREA: GoddessExplorationArea = {
description: "Test area for prayers_loss events",
durationSeconds: 1,
events: [
{ effect: { amount: 100, type: "prayers_loss" }, id: "test_prayers_loss", text: "You lost some prayers." },
],
id: "test_prayers_loss_area",
name: "Test Prayers Loss Area",
possibleMaterials: [],
zoneId: "goddess_celestial_garden",
};
const DIVINITY_GAIN_AREA: GoddessExplorationArea = {
description: "Test area for divinity_gain events",
durationSeconds: 1,
events: [
{ effect: { amount: 10, type: "divinity_gain" }, id: "test_divinity_gain", text: "You gained divinity." },
],
id: "test_divinity_gain_area",
name: "Test Divinity Gain Area",
possibleMaterials: [],
zoneId: "goddess_celestial_garden",
};
vi.mock("../../src/data/goddessExplorations.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../../src/data/goddessExplorations.js")>();
return {
defaultGoddessExplorationAreas: [
...original.defaultGoddessExplorationAreas,
PRAYERS_LOSS_AREA,
DIVINITY_GAIN_AREA,
],
};
});
const DISCORD_ID = "test_discord_id";
// garden_glade: zoneId=goddess_celestial_garden, durationSeconds=30
// events[0]: prayers_gain 50; events[1]: disciple_loss 0.05
// possibleMaterials: divine_petal(weight 5), prayer_crystal(weight 3) — total 8
const TEST_AREA_ID = "garden_glade";
const TEST_ZONE_ID = "goddess_celestial_garden";
// celestial_meadow: durationSeconds=60
// events[0]: prayers_gain 200; events[1]: sacred_material_gain celestial_dust qty 2
const MATERIAL_AREA_ID = "celestial_meadow";
const makeGoddessState = (areaId: string, zoneStatus: "unlocked" | "locked" = "unlocked"): NonNullable<GameState["goddess"]> => ({
zones: [
{
id: TEST_ZONE_ID,
name: "Celestial Garden",
description: "",
emoji: "🌸",
status: zoneStatus,
unlockBossId: null,
unlockQuestId: null,
},
],
bosses: [],
quests: [],
disciples: [
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 50,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 0,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [
{ id: areaId, status: "available" },
],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
/** Builds a state with the area in_progress and startedAt in the past so it's complete. */
const makeCompletedAreaState = (
areaId: string,
extraMaterials: Array<{ materialId: string; quantity: number }> = [],
extraPrayers = 0,
): GameState => {
const goddess = makeGoddessState(areaId);
goddess.exploration.areas = [{ id: areaId, status: "in_progress", startedAt: 0 }];
goddess.exploration.materials = extraMaterials;
return makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: extraPrayers } });
};
describe("goddessExplore route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessExploreRouter } = await import("../../src/routes/goddessExplore.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-explore", goddessExploreRouter);
});
afterEach(() => {
vi.restoreAllMocks();
});
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/goddess-explore/claimable"
: `http://localhost/goddess-explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
const postStart = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-explore/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
const postCollect = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-explore/collect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("nonexistent_area");
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when goddess is undefined", async () => {
const state = makeState(); // no goddess
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area is not in_progress", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// area status is "available" (not in_progress)
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = []; // area missing entirely
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when exploration is not yet complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = now → not complete yet (duration is 30s)
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=true when exploration is complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = 0 → expired long ago
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postStart({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await postStart({ areaId: "nonexistent_area" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when zone is not unlocked", async () => {
const goddess = makeGoddessState(TEST_AREA_ID, "locked");
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when zone is not found in goddess state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.zones = []; // zone missing
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 404 when area is not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = []; // area missing
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when another exploration is already in progress", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [
{ id: TEST_AREA_ID, status: "available" },
{ id: "other_area", status: "in_progress" },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when area is locked", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "locked" }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 200 with areaId and endsAt on success", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { areaId: string; endsAt: number };
expect(body.areaId).toBe(TEST_AREA_ID);
expect(body.endsAt).toBeGreaterThan(Date.now());
});
it("returns 500 when the database throws an Error", 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", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
describe("POST /collect", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postCollect({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await postCollect({ areaId: "nonexistent_area" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 404 when area is not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when area is not in_progress", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// area is "available", not "in_progress"
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when exploration is not yet complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = now → still in progress for 30s
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns foundNothing=true when Math.random is below 0.2 (nothing path)", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.2 → nothing
const state = makeCompletedAreaState(TEST_AREA_ID);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; nothingMessage: string; materialsFound: unknown[] };
expect(body.foundNothing).toBe(true);
expect(typeof body.nothingMessage).toBe("string");
});
it("applies prayers_gain event and returns prayersChange > 0", async () => {
const mockRandom = vi.spyOn(Math, "random");
// garden_glade has 2 events: [prayers_gain(0), disciple_loss(1)]
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 2) = 0 → prayers_gain
// Call 3: possibleMaterials roll (total weight 8): 0 * 8 = 0, 0 - 5 = -5 ≤ 0 → divine_petal
// Call 4: quantity Math.floor(0 * (3-1+1)) + 1 = 0 + 1 = 1
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0);
const state = makeCompletedAreaState(TEST_AREA_ID);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; event: { prayersChange: number }; materialsFound: Array<{ materialId: string }> };
expect(body.foundNothing).toBe(false);
expect(body.event.prayersChange).toBeGreaterThan(0);
});
it("applies sacred_material_gain event and pushes new material", async () => {
const mockRandom = vi.spyOn(Math, "random");
// celestial_meadow: events[1] = sacred_material_gain celestial_dust qty 2
// index 1: Math.floor(0.6 * 2) = 1
// possibleMaterials: [divine_petal(4), celestial_dust(3)] total 7
// Call 3: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal (new material)
// Call 4: Math.floor(0 * (4-2+1)) + 2 = 0 + 2 = 2
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event index: Math.floor(0.6 * 2) = 1 → sacred_material_gain
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 2 = 2
const state = makeCompletedAreaState(MATERIAL_AREA_ID); // no materials in state
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } }; materialsFound: Array<{ materialId: string }> };
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
});
it("increments existing material quantity on sacred_material_gain event", async () => {
const mockRandom = vi.spyOn(Math, "random");
// Same as above — celestial_meadow events[1] = sacred_material_gain celestial_dust
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event: sacred_material_gain
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
.mockReturnValueOnce(0); // quantity → 2
const state = makeCompletedAreaState(MATERIAL_AREA_ID, [
{ materialId: "celestial_dust", quantity: 5 }, // pre-existing
]);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
expect(body.event.materialGained?.quantity).toBeGreaterThan(0);
});
it("returns materialsFound with new material from possibleMaterials when none pre-existing", async () => {
const mockRandom = vi.spyOn(Math, "random");
// prayers_gain event, then roll for divine_petal (new)
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal (first, weight 5)
.mockReturnValueOnce(0); // quantity → min (1)
const state = makeCompletedAreaState(TEST_AREA_ID); // no materials
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { materialsFound: Array<{ materialId: string; quantity: number }> };
expect(body.materialsFound.length).toBeGreaterThan(0);
expect(body.materialsFound[0]?.materialId).toBe("divine_petal");
});
it("increments existing possibleMaterial quantity when material is already in state", async () => {
const mockRandom = vi.spyOn(Math, "random");
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
.mockReturnValueOnce(0); // quantity → 1
const state = makeCompletedAreaState(TEST_AREA_ID, [
{ materialId: "divine_petal", quantity: 10 }, // pre-existing
]);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
expect(body.materialsFound.some((m) => {
return m.materialId === "divine_petal";
})).toBe(true);
});
it("applies disciple_loss event and reduces disciple counts", async () => {
const mockRandom = vi.spyOn(Math, "random");
// garden_glade events[1] = disciple_loss fraction 0.05
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.6 * 2) = 1 → disciple_loss
// possibleMaterials: total weight 8; call 3: 0.9 * 8 = 7.2; 7.2 - 5 = 2.2 > 0; 2.2 - 3 = -0.8 ≤ 0 → prayer_crystal
// Call 4: quantity for prayer_crystal: Math.floor(0 * (2-1+1)) + 1 = 1
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event: Math.floor(0.6 * 2) = 1 → disciple_loss
.mockReturnValueOnce(0.9) // possibleMaterials roll → prayer_crystal
.mockReturnValueOnce(0); // quantity → 1
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
// Need disciples with non-zero count so lost > 0 triggers
goddess.disciples = [
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
});
it("applies prayers_loss event and returns negative prayersChange", async () => {
const mockRandom = vi.spyOn(Math, "random");
// test_prayers_loss_area has 1 event: prayers_loss 100
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 1) = 0 → prayers_loss
// No possibleMaterials, so no further calls needed
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1);
const goddess = makeGoddessState(PRAYERS_LOSS_AREA.id);
goddess.exploration.areas = [{ id: PRAYERS_LOSS_AREA.id, status: "in_progress", startedAt: 0 }];
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: PRAYERS_LOSS_AREA.id });
expect(res.status).toBe(200);
const body = await res.json() as { event: { prayersChange: number }; foundNothing: boolean };
expect(body.foundNothing).toBe(false);
expect(body.event.prayersChange).toBeLessThanOrEqual(0);
});
it("applies divinity_gain event and increases divinity", async () => {
const mockRandom = vi.spyOn(Math, "random");
// test_divinity_gain_area has 1 event: divinity_gain 10
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 1) = 0 → divinity_gain
// No possibleMaterials
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1);
const goddess = makeGoddessState(DIVINITY_GAIN_AREA.id);
goddess.exploration.areas = [{ id: DIVINITY_GAIN_AREA.id, status: "in_progress", startedAt: 0 }];
const initialDivinity = goddess.consecration.divinity;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: DIVINITY_GAIN_AREA.id });
expect(res.status).toBe(200);
// Verify state was saved with updated divinity
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: { goddess: { consecration: { divinity: number } } } };
};
expect(updateArg.data.state.goddess.consecration.divinity).toBe(initialDivinity + 10);
});
it("returns 500 when the database throws an Error", 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", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
});
+255
View File
@@ -0,0 +1,255 @@
/* 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() },
},
}));
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();
}),
}));
const DISCORD_ID = "test_discord_id";
// prayer_offering_1 costs 50 prayers, 0 divinity, 0 stardust; unlocked: true
const TEST_UPGRADE_ID = "prayer_offering_1";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [],
bosses: [],
quests: [],
disciples: [],
equipment: [],
upgrades: [
{
id: TEST_UPGRADE_ID,
name: "Morning Offering I",
description: "",
target: "prayers",
multiplier: 1.25,
costPrayers: 50,
costDivinity: 0,
costStardust: 0,
purchased: false,
unlocked: true,
},
],
achievements: [],
consecration: {
count: 0,
divinity: 100,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 100,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("goddessUpgrade route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessUpgradeRouter } = await import("../../src/routes/goddessUpgrade.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-upgrade", goddessUpgradeRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-upgrade/buy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when upgradeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown upgrade", async () => {
const res = await post({ upgradeId: "nonexistent_upgrade" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 404 when upgrade is not found in goddess state", async () => {
const goddess = makeGoddessState();
goddess.upgrades = []; // no upgrades in state
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when upgrade is not yet unlocked", async () => {
const goddess = makeGoddessState();
goddess.upgrades[0]!.unlocked = false;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when upgrade is already purchased", async () => {
const goddess = makeGoddessState();
goddess.upgrades[0]!.purchased = true;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when prayers is undefined (treats as 0)", async () => {
const goddess = makeGoddessState();
// Omitting prayers entirely exercises the `?? 0` fallback on line 75
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough prayers", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 10 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough divinity", async () => {
// prayer_offering_3 costs 1 divinity
const upgradeId = "prayer_offering_3";
const goddess = makeGoddessState();
goddess.upgrades.push({
id: upgradeId,
name: "Morning Offering III",
description: "",
target: "prayers",
multiplier: 2,
costPrayers: 1000,
costDivinity: 1,
costStardust: 0,
purchased: false,
unlocked: true,
});
goddess.consecration.divinity = 0; // need 1 but have 0
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 5000 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId });
expect(res.status).toBe(400);
});
it("returns 400 when not enough stardust", async () => {
// divine_spark_2 is in defaultGoddessUpgrades with costStardust: 1, costDivinity: 100, costPrayers: 500_000
const upgradeId = "divine_spark_2";
const goddess = makeGoddessState();
goddess.upgrades.push({
id: upgradeId,
name: "Divine Spark II",
description: "",
target: "prayers",
multiplier: 25,
costPrayers: 500_000,
costDivinity: 100,
costStardust: 1,
purchased: false,
unlocked: true,
});
goddess.consecration.divinity = 100; // enough divinity
goddess.enlightenment.stardust = 0; // NOT enough stardust (need 1)
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 500_000 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId });
expect(res.status).toBe(400);
});
it("returns 200 with remaining resources on success", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(200);
const body = await res.json() as { prayersRemaining: number; divinityRemaining: number; stardustRemaining: number };
expect(body.prayersRemaining).toBe(150); // 200 - 50
expect(body.divinityRemaining).toBe(100); // no divinity cost
expect(body.stardustRemaining).toBe(100); // no stardust cost
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ upgradeId: TEST_UPGRADE_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({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(500);
});
});
+12
View File
@@ -152,6 +152,18 @@ describe("leaderboards route", () => {
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 () => {
vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never);
vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never);
+62 -8
View File
@@ -7,8 +7,8 @@ import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn() },
player: { findUnique: vi.fn(), update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
},
}));
@@ -47,8 +47,8 @@ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
describe("prestige route", () => {
let app: Hono;
let prisma: {
player: { update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
};
beforeEach(async () => {
@@ -81,10 +81,20 @@ describe("prestige route", () => {
expect(res.status).toBe(400);
});
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns runestones on successful prestige", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
@@ -93,6 +103,26 @@ describe("prestige route", () => {
expect(body.runestones).toBeGreaterThanOrEqual(0);
});
it("returns 409 when a concurrent prestige already committed", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 0 } as never);
const res = await post("");
expect(res.status).toBe(409);
});
it("returns 500 when the database throws during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during prestige", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
it("updates daily challenge progress when dailyChallenges are set", async () => {
const state = makeState({
dailyChallenges: {
@@ -100,14 +130,26 @@ describe("prestige route", () => {
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
} as GameState["dailyChallenges"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { runestones: number; newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
});
it("skips webhook when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
});
});
describe("POST /buy-upgrade", () => {
@@ -152,5 +194,17 @@ describe("prestige route", () => {
expect(body.runestonesRemaining).toBe(90); // 100 - 10
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");
});
it("returns 500 when the database throws during profile get", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value during profile get", async () => {
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(500);
});
it("includes completed story chapters in profile response", async () => {
const state = makeState({
story: {
@@ -256,5 +268,23 @@ describe("profile route", () => {
const body = await res.json() as { profileSettings: { numberFormat: string } };
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);
});
});
});
+189
View File
@@ -0,0 +1,189 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn() },
},
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
log: vi.fn().mockResolvedValue(undefined),
},
}));
const makeState = (overrides: Record<string, unknown> = {}) => ({
quests: [],
exploration: { areas: [] },
...overrides,
});
describe("timers route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { timersRouter } = await import("../../src/routes/timers.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/timers", timersRouter);
});
const get = (userId: string) =>
app.fetch(new Request(`http://localhost/timers/${userId}`));
it("returns 400 for a non-numeric user ID", async () => {
const res = await get("not-a-number");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Invalid user ID");
});
it("returns 404 when player is not found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await get("123456789");
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Player not found");
});
it("returns empty arrays when no active quests or explorations", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({
state: makeState(),
});
const res = await get("123456789");
expect(res.status).toBe(200);
const body = await res.json() as { quests: unknown[]; explorations: unknown[] };
expect(body.quests).toEqual([]);
expect(body.explorations).toEqual([]);
});
it("returns active quest timers with endsAt computed from startedAt + duration", async () => {
const startedAt = Date.now() - 30_000;
const state = makeState({
quests: [
{
id: "q1",
name: "Forest Patrol",
status: "active",
startedAt: startedAt,
durationSeconds: 600,
},
],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
const res = await get("123456789");
expect(res.status).toBe(200);
const body = await res.json() as {
quests: Array<{ questId: string; name: string; endsAt: number; timeLeft: number }>;
};
expect(body.quests).toHaveLength(1);
expect(body.quests[0]?.questId).toBe("q1");
expect(body.quests[0]?.name).toBe("Forest Patrol");
expect(body.quests[0]?.endsAt).toBe(startedAt + 600_000);
expect(body.quests[0]?.timeLeft).toBeGreaterThan(0);
});
it("filters out quests that are not in_progress", async () => {
const state = makeState({
quests: [
{ id: "q1", name: "Done Quest", status: "completed", startedAt: 0, durationSeconds: 60 },
{ id: "q2", name: "Idle Quest", status: "available", durationSeconds: 60 },
],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
const res = await get("123456789");
const body = await res.json() as { quests: unknown[] };
expect(body.quests).toHaveLength(0);
});
it("returns timeLeft of 0 for already-completed quests still marked in_progress", async () => {
const startedAt = Date.now() - 700_000;
const state = makeState({
quests: [
{
id: "q1",
name: "Old Quest",
status: "active",
startedAt: startedAt,
durationSeconds: 600,
},
],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
const res = await get("123456789");
const body = await res.json() as {
quests: Array<{ timeLeft: number }>;
};
expect(body.quests[0]?.timeLeft).toBe(0);
});
it("returns active exploration timers", async () => {
const endsAt = Date.now() + 120_000;
const state = makeState({
exploration: {
areas: [
{ id: "verdant_meadows", status: "in_progress", endsAt },
{ id: "unknown_area_xyz", status: "in_progress", endsAt },
],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
const res = await get("123456789");
const body = await res.json() as {
explorations: Array<{ areaId: string; name: string; endsAt: number; timeLeft: number }>;
};
expect(body.explorations).toHaveLength(2);
expect(body.explorations[0]?.areaId).toBe("verdant_meadows");
expect(body.explorations[0]?.endsAt).toBe(endsAt);
expect(body.explorations[0]?.timeLeft).toBeGreaterThan(0);
// Unknown area falls back to ID as name
expect(body.explorations[1]?.name).toBe("unknown_area_xyz");
});
it("filters out explorations not in_progress", async () => {
const state = makeState({
exploration: {
areas: [
{ id: "area1", status: "available" },
{ id: "area2", status: "completed" },
],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
const res = await get("123456789");
const body = await res.json() as { explorations: unknown[] };
expect(body.explorations).toHaveLength(0);
});
it("handles missing exploration state gracefully", async () => {
const state = { quests: [] };
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
const res = await get("123456789");
expect(res.status).toBe(200);
const body = await res.json() as { explorations: unknown[] };
expect(body.explorations).toHaveLength(0);
});
it("returns 500 on database error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(
new Error("DB failure"),
);
const res = await get("123456789");
expect(res.status).toBe(500);
const body = await res.json() as { error: string };
expect(body.error).toBe("Internal server error");
});
it("returns 500 and logs non-Error throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
const res = await get("123456789");
expect(res.status).toBe(500);
});
});
+25 -1
View File
@@ -92,6 +92,18 @@ describe("transcendence route", () => {
expect(body.newTranscendenceCount).toBe(1);
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", () => {
@@ -146,8 +158,20 @@ describe("transcendence route", () => {
const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" });
expect(res.status).toBe(200);
const body = await res.json() as { echoesRemaining: number; purchasedUpgradeIds: string[] };
expect(body.echoesRemaining).toBe(95); // 100 - 5
expect(body.echoesRemaining).toBe(98); // 100 - 2
expect(body.purchasedUpgradeIds).toContain("echo_income_1");
});
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);
});
});
});
+36
View File
@@ -112,4 +112,40 @@ describe("buildPostApotheosisState", () => {
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.apotheosis?.count).toBe(1);
});
it("initialises goddess state on first apotheosis (count goes to 1)", () => {
const state = makeMinimalState();
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.goddess).toBeDefined();
});
it("preserves existing goddess state on second apotheosis (count goes to 2)", () => {
const goddessState: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const state = makeMinimalState({ apotheosis: { count: 1 }, goddess: goddessState });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.goddess).toEqual(goddessState);
});
it("does not add goddess when count goes to 2 but no goddess exists on current state", () => {
const state = makeMinimalState({ apotheosis: { count: 1 } });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.goddess).toBeUndefined();
});
});
+27 -3
View File
@@ -46,13 +46,37 @@ describe("generateDailyChallenges", () => {
expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id));
});
it("generates different challenges for different dates", async () => {
it("always includes a clicks challenge regardless of date", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16");
// They should differ in at least one challenge ID (types vary by seed)
expect(day1.map((c) => c.type)).not.toEqual(day2.map((c) => c.type));
expect(day1.some((c) => c.type === "clicks")).toBe(true);
expect(day2.some((c) => c.type === "clicks")).toBe(true);
});
it("always includes a crafting challenge regardless of date", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16");
expect(day1.some((c) => c.type === "crafting")).toBe(true);
expect(day2.some((c) => c.type === "crafting")).toBe(true);
});
it("progression challenge slot varies across different dates", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
// 2024-01-01 picks bossesDefeated, 2024-01-02 picks prestige (verified by seed)
const day1 = generateDailyChallenges("2024-01-01");
const day2 = generateDailyChallenges("2024-01-02");
const day1ProgressionType = day1.find((c) => {
return c.type !== "clicks" && c.type !== "crafting";
})?.type;
const day2ProgressionType = day2.find((c) => {
return c.type !== "clicks" && c.type !== "crafting";
})?.type;
expect(day1ProgressionType).not.toBe(day2ProgressionType);
});
});
+67 -23
View File
@@ -18,51 +18,31 @@ describe("discord service", () => {
});
describe("buildOAuthUrl", () => {
it("throws when DISCORD_CLIENT_ID is missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
delete process.env["DISCORD_REDIRECT_URI"];
const { buildOAuthUrl } = await import("../../src/services/discord.js");
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
});
it("returns a URL with correct query params", async () => {
process.env["DISCORD_CLIENT_ID"] = "client123";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
const { buildOAuthUrl } = await import("../../src/services/discord.js");
const url = buildOAuthUrl();
expect(url).toContain("client_id=client123");
expect(url).toContain("client_id=1479551654264049908");
expect(url).toContain("response_type=code");
expect(url).toContain("scope=identify");
});
});
describe("exchangeCode", () => {
it("throws when env vars are missing", async () => {
delete process.env["DISCORD_CLIENT_ID"];
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
delete process.env["DISCORD_CLIENT_SECRET"];
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
});
it("throws when response is not ok", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
});
it("returns parsed body on success", async () => {
process.env["DISCORD_CLIENT_ID"] = "cid";
process.env["DISCORD_CLIENT_SECRET"] = "secret";
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
const { exchangeCode } = await import("../../src/services/discord.js");
@@ -86,5 +66,69 @@ describe("discord service", () => {
expect(result.id).toBe("123");
expect(result.username).toBe("testuser");
});
it("re-throws when fetch rejects with a non-Error value", async () => {
mockFetch.mockRejectedValueOnce("raw string error");
const { fetchDiscordUser } = await import("../../src/services/discord.js");
await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error");
});
});
describe("exchangeCode non-Error throw", () => {
it("re-throws when fetch rejects with a non-Error value", async () => {
process.env["DISCORD_CLIENT_SECRET"] = "secret";
mockFetch.mockRejectedValueOnce("raw string error");
const { exchangeCode } = await import("../../src/services/discord.js");
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
});
});
describe("fetchDiscordUserById", () => {
it("returns null when DISCORD_BOT_TOKEN is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when DISCORD_BOT_TOKEN is empty", async () => {
process.env["DISCORD_BOT_TOKEN"] = "";
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when response is not ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" });
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when fetch throws", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockRejectedValueOnce(new Error("network error"));
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns null when fetch throws a non-Error value", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockRejectedValueOnce("raw string error");
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toBeNull();
});
it("returns the user on success", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" };
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) });
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
const result = await fetchDiscordUserById("123456");
expect(result).toMatchObject({ id: "123456", avatar: "abc123" });
});
});
});
+105
View File
@@ -0,0 +1,105 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/db/client.js", () => ({
prisma: {
player: { updateMany: vi.fn() },
},
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
log: vi.fn().mockResolvedValue(undefined),
},
}));
import { prisma } from "../../src/db/client.js";
const discordGuildId = "1354624415861833870";
describe("gateway service", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("handleGuildMemberAdd", () => {
it("sets inGuild to true for the matching guild", async () => {
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(prisma.player.updateMany).toHaveBeenCalledWith({
data: { inGuild: true },
where: { discordId: "user123" },
});
});
it("no-ops when guild id does not match the configured guild", async () => {
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
await handleGuildMemberAdd("user123", "other_guild");
expect(prisma.player.updateMany).not.toHaveBeenCalled();
});
it("logs error when prisma throws an Error", async () => {
const dbError = new Error("DB failure");
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
});
it("logs error when prisma throws a non-Error", async () => {
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberAdd("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith(
"gateway_member_add",
new Error("raw error"),
);
});
});
describe("handleGuildMemberRemove", () => {
it("sets inGuild to false for the matching guild", async () => {
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(prisma.player.updateMany).toHaveBeenCalledWith({
data: { inGuild: false },
where: { discordId: "user123" },
});
});
it("no-ops when guild id does not match the configured guild", async () => {
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
await handleGuildMemberRemove("user123", "other_guild");
expect(prisma.player.updateMany).not.toHaveBeenCalled();
});
it("logs error when prisma throws an Error", async () => {
const dbError = new Error("DB failure");
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
});
it("logs error when prisma throws a non-Error", async () => {
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
const { logger } = await import("../../src/services/logger.js");
await handleGuildMemberRemove("user123", discordGuildId);
expect(logger.error).toHaveBeenCalledWith(
"gateway_member_remove",
new Error("raw error"),
);
});
});
});
+204 -23
View File
@@ -13,14 +13,24 @@ import {
} from "../../src/services/prestige.js";
import type { GameState } from "@elysium/types";
const makePlayer = (totalGoldEarned: number) => ({
discordId: "test_id",
username: "testuser",
discriminator: "0",
avatar: null,
totalGoldEarned,
totalClicks: 0,
characterName: "Tester",
const makePlayer = (
totalGoldEarned: number,
lifetimeGoldEarned = 0,
totalClicks = 0,
) => ({
avatar: null,
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 =>
@@ -45,15 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
describe("calculatePrestigeThreshold", () => {
it("returns base threshold at count 0", () => {
// base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
});
it("returns 5× at count 1", () => {
expect(calculatePrestigeThreshold(1)).toBe(5_000_000);
it("returns base × 2^2.5 at count 1", () => {
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
});
it("returns 25× at count 2", () => {
expect(calculatePrestigeThreshold(2)).toBe(25_000_000);
it("returns base × 3^2.5 at count 2", () => {
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
});
it("applies threshold multiplier correctly", () => {
@@ -89,21 +102,27 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => {
// floor(sqrt(4_000_000 / 1_000_000)) × 10 = floor(2) × 10 = 20
// floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(20);
});
it("applies echo runestone multiplier", () => {
// floor(sqrt(4) × 10) = 20; × 2 = 40
// floor(cbrt(4)) × 20 = 20; × 2 = 40
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(40);
});
it("applies purchased runestone upgrade multiplier", () => {
// With "runestones_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
// With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBeGreaterThan(20);
expect(result).toBe(25);
});
it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200);
});
});
@@ -112,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
expect(calculateProductionMultiplier(0)).toBe(1);
});
it("returns 1.15 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.15);
it("returns 1.3 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
});
it("scales exponentially", () => {
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.15, 10));
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10));
});
});
@@ -132,12 +151,12 @@ describe("calculateMilestoneBonus", () => {
expect(calculateMilestoneBonus(5)).toBe(25);
});
it("returns 50 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(50);
it("returns 100 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(100);
});
it("returns 75 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(75);
it("returns 225 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(225);
});
});
@@ -236,10 +255,172 @@ describe("buildPostPrestigeState", () => {
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
});
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
const state = makeMinimalState({
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
});
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
});
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
const state = makeMinimalState();
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
});
it("preserves apotheosis data across prestige", () => {
const apotheosis = { count: 2 };
const state = makeMinimalState({ apotheosis });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
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);
});
});
+11 -5
View File
@@ -97,20 +97,21 @@ describe("isEligibleForTranscendence", () => {
describe("calculateEchoes", () => {
it("handles prestige count of 0 by treating it as 1", () => {
// safeCount = max(0, 1) = 1; floor(853 / sqrt(1)) = 853
expect(calculateEchoes(0, 1)).toBe(853);
// safeCount = max(0, 1) = 1; floor(224 / sqrt(1)) = 224
expect(calculateEchoes(0, 1)).toBe(224);
});
it("calculates echoes at count 1", () => {
expect(calculateEchoes(1, 1)).toBe(853);
// floor(224 / sqrt(1)) = 224
expect(calculateEchoes(1, 1)).toBe(224);
});
it("decreases echoes with higher prestige count", () => {
const echoesAt1 = calculateEchoes(1, 1);
const echoesAt4 = calculateEchoes(4, 1);
expect(echoesAt4).toBeLessThan(echoesAt1);
// floor(853 / sqrt(4)) = floor(853 / 2) = 426
expect(echoesAt4).toBe(426);
// floor(224 / sqrt(4)) = floor(224 / 2) = 112
expect(echoesAt4).toBe(112);
});
it("applies echoMetaMultiplier", () => {
@@ -118,6 +119,11 @@ describe("calculateEchoes", () => {
const withMult = calculateEchoes(1, 2);
expect(withMult).toBe(base * 2);
});
it("returns 50 echoes at the target prestige 20", () => {
// floor(224 / sqrt(20)) = floor(224 / 4.472) = floor(50.09) = 50
expect(calculateEchoes(20, 1)).toBe(50);
});
});
describe("buildPostTranscendenceState", () => {
+76 -28
View File
@@ -20,42 +20,20 @@ describe("webhook service", () => {
describe("grantApotheosisRole", () => {
it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when guild id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
delete process.env["DISCORD_GUILD_ID"];
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when role id is missing", async () => {
process.env["DISCORD_BOT_TOKEN"] = "token";
process.env["DISCORD_GUILD_ID"] = "guild123";
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
});
it("calls Discord API with correct URL and auth when env vars are set", async () => {
it("calls Discord API with correct URL and auth when bot token is set", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
process.env["DISCORD_GUILD_ID"] = "guild123";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
mockFetch.mockResolvedValueOnce({ ok: true });
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await grantApotheosisRole("user789");
expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
expect.objectContaining({
method: "PUT",
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
@@ -63,12 +41,74 @@ describe("webhook service", () => {
it("swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
process.env["DISCORD_GUILD_ID"] = "g";
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
it("swallows non-Error fetch rejections gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
});
});
describe("grantElysianRole", () => {
it("does nothing when bot token is missing", async () => {
delete process.env["DISCORD_BOT_TOKEN"];
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user123");
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("returns true when Discord API responds with ok", async () => {
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user789");
expect(mockFetch).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
}),
);
expect(result).toBe(true);
});
it("returns true when Discord API responds with 204", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(true);
});
it("returns false when Discord API responds with an error status", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows fetch errors gracefully", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
it("returns false and swallows non-Error fetch rejections", async () => {
process.env["DISCORD_BOT_TOKEN"] = "tok";
mockFetch.mockRejectedValueOnce("raw string error");
const { grantElysianRole } = await import("../../src/services/webhook.js");
const result = await grantElysianRole("user");
expect(result).toBe(false);
});
});
describe("postMilestoneWebhook", () => {
@@ -88,9 +128,10 @@ describe("webhook service", () => {
await postMilestoneWebhook("user123", "prestige", counts);
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
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("prestiged");
expect(body.flags).toBe(4096);
});
it("posts transcendence message correctly", async () => {
@@ -119,5 +160,12 @@ describe("webhook service", () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
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();
});
});
});
+2
View File
@@ -10,6 +10,8 @@ export default defineConfig({
"src/db/client.ts",
"src/index.ts",
"src/data/materials.ts",
// Goddess materials data file — not directly imported by any route (referenced by ID strings only)
"src/data/goddessMaterials.ts",
],
thresholds: {
statements: 100,
+33
View File
@@ -5,6 +5,39 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysium — Idle RPG</title>
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
<!-- 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>
<body>
<div id="root"></div>
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.1.1",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,20 +12,21 @@
},
"dependencies": {
"@elysium/types": "workspace:*",
"react": "19.0.0",
"react-dom": "19.0.0"
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "10.1.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "4.3.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0",
"jsdom": "26.0.0",
"jsdom": "29.0.1",
"typescript": "5.8.2",
"vite": "6.2.1",
"vite": "8.0.5",
"vitest": "3.0.8"
}
}
+246
View File
@@ -4,6 +4,7 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines -- API client grows with each new endpoint group */
import type {
AboutResponse,
ApotheosisRequest,
@@ -11,22 +12,44 @@ import type {
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyConsecrationUpgradeRequest,
BuyConsecrationUpgradeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyEnlightenmentUpgradeRequest,
BuyEnlightenmentUpgradeResponse,
BuyGoddessUpgradeRequest,
BuyGoddessUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
ConsecrationRequest,
ConsecrationResponse,
CraftRecipeRequest,
CraftRecipeResponse,
EnlightenmentRequest,
EnlightenmentResponse,
ExploreClaimableResponse,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
ForceUnlocksResponse,
GoddessBossChallengeRequest,
GoddessBossChallengeResponse,
GoddessCraftRequest,
GoddessCraftResponse,
GoddessExploreClaimableResponse,
GoddessExploreCollectRequest,
GoddessExploreCollectResponse,
GoddessExploreStartRequest,
GoddessExploreStartResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
PublicProfileResponse,
SaveRequest,
SaveResponse,
SyncNewContentResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
@@ -35,6 +58,26 @@ import type {
const baseUrl = "/api";
/**
* Represents a 4xx API error so callers can distinguish expected server
* rejections from unexpected failures. ValidationErrors are downgraded to
* console.warn and are not forwarded to the error-email pipeline.
*/
class ValidationError extends Error {
public readonly statusCode: number;
/**
* Creates a new ValidationError.
* @param message - The error message from the server response.
* @param statusCode - The HTTP status code (4xx) returned by the server.
*/
public constructor(message: string, statusCode: number) {
super(message);
this.name = "ValidationError";
this.statusCode = statusCode;
}
}
const getToken = (): string | null => {
return globalThis.localStorage.getItem("elysium_token");
};
@@ -69,6 +112,14 @@ const fetchJson = async <T>(
= typeof errorBody.error === "string"
? errorBody.error
: "Unknown error";
if (response.status === 401) {
globalThis.localStorage.removeItem("elysium_token");
globalThis.localStorage.removeItem("elysium_save_signature");
globalThis.location.href = "/";
}
if (response.status >= 400 && response.status < 500) {
throw new ValidationError(message, response.status);
}
throw new Error(message);
}
@@ -242,6 +293,19 @@ const collectExploration = async(
});
};
/**
* Checks whether a given exploration area is ready to claim on the server.
* @param areaId - The area ID to check.
* @returns Whether the exploration is claimable.
*/
const checkExplorationClaimable = async(
areaId: string,
): Promise<ExploreClaimableResponse> => {
return await fetchJson<ExploreClaimableResponse>(
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
);
};
/**
* Crafts a recipe on the server.
* @param body - The craft recipe request payload.
@@ -256,6 +320,173 @@ const craftRecipe = async(
});
};
/**
* Sends a request to fix any missing unlocks in the player's game state.
* @returns The corrected game state and counts of what was unlocked.
*/
const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
return await fetchJson<ForceUnlocksResponse>("/debug/force-unlocks", {
method: "POST",
});
};
/**
* Syncs any content added after the player's save was created into their save.
* @returns The updated game state and counts of what was added per content type.
*/
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
method: "POST",
});
};
/**
* Performs a complete hard reset of the player's game state via the debug endpoint.
* @returns The fresh game state as a LoadResponse.
*/
const debugHardReset = async(): Promise<LoadResponse> => {
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
};
/**
* Challenges a goddess boss.
* @param body - The goddess boss challenge request payload.
* @returns The goddess boss challenge response data.
*/
const challengeGoddessBoss = async(
body: GoddessBossChallengeRequest,
): Promise<GoddessBossChallengeResponse> => {
return await fetchJson<GoddessBossChallengeResponse>("/goddess/boss", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Triggers a consecration reset on the server.
* @param body - The consecration request payload.
* @returns The consecration response data.
*/
const consecrate = async(
body: ConsecrationRequest,
): Promise<ConsecrationResponse> => {
return await fetchJson<ConsecrationResponse>("/consecration", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Purchases a consecration upgrade on the server.
* @param body - The buy consecration upgrade request payload.
* @returns The buy consecration upgrade response data.
*/
const buyConsecrationUpgrade = async(
body: BuyConsecrationUpgradeRequest,
): Promise<BuyConsecrationUpgradeResponse> => {
return await fetchJson<BuyConsecrationUpgradeResponse>(
"/consecration/buy-upgrade",
{ body: JSON.stringify(body), method: "POST" },
);
};
/**
* Triggers an enlightenment reset on the server.
* @param body - The enlightenment request payload.
* @returns The enlightenment response data.
*/
const enlighten = async(
body: EnlightenmentRequest,
): Promise<EnlightenmentResponse> => {
return await fetchJson<EnlightenmentResponse>("/enlightenment", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Purchases an enlightenment upgrade on the server.
* @param body - The buy enlightenment upgrade request payload.
* @returns The buy enlightenment upgrade response data.
*/
const buyEnlightenmentUpgrade = async(
body: BuyEnlightenmentUpgradeRequest,
): Promise<BuyEnlightenmentUpgradeResponse> => {
return await fetchJson<BuyEnlightenmentUpgradeResponse>(
"/enlightenment/buy-upgrade",
{ body: JSON.stringify(body), method: "POST" },
);
};
/**
* Purchases a goddess upgrade on the server.
* @param body - The buy goddess upgrade request payload.
* @returns The buy goddess upgrade response data.
*/
const buyGoddessUpgrade = async(
body: BuyGoddessUpgradeRequest,
): Promise<BuyGoddessUpgradeResponse> => {
return await fetchJson<BuyGoddessUpgradeResponse>("/goddess/upgrade", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Crafts a goddess recipe on the server.
* @param body - The goddess craft request payload.
* @returns The goddess craft response data.
*/
const craftGoddessRecipe = async(
body: GoddessCraftRequest,
): Promise<GoddessCraftResponse> => {
return await fetchJson<GoddessCraftResponse>("/goddess/craft", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Starts a goddess exploration in a given area.
* @param body - The goddess exploration start request payload.
* @returns The goddess exploration start response data.
*/
const startGoddessExploration = async(
body: GoddessExploreStartRequest,
): Promise<GoddessExploreStartResponse> => {
return await fetchJson<GoddessExploreStartResponse>("/goddess/explore", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Collects the rewards from a completed goddess exploration.
* @param body - The goddess exploration collect request payload.
* @returns The goddess exploration collect response data.
*/
const collectGoddessExploration = async(
body: GoddessExploreCollectRequest,
): Promise<GoddessExploreCollectResponse> => {
return await fetchJson<GoddessExploreCollectResponse>("/goddess/explore", {
body: JSON.stringify(body),
method: "PUT",
});
};
/**
* Checks whether a given goddess exploration area is ready to claim on the server.
* @param areaId - The area ID to check.
* @returns Whether the goddess exploration is claimable.
*/
const checkGoddessExplorationClaimable = async(
areaId: string,
): Promise<GoddessExploreClaimableResponse> => {
return await fetchJson<GoddessExploreClaimableResponse>(
`/goddess/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
);
};
/**
* Fetches a public player profile by Discord ID.
* @param discordId - The Discord ID of the player to look up.
@@ -282,12 +513,26 @@ const updateProfile = async(
};
export {
ValidationError,
achieveApotheosis,
buyConsecrationUpgrade,
buyEchoUpgrade,
buyEnlightenmentUpgrade,
buyGoddessUpgrade,
buyPrestigeUpgrade,
challengeBoss,
challengeGoddessBoss,
checkExplorationClaimable,
checkGoddessExplorationClaimable,
collectExploration,
collectGoddessExploration,
consecrate,
craftGoddessRecipe,
craftRecipe,
debugHardReset,
enlighten,
forceUnlocks,
syncNewContent,
getAbout,
getAuthUrl,
getPublicProfile,
@@ -297,6 +542,7 @@ export {
resetProgress,
saveGame,
startExploration,
startGoddessExploration,
transcend,
updateProfile,
};
+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 };
+186 -21
View File
@@ -7,6 +7,7 @@
/* 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 */
import { type JSX, useEffect, useState } from "react";
import Markdown from "react-markdown";
import { getAbout } from "../../api/client.js";
import type { AboutResponse } from "@elysium/types";
@@ -30,14 +31,24 @@ const howToPlay = [
body:
"Purchase upgrades to multiply the gold and essence output of specific"
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
+ " for the current run and compound with each other.",
+ " 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",
},
{
body:
"Send your guild on quests that complete over time and reward gold,"
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
+ " simultaneously. Completing quests also unlocks new zones.",
+ " 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",
},
{
@@ -58,10 +69,12 @@ const howToPlay = [
{
body:
"Earn equipment from boss drops and quest rewards. Each piece provides"
+ " bonuses to gold income, click power, or combat. Rarer equipment"
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
+ " named set) to unlock escalating set bonuses shown at the top of the"
+ " Equipment panel.",
+ " bonuses to gold income, click power, or boss combat DPS. Rarer"
+ " equipment provides stronger bonuses. Note: combat bonuses only"
+ " affect boss fights — quest combat power is determined solely by"
+ " 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",
},
{
@@ -110,7 +123,11 @@ const howToPlay = [
+ " real-time and reward gold, essence, and crafting materials when"
+ " collected. Each area has a set duration — short explorations are"
+ " 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",
},
{
@@ -153,10 +170,12 @@ const howToPlay = [
{
body:
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
+ " Each item provides bonuses to gold income, combat power, or click"
+ " power. Only one item per slot 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.",
+ " Each item provides bonuses to gold income, boss combat DPS, or click"
+ " power. Combat bonuses only affect boss fights — quest combat power"
+ " is determined solely by your adventurers. Only one item per slot"
+ " 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",
},
{
@@ -180,14 +199,16 @@ const howToPlay = [
},
{
body:
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
+ " automatically sends your party on the highest-zone available quest"
+ " as soon as one completes, skipping quests whose combat power"
+ " requirement isn't met. Auto-Boss automatically challenges the"
+ " highest available boss as soon as one is ready. Both can be toggled"
+ " on or off at any time using the 🤖 Auto button in each panel"
+ " header.",
title: "🤖 Auto-Quest & Auto-Boss",
"Toggle automation in the Quests, Boss Encounters, and Prestige Shop"
+ " panels! Auto-Quest automatically sends your party on the"
+ " highest-zone available quest as soon as one completes, skipping"
+ " quests whose combat power requirement isn't met. Auto-Boss"
+ " automatically challenges the highest available boss as soon as one"
+ " is ready. Auto-Adventurer (unlocked via the Prestige Shop for 50"
+ " runestones) automatically purchases the highest-tier adventurer you"
+ " can currently afford each tick, keeping your income growing after a"
+ " prestige without any manual clicks.",
title: "🤖 Auto-Quest, Auto-Boss & Auto-Adventurer",
},
{
body:
@@ -211,7 +232,7 @@ const howToPlay = [
{
body:
"Transcendence is the ultimate prestige layer, unlocked by defeating"
+ " The Absolute One (requires Prestige 90). Transcending performs a"
+ " The Absolute One (requires Prestige 20). Transcending performs a"
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
+ " and equipment — but grants Echoes based on your prestige count"
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
@@ -233,6 +254,139 @@ const howToPlay = [
+ " entries and lifetime profile statistics are always preserved.",
title: "✨ Apotheosis",
},
{
body:
"Your first Apotheosis unlocks the Goddess Realm — an entirely new"
+ " layer of the game that runs alongside your mortal progress. Switch"
+ " between Mortal and Goddess modes using the mode bar at the top of"
+ " the screen. All Goddess tabs are always visible, but their content"
+ " is locked until Apotheosis. Your mortal progress is fully preserved"
+ " when switching modes — they advance in parallel.",
title: "✨ Goddess Mode",
},
{
body:
"The Goddess Realm uses three currencies: Prayers (earned passively"
+ " from Disciples each tick), Divinity (earned from Disciples and"
+ " multiplied by Consecration bonuses), and Stardust (awarded by"
+ " Goddess Quests, Enlightenment resets, and Achievement unlocks)."
+ " All three are always visible in the resource bar — greyed out"
+ " before Apotheosis, fully active after.",
title: "🙏 Divine Currencies",
},
{
body:
"The Goddess Realm has 18 zones, each containing 4 bosses and 5"
+ " quests. The first zone is always available after Apotheosis."
+ " Subsequent"
+ " zones unlock when you defeat the required Goddess Boss AND complete"
+ " the required Goddess Quest from the preceding zone — the same"
+ " pattern as the mortal game, but with divine stakes.",
title: "🌟 Goddess Zones",
},
{
body:
"Challenge Goddess Bosses to earn Prayers, sacred equipment drops, and"
+ " unlock new Goddess Zones. Each boss has a Consecration requirement"
+ " — you must have consecrated a minimum number of times before you"
+ " can attempt it. Bosses that have been defeated stay defeated;"
+ " the zone simply marks them as cleared.",
title: "⚔️ Goddess Boss Fights",
},
{
body:
"Goddess Quests run on a timer just like mortal quests, but they always"
+ " succeed — there is no failure chance in the divine realm. Rewards"
+ " include Prayers, Divinity, Stardust, and unlocks for Goddess"
+ " Upgrades, Disciples, and equipment. Quests within a zone are"
+ " unlocked in order via prerequisites; a quest becomes available once"
+ " all its required quests are completed.",
title: "📜 Goddess Quests",
},
{
body:
"Disciples are the Goddess Realm's equivalent of adventurers. Hire"
+ " them with Prayers and Divinity to generate passive Prayers and"
+ " Divinity income every tick. Disciples come in six classes — Oracle,"
+ " Seraph, Invoker, Templar, Herald, and Warden — each with unique"
+ " income rates and combat power. Buy in batches of 1, 10, or Max."
+ " Disciple-specific Upgrades multiply the income of individual"
+ " classes, and Global Upgrades stack on top.",
title: "🧎 Disciples",
},
{
body:
"The Goddess Realm has three equipment types: Relics 📿, Vestments 👘,"
+ " and Sigils 🔯. Each type occupies its own slot — only one of each"
+ " can be equipped at a time. Equipment is purchased with Prayers,"
+ " Divinity, and Stardust, or obtained exclusively as Boss Drop rewards"
+ " (marked 🎲 Boss Drop Only). Pieces come in Common, Rare, Epic, and"
+ " Legendary rarities and provide bonuses to Prayers/s, Disciple"
+ " Combat, or Divinity from Consecration.",
title: "🔯 Goddess Equipment & Sets",
},
{
body:
"Goddess Upgrades are purchased with Prayers, Divinity, and Stardust"
+ " and fall into five categories: Prayers (boosts all Disciple prayer"
+ " income globally), Disciple (boosts a specific Disciple class),"
+ " Global (multiplies all Goddess income), Consecration (amplifies"
+ " Consecration bonuses), and Boss (increases Goddess Boss damage)."
+ " Upgrades stack multiplicatively and are permanent within a"
+ " Consecration cycle.",
title: "🔧 Goddess Upgrades",
},
{
body:
"Consecration is the Goddess Realm's prestige layer. When you"
+ " Consecrate, your Prayers and Divinity are reset but you receive a"
+ " permanent production multiplier that stacks with every Consecration."
+ " Spend Divinity in the Consecration Shop on lasting upgrades that"
+ " amplify Prayer income, Divinity income, and Disciple power. Each"
+ " Consecration also raises your Consecration count, which is required"
+ " to challenge higher-tier Goddess Bosses.",
title: "🙏 Consecration",
},
{
body:
"Enlightenment is the Goddess Realm's transcendence layer, available"
+ " after sufficient Consecrations. Enlightening performs a deeper"
+ " reset — clearing Prayers, Divinity, and Consecration progress — in"
+ " exchange for Stardust multipliers that persist forever. Spend"
+ " Stardust in the Enlightenment Shop on meta-upgrades that amplify"
+ " Prayer income, Disciple power, and future Stardust yields.",
title: "🌌 Enlightenment",
},
{
body:
"Sacred Materials are gathered from Goddess Explorations (three unique"
+ " materials per zone). Use them in the Goddess Crafting panel to"
+ " craft recipes that grant permanent multipliers to Prayers/s, Divinity"
+ " from Consecration, and Disciple Combat Power. Each recipe can only"
+ " be crafted once; multipliers from all crafted recipes stack together"
+ " and persist through Consecration and Enlightenment resets.",
title: "⚗️ Goddess Crafting",
},
{
body:
"Send divine scouts to explore areas within each Goddess Zone. Each"
+ " area runs on a timer and rewards Prayers, Divinity, Stardust, and"
+ " Sacred Materials when collected. Goddess Explorations never fail."
+ " Exploration zones unlock alongside their corresponding Goddess Zone"
+ " — four areas are available per zone. Collecting from an area at"
+ " least once marks it as discovered.",
title: "🗺️ Goddess Exploration",
},
{
body:
"Goddess Achievements track milestones across the Goddess Realm:"
+ " total Prayers earned, Goddess Bosses defeated, Goddess Quests"
+ " completed, Disciples hired, Consecration count, and Goddess"
+ " Equipment owned. Unlocking an achievement instantly awards bonus"
+ " Divinity and Stardust. Achievements are checked automatically each"
+ " tick and are permanent once unlocked.",
title: "🏆 Goddess Achievements",
},
{
body:
"The Story tab contains 22 chapters that unlock as you progress. The"
@@ -256,6 +410,15 @@ const howToPlay = [
+ " when you first enable them.",
title: "🔔 Sounds & Notifications",
},
{
body:
"Have a question, found a bug, or want to suggest a feature? Join the"
+ " NHCarrigan community Discord at https://chat.nhcarrigan.com or open"
+ " a support ticket at https://support.nhcarrigan.com. You can also"
+ " report issues directly on the project repository. We'd love to hear"
+ " from you!",
title: "💬 Community & Support",
},
];
const formatDate = (dateString: string): string => {
@@ -331,7 +494,9 @@ const aboutPanel = (): JSX.Element => {
</span>
</button>
{expandedRelease === release.tag_name
&& <pre className="about-release-body">{release.body}</pre>
&& <div className="about-release-body">
<Markdown>{release.body}</Markdown>
</div>
}
</li>
);
@@ -7,8 +7,9 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.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.
@@ -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 {
readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string;
readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string;
readonly progressValue: number;
}
/**
@@ -63,26 +105,47 @@ interface AchievementCardProperties {
* @param props - The achievement card properties.
* @param props.achievement - The achievement to display.
* @param props.formatNumber - The number formatting utility function.
* @param props.progressValue - The player's current progress toward the unlock condition.
* @returns The JSX element.
*/
// eslint-disable-next-line max-lines-per-function -- Progress bar adds necessary lines for locked state
const AchievementCard = ({
achievement,
formatNumber,
progressValue,
}: AchievementCardProperties): JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null;
const crystals = achievement.reward?.crystals;
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
return (
<div className={`achievement-card ${isUnlocked
? "unlocked"
: "locked"}`}>
<div className="achievement-icon">{achievement.icon}</div>
<img
alt={achievement.name}
className="card-thumbnail"
src={cdnImage("achievements", achievement.id)}
/>
<div className="achievement-info">
<h3>{achievement.name}</h3>
<p>{achievement.description}</p>
<p className="achievement-condition">
{conditionDescription(achievement, formatNumber)}
</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
&& <p className="achievement-reward">
{"💎 +"}
@@ -93,7 +156,18 @@ const AchievementCard = ({
</div>
<div className="achievement-status">
{isUnlocked
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
? <>
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
{achievement.unlockedAt !== null
&& <span className="achievement-unlocked-at">
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
}
</>
: <span className="achievement-locked-badge">{"🔒"}</span>
}
</div>
@@ -158,6 +232,7 @@ const AchievementPanel = (): JSX.Element => {
achievement={achievement}
formatNumber={formatNumber}
key={achievement.id}
progressValue={getCurrentProgress(achievement, state)}
/>
);
})}
@@ -9,21 +9,39 @@
/* eslint-disable complexity -- Complex component with many render paths */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { computeEffectiveAdventurerStats } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import type { Adventurer } from "@elysium/types";
const iconByClass: Record<string, string> = {
cleric: "✝️",
mage: "🔮",
paladin: "🛡️",
ranger: "🏹",
rogue: "🗝️",
warrior: "🗡️",
};
type 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.
* @param adventurer - The adventurer to buy.
@@ -59,12 +77,19 @@ const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
return quantity;
};
interface EffectiveAdventurerStats {
readonly combatPower: number;
readonly essencePerSecond: number;
readonly goldPerSecond: number;
}
interface AdventurerCardProperties {
readonly adventurer: Adventurer;
readonly currentGold: number;
readonly batchSize: BatchSize;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
readonly adventurer: Adventurer;
readonly currentGold: number;
readonly batchSize: BatchSize;
readonly unlockHint: string | undefined;
readonly formatNumber: (n: number)=> string;
readonly effectiveStats: EffectiveAdventurerStats;
}
/**
@@ -75,6 +100,7 @@ interface AdventurerCardProperties {
* @param props.batchSize - The selected batch size.
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
* @param props.formatNumber - The number formatting utility function.
* @param props.effectiveStats - The post-multiplier per-unit stats.
* @returns The JSX element.
*/
const AdventurerCard = ({
@@ -83,6 +109,7 @@ const AdventurerCard = ({
batchSize,
unlockHint,
formatNumber,
effectiveStats,
}: AdventurerCardProperties): JSX.Element => {
const { buyAdventurer } = useGame();
@@ -105,26 +132,31 @@ const AdventurerCard = ({
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
: "🔒 Locked";
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
return (
<div className={`adventurer-card ${adventurer.unlocked
? ""
: "locked"}`}>
<div className="adventurer-icon">{adventurerIcon}</div>
<img
alt={adventurer.name}
className="card-thumbnail"
src={cdnImage("adventurers", adventurer.id)}
/>
<div className="adventurer-info">
<h3>{adventurer.name}</h3>
<p>
{formatNumber(adventurer.goldPerSecond)}
{formatNumber(effectiveStats.goldPerSecond)}
{" gold/s each"}
</p>
{adventurer.essencePerSecond > 0
&& <p>
{formatNumber(adventurer.essencePerSecond)}
{formatNumber(effectiveStats.essencePerSecond)}
{" essence/s each"}
</p>
}
<p>
{formatNumber(effectiveStats.combatPower)}
{" combat power each"}
</p>
</div>
<div className="adventurer-count">
{"×"}
@@ -153,9 +185,11 @@ const AdventurerCard = ({
* @returns The JSX element.
*/
const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber } = useGame();
const { state, formatNumber, toggleAutoAdventurer } = useGame();
const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size"));
});
if (state === null) {
return (
@@ -183,6 +217,11 @@ const AdventurerPanel = (): JSX.Element => {
}
}
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
"auto_adventurer",
);
const autoAdventurerOn = state.autoAdventurer === true;
function handleToggle(): void {
setShowLocked((current) => {
return !current;
@@ -193,16 +232,40 @@ const AdventurerPanel = (): JSX.Element => {
<section className="panel adventurer-panel">
<div className="panel-header">
<h2>{"Adventurers"}</h2>
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
<div className="panel-header-controls">
{autoAdventurerUnlocked
? <button
className={`auto-toggle-btn ${
autoAdventurerOn
? "auto-toggle-on"
: "auto-toggle-off"
}`}
onClick={toggleAutoAdventurer}
title={
"Automatically purchase the highest-tier"
+ " affordable adventurer"
}
type="button"
>
{"🤖 Auto: "}
{autoAdventurerOn
? "ON"
: "OFF"}
</button>
: null
}
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
</div>
<div className="batch-selector">
{batchOptions.map((option) => {
function handleBatchSelect(): void {
setBatchSize(option);
localStorage.setItem("elysium_batch_size", String(option));
}
return (
<button
@@ -227,6 +290,10 @@ const AdventurerPanel = (): JSX.Element => {
adventurer={adventurer}
batchSize={batchSize}
currentGold={state.resources.gold}
effectiveStats={computeEffectiveAdventurerStats(
state,
adventurer.id,
)}
formatNumber={formatNumber}
key={adventurer.id}
unlockHint={adventurerUnlockHints.get(adventurer.id)}
+3 -2
View File
@@ -62,6 +62,7 @@ const BattleModal = ({
enableNotifications,
enableSounds,
flushBossLoreToasts,
formatInteger,
formatNumber,
} = useGame();
@@ -241,14 +242,14 @@ const BattleModal = ({
{result.rewards.crystals > 0
&& <span>
{"💎 "}
{formatNumber(result.rewards.crystals)}
{formatInteger(result.rewards.crystals)}
{" crystals"}
</span>
}
{result.rewards.bountyRunestones > 0
&& <span className="battle-bounty">
{"🔮 "}
{formatNumber(result.rewards.bountyRunestones)}
{formatInteger(result.rewards.bountyRunestones)}
{" runestones (first kill!)"}
</span>
}
+111 -74
View File
@@ -11,9 +11,11 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js";
import { computePartyCombatPower } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js";
import type { Boss, GameState } from "@elysium/types";
import type { Boss } from "@elysium/types";
interface BossCardProperties {
readonly boss: Boss;
@@ -21,6 +23,7 @@ interface BossCardProperties {
readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean;
readonly unlockHint: string | undefined;
readonly formatInteger: (n: number)=> string;
readonly formatNumber: (n: number)=> string;
}
@@ -32,6 +35,7 @@ interface BossCardProperties {
* @param props.onChallenge - Callback to challenge this boss.
* @param props.isChallenging - Whether this boss is currently being challenged.
* @param props.unlockHint - Optional hint for how to unlock this boss.
* @param props.formatInteger - The integer formatting utility function.
* @param props.formatNumber - The number formatting utility function.
* @returns The JSX element.
*/
@@ -41,6 +45,7 @@ const BossCard = ({
onChallenge,
isChallenging,
unlockHint,
formatInteger,
formatNumber,
}: BossCardProperties): JSX.Element => {
const scaled = boss.currentHp * 100;
@@ -56,6 +61,11 @@ const BossCard = ({
return (
<div className={`boss-card boss-${boss.status}`}>
<img
alt={boss.name}
className="card-thumbnail"
src={cdnImage("bosses", boss.id)}
/>
<div className="boss-info">
<h3>{boss.name}</h3>
<p>{boss.description}</p>
@@ -110,7 +120,7 @@ const BossCard = ({
{boss.crystalReward > 0
&& <span>
{"💎 "}
{formatNumber(boss.crystalReward)}
{formatInteger(boss.crystalReward)}
</span>
}
{boss.equipmentRewards.length > 0
@@ -120,7 +130,9 @@ const BossCard = ({
{" Equipment"}
</span>
}
{boss.status !== "defeated" && boss.bountyRunestones > 0
{boss.status !== "defeated"
&& boss.bountyRunestones > 0
&& boss.bountyRunestonesClaimed !== true
&& <span className="boss-bounty">
{"🔮 "}
{boss.bountyRunestones}
@@ -149,82 +161,27 @@ const BossCard = ({
);
};
/**
* Computes party DPS and HP from the current game state.
* @param state - The full game state.
* @returns The computed party DPS and HP values.
*/
const computePartyStats = (
state: GameState,
): {
partyDps: number;
partyHp: number;
} => {
const { upgrades, adventurers, equipment, prestige } = state;
let globalMultiplier = 1;
for (const upgrade of upgrades) {
const { purchased, target, multiplier } = upgrade;
if (purchased && target === "global") {
globalMultiplier = globalMultiplier * multiplier;
}
}
const prestigeBonus = prestige.count * 0.1;
const prestigeMultiplier = 1 + prestigeBonus;
const equipmentCombatMultiplier = equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((multiplier, item) => {
return multiplier * (item.bonus.combatMultiplier ?? 1);
}, 1);
let partyDps = 0;
let partyHp = 0;
for (const adventurer of adventurers) {
const { count, id: adventurerId, combatPower, level } = adventurer;
if (count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of upgrades) {
const {
purchased,
target,
multiplier,
adventurerId: upgradeAdventurerId,
} = upgrade;
if (
purchased
&& target === "adventurer"
&& upgradeAdventurerId === adventurerId
) {
adventurerMultiplier = adventurerMultiplier * multiplier;
}
}
const dps
= combatPower
* count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyDps = partyDps + dps;
const hp = level * 50 * count;
partyHp = partyHp + hp;
}
partyDps = partyDps * equipmentCombatMultiplier;
return { partyDps, partyHp };
};
/**
* Renders the boss panel with zone selection and boss list.
* @returns The JSX element.
*/
const BossPanel = (): JSX.Element => {
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
const {
state,
challengeBoss,
formatInteger,
formatNumber,
toggleAutoBoss,
autoBossLastResult,
autoBossError,
bossError,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null,
);
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
});
const [ showLocked, setShowLocked ] = useState(true);
if (state === null) {
@@ -248,7 +205,31 @@ const BossPanel = (): JSX.Element => {
void handleChallenge(bossId);
}
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const {
adventurers,
autoBoss,
bosses,
prestige: playerPrestige,
quests,
zones,
} = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === activeZoneId;
});
@@ -302,6 +283,11 @@ const BossPanel = (): JSX.Element => {
}
}
function handleZoneSelect(zoneId: string): void {
setActiveZoneId(zoneId);
sessionStorage.setItem("elysium_boss_zone", zoneId);
}
function handleToggle(): void {
setShowLocked((current) => {
return !current;
@@ -309,7 +295,12 @@ const BossPanel = (): JSX.Element => {
}
const autoBossOn = autoBoss === true;
const { partyDps, partyHp } = computePartyStats(state);
const partyDps = computePartyCombatPower(state);
let partyHp = 0;
for (const { level, count } of adventurers) {
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
partyHp = partyHp + level * 50 * count;
}
const { count: prestigeCount } = playerPrestige;
return (
@@ -340,12 +331,57 @@ const BossPanel = (): JSX.Element => {
</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
activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId}
onSelectZone={handleZoneSelect}
zones={zones}
/>
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats">
<div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span>
@@ -363,6 +399,7 @@ const BossPanel = (): JSX.Element => {
return (
<BossCard
boss={boss}
formatInteger={formatInteger}
formatNumber={formatNumber}
isChallenging={challengingBossId === bossId}
key={bossId}
+12 -7
View File
@@ -14,6 +14,7 @@ import {
type PublicProfileResponse,
} from "@elysium/types";
import { type JSX, useEffect, useState } from "react";
import { logError } from "../../utils/logError.js";
interface CharacterPageProperties {
readonly discordId: string;
@@ -78,12 +79,16 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
}, [ discordId ]);
function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
});
void navigator.clipboard.writeText(window.location.href).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
if (error !== null) {
@@ -238,7 +243,7 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
return (
<div
className="character-page-equipment-item"
key={item.type}
key={item.name}
>
<div className="character-page-equipment-header">
<span className="character-page-equipment-slot">

Some files were not shown because too many files have changed in this diff Show More