12 Commits

Author SHA1 Message Date
nick 6e5a257fb1 chore: bump package versions to 0.6.0 and approve dependency build scripts
CI / Lint, Build & Test (pull_request) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:21:22 -05:00
nick 5928eb73d8 feat: v0.6.0 balance pass, quest-gated bosses, and achievement fixes 2026-07-17 01:14:46 -05: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
89 changed files with 4066 additions and 1373 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.3.2", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
@@ -14,19 +14,19 @@
}, },
"dependencies": { "dependencies": {
"@elysium/types": "workspace:*", "@elysium/types": "workspace:*",
"@hono/node-server": "1.13.7", "@hono/node-server": "1.19.12",
"@nhcarrigan/logger": "1.1.1", "@nhcarrigan/logger": "1.1.1",
"@prisma/client": "6.5.0", "@prisma/client": "6.5.0",
"hono": "4.7.4", "hono": "4.12.11",
"prisma": "6.5.0" "prisma": "6.5.0"
}, },
"devDependencies": { "devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0", "@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "25.3.5", "@types/node": "25.5.2",
"@vitest/coverage-v8": "3.0.8", "@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0", "eslint": "9.22.0",
"tsx": "4.19.3", "tsx": "4.21.0",
"typescript": "5.8.2", "typescript": "5.8.2",
"vitest": "3.0.8" "vitest": "3.0.8"
} }
+19 -19
View File
@@ -93,7 +93,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Quest milestones // Quest milestones
{ {
condition: { amount: 1, type: "questsCompleted" }, condition: { amount: 1, type: "uniqueQuestsCompleted" },
description: "Complete your first quest.", description: "Complete your first quest.",
icon: "📜", icon: "📜",
id: "first_quest", id: "first_quest",
@@ -102,7 +102,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 5, type: "questsCompleted" }, condition: { amount: 5, type: "uniqueQuestsCompleted" },
description: "Complete 5 quests.", description: "Complete 5 quests.",
icon: "📚", icon: "📚",
id: "quest_veteran", id: "quest_veteran",
@@ -111,7 +111,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 15, type: "questsCompleted" }, condition: { amount: 15, type: "uniqueQuestsCompleted" },
description: "Complete 15 quests.", description: "Complete 15 quests.",
icon: "🗺️", icon: "🗺️",
id: "quest_master", id: "quest_master",
@@ -121,7 +121,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Boss milestones // Boss milestones
{ {
condition: { amount: 1, type: "bossesDefeated" }, condition: { amount: 1, type: "uniqueBossesDefeated" },
description: "Defeat your first boss.", description: "Defeat your first boss.",
icon: "⚔️", icon: "⚔️",
id: "boss_slayer", id: "boss_slayer",
@@ -130,7 +130,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 5, type: "bossesDefeated" }, condition: { amount: 5, type: "uniqueBossesDefeated" },
description: "Defeat 5 bosses.", description: "Defeat 5 bosses.",
icon: "🗡️", icon: "🗡️",
id: "boss_veteran", id: "boss_veteran",
@@ -139,7 +139,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 10, type: "bossesDefeated" }, condition: { amount: 10, type: "uniqueBossesDefeated" },
description: "Defeat 10 bosses.", description: "Defeat 10 bosses.",
icon: "🏆", icon: "🏆",
id: "legendary_hunter", id: "legendary_hunter",
@@ -148,7 +148,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 18, type: "bossesDefeated" }, condition: { amount: 18, type: "uniqueBossesDefeated" },
description: "Defeat the 18 bosses of the mortal realms.", description: "Defeat the 18 bosses of the mortal realms.",
icon: "🌟", icon: "🌟",
id: "devourer_slayer", id: "devourer_slayer",
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "☄️", icon: "☄️",
id: "click_deity", id: "click_deity",
name: "Click Deity", name: "Click Deity",
reward: { crystals: 5000 }, reward: { crystals: 15_000 },
unlockedAt: null, unlockedAt: null,
}, },
// Endgame gold milestones // Endgame gold milestones
@@ -298,7 +298,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Higher quest milestones // Higher quest milestones
{ {
condition: { amount: 30, type: "questsCompleted" }, condition: { amount: 30, type: "uniqueQuestsCompleted" },
description: "Complete 30 quests.", description: "Complete 30 quests.",
icon: "🏅", icon: "🏅",
id: "quest_champion", id: "quest_champion",
@@ -307,7 +307,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 50, type: "questsCompleted" }, condition: { amount: 50, type: "uniqueQuestsCompleted" },
description: "Complete 50 quests.", description: "Complete 50 quests.",
icon: "🎖️", icon: "🎖️",
id: "quest_grandmaster", id: "quest_grandmaster",
@@ -316,7 +316,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 75, type: "questsCompleted" }, condition: { amount: 75, type: "uniqueQuestsCompleted" },
description: "Complete 75 quests.", description: "Complete 75 quests.",
icon: "🌠", icon: "🌠",
id: "quest_hero", id: "quest_hero",
@@ -325,7 +325,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 100, type: "questsCompleted" }, condition: { amount: 100, type: "uniqueQuestsCompleted" },
description: "Complete 100 quests.", description: "Complete 100 quests.",
icon: "💫", icon: "💫",
id: "quest_legend", id: "quest_legend",
@@ -334,7 +334,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 112, type: "questsCompleted" }, condition: { amount: 112, type: "uniqueQuestsCompleted" },
description: "Complete all 112 quests across the known multiverse.", description: "Complete all 112 quests across the known multiverse.",
icon: "🌌", icon: "🌌",
id: "quest_eternal", id: "quest_eternal",
@@ -344,7 +344,7 @@ export const defaultAchievements: Array<Achievement> = [
}, },
// Higher boss milestones // Higher boss milestones
{ {
condition: { amount: 20, type: "bossesDefeated" }, condition: { amount: 20, type: "uniqueBossesDefeated" },
description: "Defeat 20 bosses.", description: "Defeat 20 bosses.",
icon: "🦁", icon: "🦁",
id: "boss_champion", id: "boss_champion",
@@ -353,7 +353,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 30, type: "bossesDefeated" }, condition: { amount: 30, type: "uniqueBossesDefeated" },
description: "Defeat 30 bosses.", description: "Defeat 30 bosses.",
icon: "🔱", icon: "🔱",
id: "boss_grandmaster", id: "boss_grandmaster",
@@ -362,7 +362,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 50, type: "bossesDefeated" }, condition: { amount: 50, type: "uniqueBossesDefeated" },
description: "Defeat 50 bosses.", description: "Defeat 50 bosses.",
icon: "⚡", icon: "⚡",
id: "boss_legend", id: "boss_legend",
@@ -371,7 +371,7 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null, unlockedAt: null,
}, },
{ {
condition: { amount: 72, type: "bossesDefeated" }, condition: { amount: 72, type: "uniqueBossesDefeated" },
description: "Defeat all 72 bosses across every plane of existence.", description: "Defeat all 72 bosses across every plane of existence.",
icon: "💀", icon: "💀",
id: "boss_eternal", id: "boss_eternal",
@@ -405,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "💫", icon: "💫",
id: "prestige_master", id: "prestige_master",
name: "Master of Cycles", name: "Master of Cycles",
reward: { crystals: 5000 }, reward: { crystals: 15_000 },
unlockedAt: null, unlockedAt: null,
}, },
{ {
@@ -414,7 +414,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "🌠", icon: "🌠",
id: "prestige_legend", id: "prestige_legend",
name: "Legend of Eternity", name: "Legend of Eternity",
reward: { crystals: 25_000 }, reward: { crystals: 75_000 },
unlockedAt: null, unlockedAt: null,
}, },
{ {
+1 -1
View File
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
unlocked: true, unlocked: true,
}, },
{ {
baseCost: 100, baseCost: 65,
class: "warrior", class: "warrior",
combatPower: 3, combatPower: 3,
count: 0, count: 0,
+9 -5
View File
@@ -61,7 +61,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Forest Giant", name: "The Forest Giant",
prestigeRequirement: 0, prestigeRequirement: 0,
status: "locked", status: "locked",
upgradeRewards: [ "archmage_1" ], upgradeRewards: [],
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
// ── Shattered Ruins ─────────────────────────────────────────────────────── // ── Shattered Ruins ───────────────────────────────────────────────────────
@@ -1258,7 +1258,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 5e110, damagePerSecond: 5e110,
description: description:
"The announcement of finality — not a creature but the moment before the last moment, given agency. It is here to tell your guild that this is where everything ends. Your guild declines to accept the announcement.", "The announcement of finality — not a creature but the moment before the last moment, given agency. It is here to tell your guild that this is where everything ends. Your guild declines to accept the announcement.",
equipmentRewards: [], equipmentRewards: [ "omniversal_core" ],
essenceReward: 2e113, essenceReward: 2e113,
goldReward: 2e117, goldReward: 2e117,
id: "absolute_herald", id: "absolute_herald",
@@ -1266,6 +1266,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Absolute Herald", name: "The Absolute Herald",
prestigeRequirement: 17, prestigeRequirement: 17,
status: "locked", status: "locked",
unlockQuestId: "final_paradox",
upgradeRewards: [ "primordial_mage_1" ], upgradeRewards: [ "primordial_mage_1" ],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
@@ -1276,7 +1277,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 3e119, damagePerSecond: 3e119,
description: description:
"Every void, every absence, every nothing that has ever existed converging into a single point. The gravitational pull of absolute nothingness. Your guild must push against the pull of all that is not.", "Every void, every absence, every nothing that has ever existed converging into a single point. The gravitational pull of absolute nothingness. Your guild must push against the pull of all that is not.",
equipmentRewards: [], equipmentRewards: [ "eternity_plate" ],
essenceReward: 1e122, essenceReward: 1e122,
goldReward: 1e126, goldReward: 1e126,
id: "void_convergence", id: "void_convergence",
@@ -1284,6 +1285,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Void Convergence", name: "The Void Convergence",
prestigeRequirement: 18, prestigeRequirement: 18,
status: "locked", status: "locked",
unlockQuestId: "terminal_approach",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
@@ -1294,7 +1296,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 1e129, damagePerSecond: 1e129,
description: description:
"The last thing that will ever exist — visiting now, ahead of schedule, drawn by the power your guild has accumulated. It does not consider this inconvenient. Everything ends eventually. It is simply efficient.", "The last thing that will ever exist — visiting now, ahead of schedule, drawn by the power your guild has accumulated. It does not consider this inconvenient. Everything ends eventually. It is simply efficient.",
equipmentRewards: [], equipmentRewards: [ "absolute_blade" ],
essenceReward: 5e131, essenceReward: 5e131,
goldReward: 5e135, goldReward: 5e135,
id: "eternal_end", id: "eternal_end",
@@ -1302,6 +1304,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Eternal End", name: "The Eternal End",
prestigeRequirement: 19, prestigeRequirement: 19,
status: "locked", status: "locked",
unlockQuestId: "absolute_depth",
upgradeRewards: [ "omniversal_champion_1" ], upgradeRewards: [ "omniversal_champion_1" ],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
@@ -1312,7 +1315,7 @@ export const defaultBosses: Array<Boss> = [
damagePerSecond: 5e139, damagePerSecond: 5e139,
description: description:
"Beyond description. Beyond category. The terminal point of all power, all existence, all possibility. There is nothing after this. Your guild has come to this nothing and refused it. That, in itself, is the greatest achievement in the history of anything.", "Beyond description. Beyond category. The terminal point of all power, all existence, all possibility. There is nothing after this. Your guild has come to this nothing and refused it. That, in itself, is the greatest achievement in the history of anything.",
equipmentRewards: [ "absolute_blade", "eternity_plate", "omniversal_core" ], equipmentRewards: [],
essenceReward: 2e142, essenceReward: 2e142,
goldReward: 2e146, goldReward: 2e146,
id: "the_absolute_one", id: "the_absolute_one",
@@ -1320,6 +1323,7 @@ export const defaultBosses: Array<Boss> = [
name: "The Absolute One", name: "The Absolute One",
prestigeRequirement: 20, prestigeRequirement: 20,
status: "locked", status: "locked",
unlockQuestId: "final_threshold",
upgradeRewards: [], upgradeRewards: [],
zoneId: "the_absolute", zoneId: "the_absolute",
}, },
+14
View File
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
target: 5000, target: 5000,
type: "clicks", type: "clicks",
}, },
// Crafting — requires materials but no zone/boss progression
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
{
label: "Craft 2 recipes",
rewardCrystals: 175,
target: 2,
type: "crafting",
},
{
label: "Craft 3 recipes",
rewardCrystals: 350,
target: 3,
type: "crafting",
},
// Boss defeats — requires active combat // Boss defeats — requires active combat
{ {
label: "Defeat 1 boss", label: "Defeat 1 boss",
+1 -1
View File
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "trinket", type: "trinket",
}, },
{ {
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 }, bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
description: description:
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.", "A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
equipped: false, equipped: false,
+72 -87
View File
@@ -13,7 +13,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
durationSeconds: 5 * 60, durationSeconds: 60,
events: [ events: [
{ {
effect: { amount: 1000, type: "gold_gain" }, effect: { amount: 1000, type: "gold_gain" },
@@ -42,7 +42,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "verdant_meadow", id: "verdant_meadow",
name: "The Verdant Meadow", name: "The Verdant Meadow",
// 5min
possibleMaterials: [ possibleMaterials: [
{ materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 3 }, { materialId: "verdant_sap", maxQuantity: 3, minQuantity: 1, weight: 3 },
], ],
@@ -52,7 +51,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
durationSeconds: 10 * 60, durationSeconds: 75,
events: [ events: [
{ {
effect: { amount: 3000, type: "gold_gain" }, effect: { amount: 3000, type: "gold_gain" },
@@ -81,7 +80,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "whispering_forest", id: "whispering_forest",
name: "The Whispering Forest", name: "The Whispering Forest",
// 10min
possibleMaterials: [ possibleMaterials: [
{ materialId: "verdant_sap", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "verdant_sap", maxQuantity: 5, minQuantity: 2, weight: 3 },
{ {
@@ -97,7 +95,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
durationSeconds: 15 * 60, durationSeconds: 90,
events: [ events: [
{ {
effect: { amount: 6000, type: "gold_gain" }, effect: { amount: 6000, type: "gold_gain" },
@@ -126,7 +124,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "ancient_grove", id: "ancient_grove",
name: "The Ancient Grove", name: "The Ancient Grove",
// 15min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "forest_crystal", materialId: "forest_crystal",
@@ -142,7 +139,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
durationSeconds: 20 * 60, durationSeconds: 105,
events: [ events: [
{ {
effect: { amount: 10_000, type: "gold_gain" }, effect: { amount: 10_000, type: "gold_gain" },
@@ -171,7 +168,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "forbidden_glen", id: "forbidden_glen",
name: "The Forbidden Glen", name: "The Forbidden Glen",
// 20min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "forest_crystal", materialId: "forest_crystal",
@@ -189,7 +185,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
durationSeconds: 10 * 60, durationSeconds: 75,
events: [ events: [
{ {
effect: { amount: 4000, type: "gold_gain" }, effect: { amount: 4000, type: "gold_gain" },
@@ -214,7 +210,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "collapsed_outpost", id: "collapsed_outpost",
name: "The Collapsed Outpost", name: "The Collapsed Outpost",
// 10min
possibleMaterials: [ possibleMaterials: [
{ materialId: "ruin_dust", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "ruin_dust", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -224,7 +219,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
durationSeconds: 20 * 60, durationSeconds: 90,
events: [ events: [
{ {
effect: { amount: 10_000, type: "gold_gain" }, effect: { amount: 10_000, type: "gold_gain" },
@@ -253,7 +248,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "cursed_lake", id: "cursed_lake",
name: "The Cursed Lake", name: "The Cursed Lake",
// 20min
possibleMaterials: [ possibleMaterials: [
{ materialId: "ruin_dust", maxQuantity: 6, minQuantity: 2, weight: 3 }, { materialId: "ruin_dust", maxQuantity: 6, minQuantity: 2, weight: 3 },
{ {
@@ -269,7 +263,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
durationSeconds: 30 * 60, durationSeconds: 120,
events: [ events: [
{ {
effect: { amount: 20_000, type: "gold_gain" }, effect: { amount: 20_000, type: "gold_gain" },
@@ -298,7 +292,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "runic_archive", id: "runic_archive",
name: "The Runic Archive", name: "The Runic Archive",
// 30min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "cursed_fragment", materialId: "cursed_fragment",
@@ -314,7 +307,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
durationSeconds: 40 * 60, durationSeconds: 150,
events: [ events: [
{ {
effect: { amount: 40_000, type: "gold_gain" }, effect: { amount: 40_000, type: "gold_gain" },
@@ -343,7 +336,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "dragon_throne", id: "dragon_throne",
name: "The Dragon's Throne", name: "The Dragon's Throne",
// 40min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "cursed_fragment", materialId: "cursed_fragment",
@@ -366,7 +358,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
durationSeconds: 15 * 60, durationSeconds: 105,
events: [ events: [
{ {
effect: { amount: 8000, type: "gold_gain" }, effect: { amount: 8000, type: "gold_gain" },
@@ -395,7 +387,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "glacial_cave", id: "glacial_cave",
name: "The Glacial Cave", name: "The Glacial Cave",
// 15min
possibleMaterials: [ possibleMaterials: [
{ materialId: "glacial_ice", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "glacial_ice", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -405,7 +396,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
durationSeconds: 30 * 60, durationSeconds: 135,
events: [ events: [
{ {
effect: { amount: 18_000, type: "gold_gain" }, effect: { amount: 18_000, type: "gold_gain" },
@@ -434,7 +425,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "frozen_tundra", id: "frozen_tundra",
name: "The Frozen Tundra", name: "The Frozen Tundra",
// 30min
possibleMaterials: [ possibleMaterials: [
{ materialId: "glacial_ice", maxQuantity: 7, minQuantity: 3, weight: 3 }, { materialId: "glacial_ice", maxQuantity: 7, minQuantity: 3, weight: 3 },
{ {
@@ -450,7 +440,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
durationSeconds: 45 * 60, durationSeconds: 180,
events: [ events: [
{ {
effect: { amount: 35_000, type: "gold_gain" }, effect: { amount: 35_000, type: "gold_gain" },
@@ -479,7 +469,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "void_rift", id: "void_rift",
name: "The Void Rift", name: "The Void Rift",
// 45min
possibleMaterials: [ possibleMaterials: [
{ {
materialId: "frost_crystal", materialId: "frost_crystal",
@@ -495,7 +484,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
durationSeconds: 1 * 60 * 60, durationSeconds: 240,
events: [ events: [
{ {
effect: { amount: 60_000, type: "gold_gain" }, effect: { amount: 60_000, type: "gold_gain" },
@@ -542,7 +531,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
durationSeconds: 25 * 60, durationSeconds: 105,
events: [ events: [
{ {
effect: { amount: 15_000, type: "gold_gain" }, effect: { amount: 15_000, type: "gold_gain" },
@@ -571,7 +560,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "fog_hollow", id: "fog_hollow",
name: "The Fog Hollow", name: "The Fog Hollow",
// 25min
possibleMaterials: [ possibleMaterials: [
{ materialId: "marsh_root", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "marsh_root", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -581,7 +569,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
durationSeconds: 50 * 60, durationSeconds: 150,
events: [ events: [
{ {
effect: { amount: 35_000, type: "gold_gain" }, effect: { amount: 35_000, type: "gold_gain" },
@@ -610,7 +598,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "dark_grotto", id: "dark_grotto",
name: "The Dark Grotto", name: "The Dark Grotto",
// 50min
possibleMaterials: [ possibleMaterials: [
{ materialId: "marsh_root", maxQuantity: 7, minQuantity: 3, weight: 3 }, { materialId: "marsh_root", maxQuantity: 7, minQuantity: 3, weight: 3 },
{ {
@@ -626,7 +613,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
durationSeconds: 90 * 60, durationSeconds: 195,
events: [ events: [
{ {
effect: { amount: 70_000, type: "gold_gain" }, effect: { amount: 70_000, type: "gold_gain" },
@@ -671,7 +658,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
durationSeconds: 90 * 60, durationSeconds: 255,
events: [ events: [
{ {
effect: { amount: 120_000, type: "gold_gain" }, effect: { amount: 120_000, type: "gold_gain" },
@@ -718,7 +705,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
durationSeconds: 35 * 60, durationSeconds: 120,
events: [ events: [
{ {
effect: { amount: 30_000, type: "gold_gain" }, effect: { amount: 30_000, type: "gold_gain" },
@@ -747,7 +734,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "magma_tunnel", id: "magma_tunnel",
name: "The Magma Tunnel", name: "The Magma Tunnel",
// 35min
possibleMaterials: [ possibleMaterials: [
{ materialId: "magma_stone", maxQuantity: 5, minQuantity: 2, weight: 3 }, { materialId: "magma_stone", maxQuantity: 5, minQuantity: 2, weight: 3 },
], ],
@@ -757,7 +743,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
durationSeconds: 1 * 60 * 60, durationSeconds: 165,
events: [ events: [
{ {
effect: { amount: 70_000, type: "gold_gain" }, effect: { amount: 70_000, type: "gold_gain" },
@@ -802,7 +788,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
durationSeconds: 2 * 60 * 60, durationSeconds: 225,
events: [ events: [
{ {
effect: { amount: 130_000, type: "gold_gain" }, effect: { amount: 130_000, type: "gold_gain" },
@@ -852,7 +838,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
durationSeconds: 150 * 60, durationSeconds: 300,
events: [ events: [
{ {
effect: { amount: 250_000, type: "gold_gain" }, effect: { amount: 250_000, type: "gold_gain" },
@@ -904,7 +890,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
durationSeconds: 50 * 60, durationSeconds: 150,
events: [ events: [
{ {
effect: { amount: 500_000, type: "gold_gain" }, effect: { amount: 500_000, type: "gold_gain" },
@@ -929,7 +915,6 @@ export const defaultExplorations: Array<ExplorationArea> = [
], ],
id: "star_field", id: "star_field",
name: "The Star Field", name: "The Star Field",
// 50min
possibleMaterials: [ possibleMaterials: [
{ materialId: "stardust", maxQuantity: 7, minQuantity: 3, weight: 3 }, { materialId: "stardust", maxQuantity: 7, minQuantity: 3, weight: 3 },
], ],
@@ -939,7 +924,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
durationSeconds: 90 * 60, durationSeconds: 210,
events: [ events: [
{ {
effect: { amount: 1_000_000, type: "gold_gain" }, effect: { amount: 1_000_000, type: "gold_gain" },
@@ -984,7 +969,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
durationSeconds: 150 * 60, durationSeconds: 270,
events: [ events: [
{ {
effect: { amount: 2_000_000, type: "gold_gain" }, effect: { amount: 2_000_000, type: "gold_gain" },
@@ -1029,7 +1014,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
durationSeconds: 210 * 60, durationSeconds: 375,
events: [ events: [
{ {
effect: { amount: 4_000_000, type: "gold_gain" }, effect: { amount: 4_000_000, type: "gold_gain" },
@@ -1076,7 +1061,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
durationSeconds: 1 * 60 * 60, durationSeconds: 300,
events: [ events: [
{ {
effect: { amount: 3_000_000, type: "gold_gain" }, effect: { amount: 3_000_000, type: "gold_gain" },
@@ -1120,7 +1105,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
durationSeconds: 2 * 60 * 60, durationSeconds: 390,
events: [ events: [
{ {
effect: { amount: 6_000_000, type: "gold_gain" }, effect: { amount: 6_000_000, type: "gold_gain" },
@@ -1170,7 +1155,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
durationSeconds: 3 * 60 * 60, durationSeconds: 540,
events: [ events: [
{ {
effect: { amount: 12_000_000, type: "gold_gain" }, effect: { amount: 12_000_000, type: "gold_gain" },
@@ -1215,7 +1200,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
durationSeconds: 4 * 60 * 60, durationSeconds: 720,
events: [ events: [
{ {
effect: { amount: 25_000_000, type: "gold_gain" }, effect: { amount: 25_000_000, type: "gold_gain" },
@@ -1262,7 +1247,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
durationSeconds: 1 * 60 * 60, durationSeconds: 420,
events: [ events: [
{ {
effect: { amount: 8_000_000, type: "gold_gain" }, effect: { amount: 8_000_000, type: "gold_gain" },
@@ -1301,7 +1286,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
durationSeconds: 150 * 60, durationSeconds: 570,
events: [ events: [
{ {
effect: { amount: 18_000_000, type: "gold_gain" }, effect: { amount: 18_000_000, type: "gold_gain" },
@@ -1341,7 +1326,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
durationSeconds: 210 * 60, durationSeconds: 750,
events: [ events: [
{ {
effect: { amount: 40_000_000, type: "gold_gain" }, effect: { amount: 40_000_000, type: "gold_gain" },
@@ -1386,7 +1371,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
durationSeconds: 270 * 60, durationSeconds: 990,
events: [ events: [
{ {
effect: { amount: 80_000_000, type: "gold_gain" }, effect: { amount: 80_000_000, type: "gold_gain" },
@@ -1433,7 +1418,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
durationSeconds: 90 * 60, durationSeconds: 540,
events: [ events: [
{ {
effect: { amount: 20_000_000, type: "gold_gain" }, effect: { amount: 20_000_000, type: "gold_gain" },
@@ -1477,7 +1462,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
durationSeconds: 150 * 60, durationSeconds: 720,
events: [ events: [
{ {
effect: { amount: 45_000_000, type: "gold_gain" }, effect: { amount: 45_000_000, type: "gold_gain" },
@@ -1522,7 +1507,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
durationSeconds: 4 * 60 * 60, durationSeconds: 960,
events: [ events: [
{ {
effect: { amount: 90_000_000, type: "gold_gain" }, effect: { amount: 90_000_000, type: "gold_gain" },
@@ -1562,7 +1547,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
durationSeconds: 330 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 180_000_000, type: "gold_gain" }, effect: { amount: 180_000_000, type: "gold_gain" },
@@ -1604,7 +1589,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
durationSeconds: 90 * 60, durationSeconds: 750,
events: [ events: [
{ {
effect: { amount: 60_000_000, type: "gold_gain" }, effect: { amount: 60_000_000, type: "gold_gain" },
@@ -1643,7 +1628,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
durationSeconds: 3 * 60 * 60, durationSeconds: 1005,
events: [ events: [
{ {
effect: { amount: 130_000_000, type: "gold_gain" }, effect: { amount: 130_000_000, type: "gold_gain" },
@@ -1688,7 +1673,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
durationSeconds: 270 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 270_000_000, type: "gold_gain" }, effect: { amount: 270_000_000, type: "gold_gain" },
@@ -1738,7 +1723,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
durationSeconds: 6 * 60 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 550_000_000, type: "gold_gain" }, effect: { amount: 550_000_000, type: "gold_gain" },
@@ -1790,7 +1775,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
durationSeconds: 90 * 60, durationSeconds: 990,
events: [ events: [
{ {
effect: { amount: 200_000_000, type: "gold_gain" }, effect: { amount: 200_000_000, type: "gold_gain" },
@@ -1829,7 +1814,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
durationSeconds: 210 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 420_000_000, type: "gold_gain" }, effect: { amount: 420_000_000, type: "gold_gain" },
@@ -1874,7 +1859,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
durationSeconds: 5 * 60 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 900_000_000, type: "gold_gain" }, effect: { amount: 900_000_000, type: "gold_gain" },
@@ -1919,7 +1904,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
durationSeconds: 7 * 60 * 60, durationSeconds: 2400,
events: [ events: [
{ {
effect: { amount: 1_800_000_000, type: "gold_gain" }, effect: { amount: 1_800_000_000, type: "gold_gain" },
@@ -1966,7 +1951,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
durationSeconds: 2 * 60 * 60, durationSeconds: 1320,
events: [ events: [
{ {
effect: { amount: 700_000_000, type: "gold_gain" }, effect: { amount: 700_000_000, type: "gold_gain" },
@@ -2005,7 +1990,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
durationSeconds: 210 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 1_400_000_000, type: "gold_gain" }, effect: { amount: 1_400_000_000, type: "gold_gain" },
@@ -2050,7 +2035,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
durationSeconds: 330 * 60, durationSeconds: 2400,
events: [ events: [
{ {
effect: { amount: 3_000_000_000, type: "gold_gain" }, effect: { amount: 3_000_000_000, type: "gold_gain" },
@@ -2100,7 +2085,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
durationSeconds: 7 * 60 * 60, durationSeconds: 3600,
events: [ events: [
{ {
effect: { amount: 6_000_000_000, type: "gold_gain" }, effect: { amount: 6_000_000_000, type: "gold_gain" },
@@ -2152,7 +2137,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
durationSeconds: 2 * 60 * 60, durationSeconds: 1800,
events: [ events: [
{ {
effect: { amount: 2_000_000_000, type: "gold_gain" }, effect: { amount: 2_000_000_000, type: "gold_gain" },
@@ -2196,7 +2181,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
durationSeconds: 4 * 60 * 60, durationSeconds: 2700,
events: [ events: [
{ {
effect: { amount: 4_000_000_000, type: "gold_gain" }, effect: { amount: 4_000_000_000, type: "gold_gain" },
@@ -2246,7 +2231,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
durationSeconds: 6 * 60 * 60, durationSeconds: 3600,
events: [ events: [
{ {
effect: { amount: 8_000_000_000, type: "gold_gain" }, effect: { amount: 8_000_000_000, type: "gold_gain" },
@@ -2296,7 +2281,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
durationSeconds: 8 * 60 * 60, durationSeconds: 4500,
events: [ events: [
{ {
effect: { amount: 16_000_000_000, type: "gold_gain" }, effect: { amount: 16_000_000_000, type: "gold_gain" },
@@ -2348,7 +2333,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
durationSeconds: 2 * 60 * 60, durationSeconds: 2400,
events: [ events: [
{ {
effect: { amount: 6_000_000_000, type: "gold_gain" }, effect: { amount: 6_000_000_000, type: "gold_gain" },
@@ -2387,7 +2372,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
durationSeconds: 270 * 60, durationSeconds: 3600,
events: [ events: [
{ {
effect: { amount: 12_000_000_000, type: "gold_gain" }, effect: { amount: 12_000_000_000, type: "gold_gain" },
@@ -2432,7 +2417,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
durationSeconds: 7 * 60 * 60, durationSeconds: 4500,
events: [ events: [
{ {
effect: { amount: 25_000_000_000, type: "gold_gain" }, effect: { amount: 25_000_000_000, type: "gold_gain" },
@@ -2482,7 +2467,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
durationSeconds: 9 * 60 * 60, durationSeconds: 5700,
events: [ events: [
{ {
effect: { amount: 50_000_000_000, type: "gold_gain" }, effect: { amount: 50_000_000_000, type: "gold_gain" },
@@ -2534,7 +2519,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
durationSeconds: 150 * 60, durationSeconds: 5400,
events: [ events: [
{ {
effect: { amount: 20_000_000_000, type: "gold_gain" }, effect: { amount: 20_000_000_000, type: "gold_gain" },
@@ -2569,7 +2554,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
durationSeconds: 270 * 60, durationSeconds: 7200,
events: [ events: [
{ {
effect: { amount: 40_000_000_000, type: "gold_gain" }, effect: { amount: 40_000_000_000, type: "gold_gain" },
@@ -2614,7 +2599,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
durationSeconds: 7 * 60 * 60, durationSeconds: 9000,
events: [ events: [
{ {
effect: { amount: 80_000_000_000, type: "gold_gain" }, effect: { amount: 80_000_000_000, type: "gold_gain" },
@@ -2664,7 +2649,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
durationSeconds: 9 * 60 * 60, durationSeconds: 12_600,
events: [ events: [
{ {
effect: { amount: 160_000_000_000, type: "gold_gain" }, effect: { amount: 160_000_000_000, type: "gold_gain" },
@@ -2716,7 +2701,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
durationSeconds: 150 * 60, durationSeconds: 7200,
events: [ events: [
{ {
effect: { amount: 60_000_000_000, type: "gold_gain" }, effect: { amount: 60_000_000_000, type: "gold_gain" },
@@ -2760,7 +2745,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
durationSeconds: 5 * 60 * 60, durationSeconds: 10_800,
events: [ events: [
{ {
effect: { amount: 120_000_000_000, type: "gold_gain" }, effect: { amount: 120_000_000_000, type: "gold_gain" },
@@ -2810,7 +2795,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
durationSeconds: 8 * 60 * 60, durationSeconds: 14_400,
events: [ events: [
{ {
effect: { amount: 250_000_000_000, type: "gold_gain" }, effect: { amount: 250_000_000_000, type: "gold_gain" },
@@ -2860,7 +2845,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
durationSeconds: 10 * 60 * 60, durationSeconds: 18_000,
events: [ events: [
{ {
effect: { amount: 500_000_000_000, type: "gold_gain" }, effect: { amount: 500_000_000_000, type: "gold_gain" },
@@ -2912,7 +2897,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
durationSeconds: 150 * 60, durationSeconds: 9000,
events: [ events: [
{ {
effect: { amount: 200_000_000_000, type: "gold_gain" }, effect: { amount: 200_000_000_000, type: "gold_gain" },
@@ -2951,7 +2936,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
durationSeconds: 330 * 60, durationSeconds: 16_200,
events: [ events: [
{ {
effect: { amount: 400_000_000_000, type: "gold_gain" }, effect: { amount: 400_000_000_000, type: "gold_gain" },
@@ -2991,7 +2976,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
durationSeconds: 8 * 60 * 60, durationSeconds: 22_500,
events: [ events: [
{ {
effect: { amount: 800_000_000_000, type: "gold_gain" }, effect: { amount: 800_000_000_000, type: "gold_gain" },
@@ -3036,7 +3021,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
durationSeconds: 11 * 60 * 60, durationSeconds: 29_700,
events: [ events: [
{ {
effect: { amount: 1_600_000_000_000, type: "gold_gain" }, effect: { amount: 1_600_000_000_000, type: "gold_gain" },
@@ -3083,7 +3068,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
durationSeconds: 3 * 60 * 60, durationSeconds: 10_800,
events: [ events: [
{ {
effect: { amount: 600_000_000_000, type: "gold_gain" }, effect: { amount: 600_000_000_000, type: "gold_gain" },
@@ -3127,7 +3112,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
durationSeconds: 6 * 60 * 60, durationSeconds: 21_600,
events: [ events: [
{ {
effect: { amount: 1_200_000_000_000, type: "gold_gain" }, effect: { amount: 1_200_000_000_000, type: "gold_gain" },
@@ -3177,7 +3162,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
durationSeconds: 9 * 60 * 60, durationSeconds: 32_400,
events: [ events: [
{ {
effect: { amount: 2_500_000_000_000, type: "gold_gain" }, effect: { amount: 2_500_000_000_000, type: "gold_gain" },
@@ -3227,7 +3212,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
durationSeconds: 12 * 60 * 60, durationSeconds: 43_200,
events: [ events: [
{ {
effect: { amount: 5_000_000_000_000, type: "gold_gain" }, effect: { amount: 5_000_000_000_000, type: "gold_gain" },
+66
View File
@@ -0,0 +1,66 @@
/**
* @file Draft release notes shown in the About panel ahead of the published
* Gitea releases. Local preview only — remove each entry once the release is
* published on the repository.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { GiteaRelease } from "@elysium/types";
const v060Notes = `## What's New
### Quests
- **Quest timers rebalanced** — all quest durations updated to a more reasonable length; a full run of all quests now takes close to one week excluding bonuses
- **Zone unlocks moved to final quests** — the zone unlock requirement is now the last quest of each zone (Shadow Marshes and the last 3 zones)
### Exploration
- **Exploration timers rebalanced** — timers updated to match the new quest times; each zone now allows 4 of the highest explorations before the quest timers complete
### Adventurers & Upgrades
- **Leyline Binding unlock moved** — the Archmage upgrade now unlocks from The Cursed Library quest instead of The Forest Giant boss
- **Dragon Rider unlock moved** — now unlocks in the Frozen Wastes instead of the Dragon's Lair
### Story
- **Full-size image modal** — the chapter image has been resized slightly and can now be clicked to view a full-size version in a modal
### Prestige
- **Runestone cap doubled** — base Max Runestones cap increased 200 → 400 (750 with Runestone bonuses)
- **Prestige notifications** — the prestige notification now fires every 5 prestiges
- **Number format support** — values in the Prestige window now use your number format preference
- **Silent save after prestige** — a silent save now fires 10 seconds after prestiging, helping with rapid prestiging
- **Milestone bonuses shown** — the Prestige button now properly displays the runestones earned including milestone bonuses
### Companions
- **Quest reduction display** — companion quest time reduction now properly displays in the client interface
- **Unlock requirements reduced** — Kael now requires 500 bosses defeated, Wren 250 quests completed, and Zuri 500 quests completed
### The Absolute
- **Equipment unlocks moved earlier** — equipment previously unlocked by The Absolute One now drops from earlier bosses: Omniversal Core from The Absolute Herald, Eternity Plate from Void Convergence, and Absolute Blade from Eternal End
- **Quest-gated bosses** — each Absolute boss now requires a quest in The Absolute to be completed before it unlocks: The Absolute Herald requires The Final Paradox, Void Convergence requires The Terminal Approach, Eternal End requires The Absolute Depth, and The Absolute One requires The Final Threshold
### Quality of Life
- **Scientific notation by default** — the default number format is now Scientific; the suffix format remains available in edit profile
- **Companions stay selected** — companions now remain selected after Prestige, Transcend, and Apotheosis
- **Prestige in transcendence notifications** — transcendence notifications now include your prestige in the message
### Bug Fixes
- Achievement progress no longer resets on prestige (mainly clicking)
- Runestone Shop and Echo Shop now display the correct cost
- Crystal rewards for gold earned achievements no longer require a prestige to unlock
- Achievements no longer count multiple times when prestiging
- Crafting dailies now give credit when the player crafts
- Quest Eternal achievement is now attainable — changed from 122 quests to the intended 112 quests
`;
export const pendingReleases: Array<GiteaRelease> = [
{
body: v060Notes,
name: "v0.6.0",
// eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name
published_at: "2026-07-15T00:00:00Z",
// eslint-disable-next-line @typescript-eslint/naming-convention -- external API field name
tag_name: "v0.6.0",
},
];
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10", id: "income_10",
multiplier: 200, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 22_500, runestonesCost: 15_000,
}, },
{ {
category: "income", category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11", id: "income_11",
multiplier: 500, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 60_000, runestonesCost: 35_000,
}, },
// ── Click Power ─────────────────────────────────────────────────────────── // ── Click Power ───────────────────────────────────────────────────────────
{ {
+121 -118
View File
@@ -13,12 +13,13 @@ export const defaultQuests: Array<Quest> = [
{ {
description: description:
"Every legend begins somewhere. Send your first adventurer into the field.", "Every legend begins somewhere. Send your first adventurer into the field.",
durationSeconds: 60, durationSeconds: 45,
id: "first_steps", id: "first_steps",
name: "First Steps", name: "First Steps",
prerequisiteIds: [], prerequisiteIds: [],
rewards: [ rewards: [
{ amount: 500, type: "gold" }, { amount: 500, type: "gold" },
{ amount: 5, type: "crystals" },
{ targetId: "militia", type: "adventurer" }, { targetId: "militia", type: "adventurer" },
], ],
status: "available", status: "available",
@@ -26,13 +27,14 @@ export const defaultQuests: Array<Quest> = [
}, },
{ {
description: "Clear out a troublesome goblin camp to the east.", description: "Clear out a troublesome goblin camp to the east.",
durationSeconds: 5 * 60, durationSeconds: 120,
id: "goblin_camp", id: "goblin_camp",
name: "Goblin Camp", name: "Goblin Camp",
prerequisiteIds: [ "first_steps" ], prerequisiteIds: [ "first_steps" ],
rewards: [ rewards: [
{ amount: 2000, type: "gold" }, { amount: 2000, type: "gold" },
{ amount: 5, type: "essence" }, { amount: 5, type: "essence" },
{ amount: 10, type: "crystals" },
{ targetId: "peasant_1", type: "upgrade" }, { targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" }, { targetId: "apprentice_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" }, { targetId: "apprentice", type: "adventurer" },
@@ -44,7 +46,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 10, combatPowerRequired: 10,
description: description:
"An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.",
durationSeconds: 15 * 60, durationSeconds: 150,
id: "haunted_mine", id: "haunted_mine",
name: "The Haunted Mine", name: "The Haunted Mine",
prerequisiteIds: [ "goblin_camp" ], prerequisiteIds: [ "goblin_camp" ],
@@ -61,7 +63,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 50, combatPowerRequired: 50,
description: description:
"Scholars believe the ruins hold secrets of a forgotten civilisation.", "Scholars believe the ruins hold secrets of a forgotten civilisation.",
durationSeconds: 30 * 60, durationSeconds: 180,
id: "ancient_ruins", id: "ancient_ruins",
name: "Ancient Ruins", name: "Ancient Ruins",
prerequisiteIds: [ "haunted_mine" ], prerequisiteIds: [ "haunted_mine" ],
@@ -77,7 +79,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 500, combatPowerRequired: 500,
description: description:
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.", "A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
durationSeconds: 5 * 60, durationSeconds: 120,
id: "necromancer_tower", id: "necromancer_tower",
name: "Necromancer's Tower", name: "Necromancer's Tower",
prerequisiteIds: [], prerequisiteIds: [],
@@ -94,7 +96,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2000, combatPowerRequired: 2000,
description: description:
"An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.", "An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.",
durationSeconds: 5 * 60, durationSeconds: 150,
id: "crumbling_fortress", id: "crumbling_fortress",
name: "The Crumbling Fortress", name: "The Crumbling Fortress",
prerequisiteIds: [ "necromancer_tower" ], prerequisiteIds: [ "necromancer_tower" ],
@@ -111,7 +113,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8000, combatPowerRequired: 8000,
description: description:
"A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.", "A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.",
durationSeconds: 10 * 60, durationSeconds: 180,
id: "cursed_library", id: "cursed_library",
name: "The Cursed Library", name: "The Cursed Library",
prerequisiteIds: [ "crumbling_fortress" ], prerequisiteIds: [ "crumbling_fortress" ],
@@ -119,6 +121,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 300, type: "essence" }, { amount: 300, type: "essence" },
{ amount: 30, type: "crystals" }, { amount: 30, type: "crystals" },
{ targetId: "archmage", type: "adventurer" }, { targetId: "archmage", type: "adventurer" },
{ targetId: "archmage_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -127,7 +130,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 30_000, combatPowerRequired: 30_000,
description: description:
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.", "The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.",
durationSeconds: 15 * 60, durationSeconds: 240,
id: "dragon_lair", id: "dragon_lair",
name: "Dragon's Lair", name: "Dragon's Lair",
prerequisiteIds: [ "cursed_library" ], prerequisiteIds: [ "cursed_library" ],
@@ -135,7 +138,6 @@ export const defaultQuests: Array<Quest> = [
{ amount: 500_000, type: "gold" }, { amount: 500_000, type: "gold" },
{ amount: 50, type: "crystals" }, { amount: 50, type: "crystals" },
{ targetId: "paladin", type: "adventurer" }, { targetId: "paladin", type: "adventurer" },
{ targetId: "dragon_rider", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -145,7 +147,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 100_000, combatPowerRequired: 100_000,
description: description:
"A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.", "A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.",
durationSeconds: 15 * 60, durationSeconds: 120,
id: "frozen_wastes", id: "frozen_wastes",
name: "The Frozen Wastes", name: "The Frozen Wastes",
prerequisiteIds: [], prerequisiteIds: [],
@@ -154,6 +156,7 @@ export const defaultQuests: Array<Quest> = [
{ amount: 100, type: "crystals" }, { amount: 100, type: "crystals" },
{ targetId: "global_3", type: "upgrade" }, { targetId: "global_3", type: "upgrade" },
{ targetId: "knight_1", type: "upgrade" }, { targetId: "knight_1", type: "upgrade" },
{ targetId: "dragon_rider", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -162,7 +165,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 200_000, combatPowerRequired: 200_000,
description: description:
"A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.", "A tomb sealed within a glacier for millennia. The soldiers interred here died guarding something that no longer exists — but their treasures remain.",
durationSeconds: 20 * 60, durationSeconds: 150,
id: "glacier_tomb", id: "glacier_tomb",
name: "The Glacier Tomb", name: "The Glacier Tomb",
prerequisiteIds: [ "frozen_wastes" ], prerequisiteIds: [ "frozen_wastes" ],
@@ -178,7 +181,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 400_000, combatPowerRequired: 400_000,
description: description:
"A labyrinthine network of crystal caverns that descend for miles. The cold here is a presence, not just a temperature.", "A labyrinthine network of crystal caverns that descend for miles. The cold here is a presence, not just a temperature.",
durationSeconds: 25 * 60, durationSeconds: 195,
id: "ice_caves", id: "ice_caves",
name: "The Ice Caves", name: "The Ice Caves",
prerequisiteIds: [ "glacier_tomb" ], prerequisiteIds: [ "glacier_tomb" ],
@@ -194,7 +197,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1_500_000, combatPowerRequired: 1_500_000,
description: description:
"A fortress suspended in a permanent blizzard, built by a mage who wanted to be left alone — and succeeded for three hundred years.", "A fortress suspended in a permanent blizzard, built by a mage who wanted to be left alone — and succeeded for three hundred years.",
durationSeconds: 45 * 60, durationSeconds: 240,
id: "storm_citadel", id: "storm_citadel",
name: "The Storm Citadel", name: "The Storm Citadel",
prerequisiteIds: [ "ice_caves" ], prerequisiteIds: [ "ice_caves" ],
@@ -209,7 +212,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3_000_000, combatPowerRequired: 3_000_000,
description: description:
"Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.", "Deep in the peaks lies the throne room of an ancient frost king, long dead, whose dominion over cold and storm was absolute. His crown still waits.",
durationSeconds: 1 * 60 * 60, durationSeconds: 300,
id: "frozen_throne", id: "frozen_throne",
name: "The Frozen Throne", name: "The Frozen Throne",
prerequisiteIds: [ "storm_citadel" ], prerequisiteIds: [ "storm_citadel" ],
@@ -226,7 +229,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2_000_000, combatPowerRequired: 2_000_000,
description: description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 5 * 60, durationSeconds: 195,
id: "shadow_mere", id: "shadow_mere",
name: "The Shadow Mere", name: "The Shadow Mere",
prerequisiteIds: [], prerequisiteIds: [],
@@ -243,7 +246,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 20_000_000, combatPowerRequired: 20_000_000,
description: description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.", "Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 15 * 60, durationSeconds: 240,
id: "witch_coven", id: "witch_coven",
name: "The Witch Coven", name: "The Witch Coven",
prerequisiteIds: [ "shadow_mere" ], prerequisiteIds: [ "shadow_mere" ],
@@ -260,7 +263,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 80_000_000, combatPowerRequired: 80_000_000,
description: description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.", "An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 15 * 60, durationSeconds: 285,
id: "sunken_temple", id: "sunken_temple",
name: "The Sunken Temple", name: "The Sunken Temple",
prerequisiteIds: [ "witch_coven" ], prerequisiteIds: [ "witch_coven" ],
@@ -276,7 +279,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 300_000_000, combatPowerRequired: 300_000_000,
description: description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.", "A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 25 * 60, durationSeconds: 360,
id: "plague_ruins", id: "plague_ruins",
name: "The Plague Ruins", name: "The Plague Ruins",
prerequisiteIds: [ "sunken_temple" ], prerequisiteIds: [ "sunken_temple" ],
@@ -294,7 +297,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1_200_000_000, combatPowerRequired: 1_200_000_000,
description: description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 25 * 60, durationSeconds: 210,
id: "lava_flows", id: "lava_flows",
name: "The Lava Flows", name: "The Lava Flows",
prerequisiteIds: [], prerequisiteIds: [],
@@ -310,7 +313,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4_800_000_000, combatPowerRequired: 4_800_000_000,
description: description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 45 * 60, durationSeconds: 270,
id: "fire_temple", id: "fire_temple",
name: "The Temple of the Flame", name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ], prerequisiteIds: [ "lava_flows" ],
@@ -326,7 +329,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 18_000_000_000, combatPowerRequired: 18_000_000_000,
description: description:
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.", "Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
durationSeconds: 1 * 60 * 60, durationSeconds: 330,
id: "magma_caverns", id: "magma_caverns",
name: "The Magma Caverns", name: "The Magma Caverns",
prerequisiteIds: [ "fire_temple" ], prerequisiteIds: [ "fire_temple" ],
@@ -342,7 +345,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 72_000_000_000, combatPowerRequired: 72_000_000_000,
description: description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", "The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
durationSeconds: 90 * 60, durationSeconds: 420,
id: "the_forge", id: "the_forge",
name: "The Primordial Forge", name: "The Primordial Forge",
prerequisiteIds: [ "magma_caverns" ], prerequisiteIds: [ "magma_caverns" ],
@@ -359,7 +362,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 300_000_000_000, combatPowerRequired: 300_000_000_000,
description: description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 35 * 60, durationSeconds: 240,
id: "void_rift", id: "void_rift",
name: "Void Rift", name: "Void Rift",
prerequisiteIds: [], prerequisiteIds: [],
@@ -375,7 +378,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1_200_000_000_000, combatPowerRequired: 1_200_000_000_000,
description: description:
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.", "A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
durationSeconds: 1 * 60 * 60, durationSeconds: 300,
id: "star_graveyard", id: "star_graveyard",
name: "The Star Graveyard", name: "The Star Graveyard",
prerequisiteIds: [ "void_rift" ], prerequisiteIds: [ "void_rift" ],
@@ -391,7 +394,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4_800_000_000_000, combatPowerRequired: 4_800_000_000_000,
description: description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", "The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
durationSeconds: 90 * 60, durationSeconds: 420,
id: "between_worlds", id: "between_worlds",
name: "Between Worlds", name: "Between Worlds",
prerequisiteIds: [ "star_graveyard" ], prerequisiteIds: [ "star_graveyard" ],
@@ -408,7 +411,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 18_000_000_000_000, combatPowerRequired: 18_000_000_000_000,
description: description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", "There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
durationSeconds: 210 * 60, durationSeconds: 600,
id: "the_end", id: "the_end",
name: "The End of All Things", name: "The End of All Things",
prerequisiteIds: [ "between_worlds" ], prerequisiteIds: [ "between_worlds" ],
@@ -425,7 +428,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e13, combatPowerRequired: 7.2e13,
description: description:
"The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.", "The threshold between the astral and the divine. Just passing through it changes those who do so in ways they will only understand later.",
durationSeconds: 90 * 60, durationSeconds: 225,
id: "heavens_gate", id: "heavens_gate",
name: "The Heaven's Gate", name: "The Heaven's Gate",
prerequisiteIds: [], prerequisiteIds: [],
@@ -441,7 +444,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e14, combatPowerRequired: 3e14,
description: description:
"A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.", "A gathering of celestial voices whose harmony shapes reality. To witness it is to understand, briefly, what the universe was meant to be.",
durationSeconds: 25 * 60, durationSeconds: 300,
id: "angelic_choir", id: "angelic_choir",
name: "The Angelic Choir", name: "The Angelic Choir",
prerequisiteIds: [ "heavens_gate" ], prerequisiteIds: [ "heavens_gate" ],
@@ -456,7 +459,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e15, combatPowerRequired: 1.2e15,
description: description:
"Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.", "Every event that has ever occurred is recorded here. Your guild's entire history is contained in a single volume, filed under 'Unlikely'.",
durationSeconds: 45 * 60, durationSeconds: 390,
id: "divine_library", id: "divine_library",
name: "The Divine Library", name: "The Divine Library",
prerequisiteIds: [ "angelic_choir" ], prerequisiteIds: [ "angelic_choir" ],
@@ -472,7 +475,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e15, combatPowerRequired: 4.8e15,
description: description:
"A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.", "A fortress built in the space between thoughts — larger inside than any physical structure could be. The celestial host uses it as a staging ground for interventions in mortal affairs.",
durationSeconds: 1 * 60 * 60, durationSeconds: 510,
id: "cloud_citadel", id: "cloud_citadel",
name: "The Cloud Citadel", name: "The Cloud Citadel",
prerequisiteIds: [ "divine_library" ], prerequisiteIds: [ "divine_library" ],
@@ -488,7 +491,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e16, combatPowerRequired: 1.8e16,
description: description:
"The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.", "The celestial host subjects your guild to trials that test not strength but character. Fortunately, your guild has both. Less fortunately, the trials are also designed to be impossible.",
durationSeconds: 90 * 60, durationSeconds: 660,
id: "trial_of_virtue", id: "trial_of_virtue",
name: "The Trial of Virtue", name: "The Trial of Virtue",
prerequisiteIds: [ "cloud_citadel" ], prerequisiteIds: [ "cloud_citadel" ],
@@ -505,7 +508,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e16, combatPowerRequired: 7.2e16,
description: description:
"The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.", "The deepest record in the divine realm — not just of what has happened, but of what is possible. Your guild leaves a mark here that will not be erased when the universe ends.",
durationSeconds: 3 * 60 * 60, durationSeconds: 900,
id: "celestial_archive", id: "celestial_archive",
name: "The Celestial Archive", name: "The Celestial Archive",
prerequisiteIds: [ "trial_of_virtue" ], prerequisiteIds: [ "trial_of_virtue" ],
@@ -522,7 +525,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e17, combatPowerRequired: 3e17,
description: description:
"The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.", "The entry point to the trench — where light surrenders completely and the pressure begins its long, patient work of reminding you of your smallness.",
durationSeconds: 15 * 60, durationSeconds: 360,
id: "the_dark_waters", id: "the_dark_waters",
name: "The Dark Waters", name: "The Dark Waters",
prerequisiteIds: [], prerequisiteIds: [],
@@ -538,14 +541,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e18, combatPowerRequired: 1.2e18,
description: description:
"The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.", "The remains of a civilisation that lived at the bottom of the world for millennia, lighting their world with their own bodies. They are gone. Their light remains, eerie and cold.",
durationSeconds: 35 * 60, durationSeconds: 450,
id: "bioluminescent_ruins", id: "bioluminescent_ruins",
name: "The Bioluminescent Ruins", name: "The Bioluminescent Ruins",
prerequisiteIds: [ "the_dark_waters" ], prerequisiteIds: [ "the_dark_waters" ],
rewards: [ rewards: [
{ amount: 3_000_000_000_000, type: "gold" }, { amount: 3_000_000_000_000, type: "gold" },
{ amount: 1_500_000_000, type: "essence" }, { amount: 1_500_000_000, type: "essence" },
{ amount: 12_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -554,14 +557,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e18, combatPowerRequired: 4.8e18,
description: description:
"Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.", "Caverns carved by forces that would shatter your strongest armour as casually as paper. Your guild navigates them through a combination of skill, preparation, and — honestly — luck.",
durationSeconds: 1 * 60 * 60, durationSeconds: 570,
id: "pressure_caves", id: "pressure_caves",
name: "The Pressure Caves", name: "The Pressure Caves",
prerequisiteIds: [ "bioluminescent_ruins" ], prerequisiteIds: [ "bioluminescent_ruins" ],
rewards: [ rewards: [
{ amount: 10_000_000_000_000, type: "gold" }, { amount: 10_000_000_000_000, type: "gold" },
{ amount: 5_000_000_000, type: "essence" }, { amount: 5_000_000_000, type: "essence" },
{ amount: 30_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -570,14 +573,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e19, combatPowerRequired: 1.8e19,
description: description:
"Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.", "Where the great serpents of the deep come to die — bones larger than cities, slowly being consumed by things that feed on the dead of things that were never truly alive.",
durationSeconds: 90 * 60, durationSeconds: 660,
id: "leviathan_graveyard", id: "leviathan_graveyard",
name: "The Leviathan Graveyard", name: "The Leviathan Graveyard",
prerequisiteIds: [ "pressure_caves" ], prerequisiteIds: [ "pressure_caves" ],
rewards: [ rewards: [
{ amount: 30_000_000_000_000, type: "gold" }, { amount: 30_000_000_000_000, type: "gold" },
{ amount: 15_000_000_000, type: "essence" }, { amount: 15_000_000_000, type: "essence" },
{ amount: 60_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -586,14 +589,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e19, combatPowerRequired: 7.2e19,
description: description:
"A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.", "A throne carved from something that predates stone, found at a depth where the trench opens into something that should not exist below it. Something sat here once. Something may sit here again.",
durationSeconds: 150 * 60, durationSeconds: 900,
id: "black_throne", id: "black_throne",
name: "The Black Throne", name: "The Black Throne",
prerequisiteIds: [ "leviathan_graveyard" ], prerequisiteIds: [ "leviathan_graveyard" ],
rewards: [ rewards: [
{ amount: 100_000_000_000_000, type: "gold" }, { amount: 100_000_000_000_000, type: "gold" },
{ amount: 50_000_000_000, type: "essence" }, { amount: 50_000_000_000, type: "essence" },
{ amount: 120_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
{ targetId: "abyss_diver_1", type: "upgrade" }, { targetId: "abyss_diver_1", type: "upgrade" },
], ],
status: "locked", status: "locked",
@@ -603,14 +606,14 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e20, combatPowerRequired: 3e20,
description: description:
"The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.", "The record carved into the walls of the deepest part of the trench by whatever has lived there since time began. Your guild adds its chapter. It is the first written in a language anyone above has ever understood.",
durationSeconds: 270 * 60, durationSeconds: 1200,
id: "abyssal_chronicle", id: "abyssal_chronicle",
name: "The Abyssal Chronicle", name: "The Abyssal Chronicle",
prerequisiteIds: [ "black_throne" ], prerequisiteIds: [ "black_throne" ],
rewards: [ rewards: [
{ amount: 400_000_000_000_000, type: "gold" }, { amount: 400_000_000_000_000, type: "gold" },
{ amount: 200_000_000_000, type: "essence" }, { amount: 200_000_000_000, type: "essence" },
{ amount: 400_000_000, type: "crystals" }, { amount: 0, type: "crystals" },
], ],
status: "locked", status: "locked",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -620,7 +623,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e21, combatPowerRequired: 1.2e21,
description: description:
"The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.", "The outer reaches of the infernal court — a landscape of sulphur and old fire where lesser demons make their homes and forget what they are waiting for.",
durationSeconds: 25 * 60, durationSeconds: 390,
id: "brimstone_wastes", id: "brimstone_wastes",
name: "The Brimstone Wastes", name: "The Brimstone Wastes",
prerequisiteIds: [], prerequisiteIds: [],
@@ -636,7 +639,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e21, combatPowerRequired: 4.8e21,
description: description:
"The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.", "The repository of every soul the infernal court has ever collected, stretching downward without apparent limit. The voices here are beyond counting. Some of them are recognisable.",
durationSeconds: 50 * 60, durationSeconds: 540,
id: "pit_of_souls", id: "pit_of_souls",
name: "The Pit of Souls", name: "The Pit of Souls",
prerequisiteIds: [ "brimstone_wastes" ], prerequisiteIds: [ "brimstone_wastes" ],
@@ -652,7 +655,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e22, combatPowerRequired: 1.8e22,
description: description:
"The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.", "The actual seat of demon governance — where the lords convene to settle their endless disputes. Your guild attends the session uninvited. The lords are not pleased. They are, however, briefly unified.",
durationSeconds: 90 * 60, durationSeconds: 690,
id: "court_of_blood", id: "court_of_blood",
name: "The Court of Blood", name: "The Court of Blood",
prerequisiteIds: [ "pit_of_souls" ], prerequisiteIds: [ "pit_of_souls" ],
@@ -668,7 +671,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e22, combatPowerRequired: 7.2e22,
description: description:
"Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.", "Each circle of the infernal court is its own ecosystem of suffering, and your guild passes through all nine. By the seventh, it has stopped being surprising. By the ninth, it has become almost comfortable.",
durationSeconds: 150 * 60, durationSeconds: 900,
id: "nine_hells", id: "nine_hells",
name: "The Nine Hells", name: "The Nine Hells",
prerequisiteIds: [ "court_of_blood" ], prerequisiteIds: [ "court_of_blood" ],
@@ -684,7 +687,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e23, combatPowerRequired: 3e23,
description: description:
"The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.", "The forge where the demon lords create their weapons — each one an atrocity given material form. Your guild has come to learn its secrets, or failing that, to destroy it.",
durationSeconds: 210 * 60, durationSeconds: 1200,
id: "demon_forge", id: "demon_forge",
name: "The Demon Forge", name: "The Demon Forge",
prerequisiteIds: [ "nine_hells" ], prerequisiteIds: [ "nine_hells" ],
@@ -701,7 +704,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e24, combatPowerRequired: 1.2e24,
description: description:
"The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.", "The complete record of every deal, pact, and contract the infernal court has ever made. Your guild finds its own name in there, in a clause you definitely did not agree to. You cross it out.",
durationSeconds: 330 * 60, durationSeconds: 1500,
id: "infernal_codex", id: "infernal_codex",
name: "The Infernal Codex", name: "The Infernal Codex",
prerequisiteIds: [ "demon_forge" ], prerequisiteIds: [ "demon_forge" ],
@@ -718,7 +721,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e24, combatPowerRequired: 4.8e24,
description: description:
"The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.", "The entrance to the spire — a door made of possibilities that splits your guild into every version of itself simultaneously. Only the best version makes it through. You are that version.",
durationSeconds: 35 * 60, durationSeconds: 600,
id: "prism_gate", id: "prism_gate",
name: "The Prism Gate", name: "The Prism Gate",
prerequisiteIds: [], prerequisiteIds: [],
@@ -734,7 +737,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e25, combatPowerRequired: 1.8e25,
description: description:
"A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.", "A maze of mirrors that reflects not your appearance but your choices — every path shows what would have happened if you had chosen differently. Several of those paths look significantly better.",
durationSeconds: 1 * 60 * 60, durationSeconds: 780,
id: "crystal_labyrinth", id: "crystal_labyrinth",
name: "The Crystal Labyrinth", name: "The Crystal Labyrinth",
prerequisiteIds: [ "prism_gate" ], prerequisiteIds: [ "prism_gate" ],
@@ -750,7 +753,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e25, combatPowerRequired: 7.2e25,
description: description:
"A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.", "A space where geometry has opinions — where right angles are suggestions and parallel lines eventually converge into something that has no name in any language your guild speaks.",
durationSeconds: 2 * 60 * 60, durationSeconds: 960,
id: "faceted_realm", id: "faceted_realm",
name: "The Faceted Realm", name: "The Faceted Realm",
prerequisiteIds: [ "crystal_labyrinth" ], prerequisiteIds: [ "crystal_labyrinth" ],
@@ -766,7 +769,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e26, combatPowerRequired: 3e26,
description: description:
"The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.", "The repository of crystallised knowledge — everything the spire has calculated, preserved in structures of compressed carbon that contain more information than your guild's entire written history.",
durationSeconds: 3 * 60 * 60, durationSeconds: 1200,
id: "diamond_vault", id: "diamond_vault",
name: "The Diamond Vault", name: "The Diamond Vault",
prerequisiteIds: [ "faceted_realm" ], prerequisiteIds: [ "faceted_realm" ],
@@ -782,7 +785,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e27, combatPowerRequired: 1.2e27,
description: description:
"The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.", "The approach to the Sovereign's chamber — a corridor of living crystal that evaluates your guild as you walk through it and reconfigures itself in real time to create the optimal challenge for exactly what your guild is.",
durationSeconds: 270 * 60, durationSeconds: 1500,
id: "sovereign_spire", id: "sovereign_spire",
name: "The Sovereign's Spire", name: "The Sovereign's Spire",
prerequisiteIds: [ "diamond_vault" ], prerequisiteIds: [ "diamond_vault" ],
@@ -799,7 +802,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e27, combatPowerRequired: 4.8e27,
description: description:
"The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.", "The innermost sanctum of the spire — where the Sovereign keeps its most precious calculations, its predictions for the last moments of this universe, sealed in crystal that has never been touched by anything other than thought.",
durationSeconds: 7 * 60 * 60, durationSeconds: 1800,
id: "the_prism_vault", id: "the_prism_vault",
name: "The Prism Vault", name: "The Prism Vault",
prerequisiteIds: [ "sovereign_spire" ], prerequisiteIds: [ "sovereign_spire" ],
@@ -816,7 +819,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e28, combatPowerRequired: 1.8e28,
description: description:
"The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.", "The boundary between existing and not — a membrane so thin that your guild can feel their own existence becoming uncertain as they cross it. On the other side: the sanctum.",
durationSeconds: 50 * 60, durationSeconds: 900,
id: "void_threshold", id: "void_threshold",
name: "The Void Threshold", name: "The Void Threshold",
prerequisiteIds: [], prerequisiteIds: [],
@@ -832,7 +835,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e28, combatPowerRequired: 7.2e28,
description: description:
"Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.", "Darkness here is not the absence of light but a substance in its own right — thick, pressured, aware. It has been dark here since before the concept of light existed elsewhere.",
durationSeconds: 90 * 60, durationSeconds: 1200,
id: "eternal_dark", id: "eternal_dark",
name: "The Eternal Dark", name: "The Eternal Dark",
prerequisiteIds: [ "void_threshold" ], prerequisiteIds: [ "void_threshold" ],
@@ -848,7 +851,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e29, combatPowerRequired: 3e29,
description: description:
"The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.", "The lower reaches of the void sanctum, where the Emperor's power saturates every particle. Your guild walks through a space that doesn't want them to exist — and continues existing anyway.",
durationSeconds: 3 * 60 * 60, durationSeconds: 1500,
id: "sanctum_depths", id: "sanctum_depths",
name: "The Sanctum Depths", name: "The Sanctum Depths",
prerequisiteIds: [ "eternal_dark" ], prerequisiteIds: [ "eternal_dark" ],
@@ -864,7 +867,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e30, combatPowerRequired: 1.2e30,
description: description:
"Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.", "Where the void Emperor tests its power — a space where things are regularly unmade as a display of authority. Your guild's refusal to be unmade is, to the Emperor, nothing short of astonishing.",
durationSeconds: 270 * 60, durationSeconds: 1800,
id: "unmaking_grounds", id: "unmaking_grounds",
name: "The Unmaking Grounds", name: "The Unmaking Grounds",
prerequisiteIds: [ "sanctum_depths" ], prerequisiteIds: [ "sanctum_depths" ],
@@ -880,7 +883,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e30, combatPowerRequired: 4.8e30,
description: description:
"The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.", "The final corridor before the void Emperor — a space that exists only because the Emperor allows it to. Every step forward is an argument your guild makes for their right to exist. So far, it's working.",
durationSeconds: 7 * 60 * 60, durationSeconds: 2100,
id: "emperor_approach", id: "emperor_approach",
name: "The Emperor's Approach", name: "The Emperor's Approach",
prerequisiteIds: [ "unmaking_grounds" ], prerequisiteIds: [ "unmaking_grounds" ],
@@ -897,7 +900,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e31, combatPowerRequired: 1.8e31,
description: description:
"The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.", "The absolute centre of the void sanctum — the point from which all absence radiates. Your guild stands here and, remarkably, continues to be. That alone is a victory no one before them has achieved.",
durationSeconds: 10 * 60 * 60, durationSeconds: 2400,
id: "heart_of_void", id: "heart_of_void",
name: "The Heart of the Void", name: "The Heart of the Void",
prerequisiteIds: [ "emperor_approach" ], prerequisiteIds: [ "emperor_approach" ],
@@ -914,7 +917,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e31, combatPowerRequired: 7.2e31,
description: description:
"The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.", "The waiting room for the absolute seat of power. No one has ever been made to wait here, because no one has ever arrived before. Your guild has arrived. The door is very large.",
durationSeconds: 1 * 60 * 60, durationSeconds: 1200,
id: "throne_antechamber", id: "throne_antechamber",
name: "The Throne Antechamber", name: "The Throne Antechamber",
prerequisiteIds: [], prerequisiteIds: [],
@@ -930,7 +933,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e32, combatPowerRequired: 3e32,
description: description:
"A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.", "A series of trials designed not to test your guild but to exhaust them — to ensure that only something with genuine, inexhaustible will can reach the throne. Your guild has passed. The throne takes note.",
durationSeconds: 150 * 60, durationSeconds: 1500,
id: "eternal_gauntlet", id: "eternal_gauntlet",
name: "The Eternal Gauntlet", name: "The Eternal Gauntlet",
prerequisiteIds: [ "throne_antechamber" ], prerequisiteIds: [ "throne_antechamber" ],
@@ -946,7 +949,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e33, combatPowerRequired: 1.2e33,
description: description:
"The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.", "The final proving ground — a set of challenges that have been accumulating since the throne was first occupied, waiting for a challenger worthy enough to face them. Your guild is facing them. Barely.",
durationSeconds: 4 * 60 * 60, durationSeconds: 1800,
id: "apex_trials", id: "apex_trials",
name: "The Apex Trials", name: "The Apex Trials",
prerequisiteIds: [ "eternal_gauntlet" ], prerequisiteIds: [ "eternal_gauntlet" ],
@@ -962,7 +965,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e33, combatPowerRequired: 4.8e33,
description: description:
"The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.", "The great hall through which every power in every universe has passed in supplication. No one has walked it as an equal before. Your guild walks it as a challenger. The difference is felt by everything that has ever knelt here.",
durationSeconds: 330 * 60, durationSeconds: 2100,
id: "sovereign_hall", id: "sovereign_hall",
name: "The Sovereign's Hall", name: "The Sovereign's Hall",
prerequisiteIds: [ "apex_trials" ], prerequisiteIds: [ "apex_trials" ],
@@ -979,7 +982,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e34, combatPowerRequired: 1.8e34,
description: description:
"The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.", "The last staircase. Every step a moment of history being made. At the top: the throne, and the one who sits upon it, who has watched your guild climb and finds themselves, for the first time in all of existence, uncertain.",
durationSeconds: 9 * 60 * 60, durationSeconds: 2700,
id: "the_final_ascent", id: "the_final_ascent",
name: "The Final Ascent", name: "The Final Ascent",
prerequisiteIds: [ "sovereign_hall" ], prerequisiteIds: [ "sovereign_hall" ],
@@ -995,7 +998,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e34, combatPowerRequired: 7.2e34,
description: description:
"The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.", "The throne is yours. Not just this one — all the power that flows from it, into every plane and reality it has shaped across all of time. Your guild has not merely won. It has become the thing that wins, permanently, for the rest of forever.",
durationSeconds: 14 * 60 * 60, durationSeconds: 3600,
id: "eternal_dominion", id: "eternal_dominion",
name: "Eternal Dominion", name: "Eternal Dominion",
prerequisiteIds: [ "the_final_ascent" ], prerequisiteIds: [ "the_final_ascent" ],
@@ -1012,7 +1015,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e35, combatPowerRequired: 3e35,
description: description:
"Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.", "Your guild steps beyond the throne into something that has no rules — a place where the very concept of place is contested. Every step forward is an act of defiance against the universe's first draft of itself.",
durationSeconds: 90 * 60, durationSeconds: 1500,
id: "chaos_entry", id: "chaos_entry",
name: "Into the Chaos", name: "Into the Chaos",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1028,7 +1031,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e36, combatPowerRequired: 1.2e36,
description: description:
"Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.", "Rivers of raw creation flow through the primordial chaos — not water but pure potential, capable of transforming anything they touch into anything else entirely.",
durationSeconds: 150 * 60, durationSeconds: 1800,
id: "chaos_currents", id: "chaos_currents",
name: "The Chaos Currents", name: "The Chaos Currents",
prerequisiteIds: [ "chaos_entry" ], prerequisiteIds: [ "chaos_entry" ],
@@ -1044,7 +1047,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e36, combatPowerRequired: 4.8e36,
description: description:
"A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.", "A region of the chaos where the argument between existence and non-existence has not yet produced a winner — where matter and anti-matter coexist in violent, constant negotiation.",
durationSeconds: 270 * 60, durationSeconds: 2400,
id: "unformed_wastes", id: "unformed_wastes",
name: "The Unformed Wastes", name: "The Unformed Wastes",
prerequisiteIds: [ "chaos_currents" ], prerequisiteIds: [ "chaos_currents" ],
@@ -1061,7 +1064,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e37, combatPowerRequired: 1.8e37,
description: description:
"Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.", "Every possibility that has never occurred is stored here — in vaults that have no walls, containing things that have no form. Your guild navigates them by deciding what they want to find, and finding it.",
durationSeconds: 6 * 60 * 60, durationSeconds: 3000,
id: "potential_vaults", id: "potential_vaults",
name: "The Vaults of Potential", name: "The Vaults of Potential",
prerequisiteIds: [ "unformed_wastes" ], prerequisiteIds: [ "unformed_wastes" ],
@@ -1077,7 +1080,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e37, combatPowerRequired: 7.2e37,
description: description:
"The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.", "The origin point of everything — not a place but the idea of the first place, preserved in the chaos as a monument to the moment reality decided to exist.",
durationSeconds: 9 * 60 * 60, durationSeconds: 3600,
id: "creation_cradle", id: "creation_cradle",
name: "The Creation Cradle", name: "The Creation Cradle",
prerequisiteIds: [ "potential_vaults" ], prerequisiteIds: [ "potential_vaults" ],
@@ -1094,7 +1097,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e38, combatPowerRequired: 3e38,
description: description:
"The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.", "The record of everything that almost was — every universe that the chaos produced and discarded before settling on this one. Your guild reads it and understands, for the first time, how unlikely they are.",
durationSeconds: 13 * 60 * 60, durationSeconds: 4800,
id: "chaos_chronicle", id: "chaos_chronicle",
name: "The Chaos Chronicle", name: "The Chaos Chronicle",
prerequisiteIds: [ "creation_cradle" ], prerequisiteIds: [ "creation_cradle" ],
@@ -1111,7 +1114,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e39, combatPowerRequired: 1.2e39,
description: description:
"The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.", "The edge of the knowable — not because nothing lies beyond, but because the Expanse has no edges and every horizon is also a centre. Your guild walks toward a destination that keeps receding at the exact speed they approach it.",
durationSeconds: 90 * 60, durationSeconds: 1800,
id: "first_horizon", id: "first_horizon",
name: "The First Horizon", name: "The First Horizon",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1127,7 +1130,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e39, combatPowerRequired: 4.8e39,
description: description:
"An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.", "An ocean with no shores, no depth, no surface — a body of liquid possibility that extends infinitely in all directions, including inward. Your guild sails it without a ship and arrives exactly when they decide to.",
durationSeconds: 3 * 60 * 60, durationSeconds: 2400,
id: "endless_sea", id: "endless_sea",
name: "The Endless Sea", name: "The Endless Sea",
prerequisiteIds: [ "first_horizon" ], prerequisiteIds: [ "first_horizon" ],
@@ -1143,7 +1146,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e40, combatPowerRequired: 1.8e40,
description: description:
"Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.", "Civilisations that attempted the Expanse before your guild and ran out of universe. Their ruins drift without reference points, enormous and silent, a reminder that infinity has claimed predecessors.",
durationSeconds: 5 * 60 * 60, durationSeconds: 3000,
id: "expanse_ruins", id: "expanse_ruins",
name: "The Expanse Ruins", name: "The Expanse Ruins",
prerequisiteIds: [ "endless_sea" ], prerequisiteIds: [ "endless_sea" ],
@@ -1160,7 +1163,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e40, combatPowerRequired: 7.2e40,
description: description:
"A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.", "A library with no walls, cataloguing everything that exists across all of infinite space. The catalogue itself is infinite. The librarian is very tired.",
durationSeconds: 8 * 60 * 60, durationSeconds: 3900,
id: "infinite_archive", id: "infinite_archive",
name: "The Infinite Archive", name: "The Infinite Archive",
prerequisiteIds: [ "expanse_ruins" ], prerequisiteIds: [ "expanse_ruins" ],
@@ -1177,7 +1180,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e41, combatPowerRequired: 3e41,
description: description:
"A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.", "A region where the Expanse loops back on itself — where every direction is simultaneously every other direction, and travel requires your guild to stop thinking about it too hard.",
durationSeconds: 11 * 60 * 60, durationSeconds: 5100,
id: "paradox_plains", id: "paradox_plains",
name: "The Paradox Plains", name: "The Paradox Plains",
prerequisiteIds: [ "infinite_archive" ], prerequisiteIds: [ "infinite_archive" ],
@@ -1194,7 +1197,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e42, combatPowerRequired: 1.2e42,
description: description:
"The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.", "The complete record of all infinite things — compressed, impossibly, into a document your guild can almost read. What they can read changes everything they thought they understood about the word 'everything'.",
durationSeconds: 16 * 60 * 60, durationSeconds: 6600,
id: "expanse_codex", id: "expanse_codex",
name: "The Expanse Codex", name: "The Expanse Codex",
prerequisiteIds: [ "paradox_plains" ], prerequisiteIds: [ "paradox_plains" ],
@@ -1211,7 +1214,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e42, combatPowerRequired: 4.8e42,
description: description:
"The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.", "The door to the Reality Forge has been open since the moment reality started — left ajar because the workers never thought anyone else would find it. Your guild finds it.",
durationSeconds: 2 * 60 * 60, durationSeconds: 1800,
id: "forge_entrance", id: "forge_entrance",
name: "The Forge Entrance", name: "The Forge Entrance",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1227,7 +1230,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e43, combatPowerRequired: 1.8e43,
description: description:
"The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.", "The Forge keeps the blueprints for every universe it has ever built — and the rejected designs for the ones it hasn't. Some of those rejected blueprints are disturbingly appealing.",
durationSeconds: 210 * 60, durationSeconds: 2400,
id: "blueprint_vault", id: "blueprint_vault",
name: "The Blueprint Vault", name: "The Blueprint Vault",
prerequisiteIds: [ "forge_entrance" ], prerequisiteIds: [ "forge_entrance" ],
@@ -1243,7 +1246,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e43, combatPowerRequired: 7.2e43,
description: description:
"The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.", "The active floor of the Forge — where new realities are being assembled right now, and your guild must navigate between workbenches containing half-finished universes without knocking anything over.",
durationSeconds: 330 * 60, durationSeconds: 3000,
id: "creation_workshop", id: "creation_workshop",
name: "The Creation Workshop", name: "The Creation Workshop",
prerequisiteIds: [ "blueprint_vault" ], prerequisiteIds: [ "blueprint_vault" ],
@@ -1260,7 +1263,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e44, combatPowerRequired: 3e44,
description: description:
"The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.", "The mechanism that produces the laws of physics — an engine running since the first moment, churning out constants and rules that every universe obeys without knowing why. Your guild sees the source code.",
durationSeconds: 9 * 60 * 60, durationSeconds: 3600,
id: "laws_engine", id: "laws_engine",
name: "The Laws Engine", name: "The Laws Engine",
prerequisiteIds: [ "creation_workshop" ], prerequisiteIds: [ "creation_workshop" ],
@@ -1277,7 +1280,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e45, combatPowerRequired: 1.2e45,
description: description:
"The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.", "The power source of the Reality Forge — not a furnace but a contained singularity, burning with the same energy that ignited the first universe. Your guild siphons from it. The Forge barely notices.",
durationSeconds: 12 * 60 * 60, durationSeconds: 4800,
id: "forge_heart", id: "forge_heart",
name: "The Forge Heart", name: "The Forge Heart",
prerequisiteIds: [ "laws_engine" ], prerequisiteIds: [ "laws_engine" ],
@@ -1293,7 +1296,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e45, combatPowerRequired: 4.8e45,
description: description:
"The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.", "The record of every reality the Forge has produced — every universe that exists or ever existed, with notes on what worked and what didn't. Your guild's universe has several notes. Most are surprising.",
durationSeconds: 17 * 60 * 60, durationSeconds: 6000,
id: "forge_chronicle", id: "forge_chronicle",
name: "The Forge Chronicle", name: "The Forge Chronicle",
prerequisiteIds: [ "forge_heart" ], prerequisiteIds: [ "forge_heart" ],
@@ -1310,7 +1313,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e59, combatPowerRequired: 1e59,
description: description:
"The deep levels of the Forge, where the most experimental realities are stored in a state of near-completion. Your guild discovers that several of these abandoned projects are disturbingly familiar.", "The deep levels of the Forge, where the most experimental realities are stored in a state of near-completion. Your guild discovers that several of these abandoned projects are disturbingly familiar.",
durationSeconds: 24 * 60 * 60, durationSeconds: 7200,
id: "forge_depths", id: "forge_depths",
name: "The Forge Depths", name: "The Forge Depths",
prerequisiteIds: [ "forge_chronicle" ], prerequisiteIds: [ "forge_chronicle" ],
@@ -1326,7 +1329,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e64, combatPowerRequired: 3e64,
description: description:
"The underlying structure that defines the laws for every reality the Forge produces — a lattice of constraints so fundamental that violating them would undo everything. Your guild crosses it carefully.", "The underlying structure that defines the laws for every reality the Forge produces — a lattice of constraints so fundamental that violating them would undo everything. Your guild crosses it carefully.",
durationSeconds: 24 * 60 * 60, durationSeconds: 9300,
id: "prime_matrix", id: "prime_matrix",
name: "The Prime Matrix", name: "The Prime Matrix",
prerequisiteIds: [ "forge_depths" ], prerequisiteIds: [ "forge_depths" ],
@@ -1342,7 +1345,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8e69, combatPowerRequired: 8e69,
description: description:
"The complete record of every reality the Forge has ever produced, indexed, annotated, and preserved. Your universe has a surprisingly detailed entry with several editorial notes.", "The complete record of every reality the Forge has ever produced, indexed, annotated, and preserved. Your universe has a surprisingly detailed entry with several editorial notes.",
durationSeconds: 24 * 60 * 60, durationSeconds: 11_400,
id: "creation_archive", id: "creation_archive",
name: "The Creation Archive", name: "The Creation Archive",
prerequisiteIds: [ "prime_matrix" ], prerequisiteIds: [ "prime_matrix" ],
@@ -1359,7 +1362,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e46, combatPowerRequired: 1.8e46,
description: description:
"The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.", "The outermost reach of the Cosmic Maelstrom — where everything moves at a speed that makes stars look stationary. Your guild anchors itself in the relative calm of its periphery and begins to push inward.",
durationSeconds: 150 * 60, durationSeconds: 3000,
id: "maelstrom_entry", id: "maelstrom_entry",
name: "The Maelstrom's Edge", name: "The Maelstrom's Edge",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1375,7 +1378,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e46, combatPowerRequired: 7.2e46,
description: description:
"The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.", "The point where every cosmic force intersects — where gravity and electromagnetism and every other fundamental force meet and argue. The argument is conducted at energies that reshape matter.",
durationSeconds: 4 * 60 * 60, durationSeconds: 3900,
id: "force_nexus", id: "force_nexus",
name: "The Force Nexus", name: "The Force Nexus",
prerequisiteIds: [ "maelstrom_entry" ], prerequisiteIds: [ "maelstrom_entry" ],
@@ -1391,7 +1394,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e47, combatPowerRequired: 3e47,
description: description:
"A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.", "A region where cosmic storms have been brewing since the beginning of time, compounding on themselves into intensities that no physical object should be able to survive. Your guild survives.",
durationSeconds: 6 * 60 * 60, durationSeconds: 4800,
id: "storm_cauldron", id: "storm_cauldron",
name: "The Storm Cauldron", name: "The Storm Cauldron",
prerequisiteIds: [ "force_nexus" ], prerequisiteIds: [ "force_nexus" ],
@@ -1408,7 +1411,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e48, combatPowerRequired: 1.2e48,
description: description:
"Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.", "Regions of space where creation and destruction happen simultaneously at rates that would erase continents. Your guild navigates the moments between creation and erasure with precision that surprises even themselves.",
durationSeconds: 9 * 60 * 60, durationSeconds: 6000,
id: "annihilation_fields", id: "annihilation_fields",
name: "The Annihilation Fields", name: "The Annihilation Fields",
prerequisiteIds: [ "storm_cauldron" ], prerequisiteIds: [ "storm_cauldron" ],
@@ -1424,7 +1427,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e48, combatPowerRequired: 4.8e48,
description: description:
"The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.", "The centre of the Cosmic Maelstrom — the point toward which every force converges and from which everything radiates. Being here is being at the exact centre of all physical law. It is very loud.",
durationSeconds: 13 * 60 * 60, durationSeconds: 7200,
id: "convergence_point", id: "convergence_point",
name: "The Convergence Point", name: "The Convergence Point",
prerequisiteIds: [ "annihilation_fields" ], prerequisiteIds: [ "annihilation_fields" ],
@@ -1441,7 +1444,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e49, combatPowerRequired: 1.8e49,
description: description:
"The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.", "The record kept in the eye of the storm — the one place calm enough to write, where every force is in perfect balance. Your guild adds their chapter in the moments before the balance shifts again.",
durationSeconds: 19 * 60 * 60, durationSeconds: 9300,
id: "maelstrom_codex", id: "maelstrom_codex",
name: "The Maelstrom Codex", name: "The Maelstrom Codex",
prerequisiteIds: [ "convergence_point" ], prerequisiteIds: [ "convergence_point" ],
@@ -1458,7 +1461,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2e73, combatPowerRequired: 2e73,
description: description:
"The deepest layer of the maelstrom — where the storms have been spiralling for so long they have created something resembling order. Your guild navigates it by learning to read the shape of chaos.", "The deepest layer of the maelstrom — where the storms have been spiralling for so long they have created something resembling order. Your guild navigates it by learning to read the shape of chaos.",
durationSeconds: 24 * 60 * 60, durationSeconds: 11_400,
id: "maelstrom_deep", id: "maelstrom_deep",
name: "The Deep Maelstrom", name: "The Deep Maelstrom",
prerequisiteIds: [ "maelstrom_codex" ], prerequisiteIds: [ "maelstrom_codex" ],
@@ -1474,7 +1477,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 5e79, combatPowerRequired: 5e79,
description: description:
"The point at which all the storm currents converge — not because they are drawn there, but because there is nowhere else left to go. Your guild stands in the geometric centre of cosmic fury.", "The point at which all the storm currents converge — not because they are drawn there, but because there is nowhere else left to go. Your guild stands in the geometric centre of cosmic fury.",
durationSeconds: 24 * 60 * 60, durationSeconds: 14_400,
id: "maelstrom_nexus", id: "maelstrom_nexus",
name: "The Storm Nexus", name: "The Storm Nexus",
prerequisiteIds: [ "maelstrom_deep" ], prerequisiteIds: [ "maelstrom_deep" ],
@@ -1490,7 +1493,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e86, combatPowerRequired: 1e86,
description: description:
"The record of every storm that has ever been — an archive written in lightning, indexed in thunder, preserved in the kind of silence that only exists at the exact centre of infinite noise.", "The record of every storm that has ever been — an archive written in lightning, indexed in thunder, preserved in the kind of silence that only exists at the exact centre of infinite noise.",
durationSeconds: 24 * 60 * 60, durationSeconds: 18_000,
id: "storm_chronicle", id: "storm_chronicle",
name: "The Storm Chronicle", name: "The Storm Chronicle",
prerequisiteIds: [ "maelstrom_nexus" ], prerequisiteIds: [ "maelstrom_nexus" ],
@@ -1507,7 +1510,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e49, combatPowerRequired: 7.2e49,
description: description:
"The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.", "The entrance to the oldest place — a threshold that does not open because it was never closed. It merely requires you to be old enough, deep enough, powerful enough to perceive it.",
durationSeconds: 150 * 60, durationSeconds: 4800,
id: "sanctum_gate", id: "sanctum_gate",
name: "The Sanctum Gate", name: "The Sanctum Gate",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1523,7 +1526,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e50, combatPowerRequired: 3e50,
description: description:
"The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.", "The sanctum stores every moment that has ever occurred — not as records but as living impressions, still occurring in perpetual replay. Your guild walks through history as it happens, over and over.",
durationSeconds: 270 * 60, durationSeconds: 5700,
id: "memory_vaults", id: "memory_vaults",
name: "The Memory Vaults", name: "The Memory Vaults",
prerequisiteIds: [ "sanctum_gate" ], prerequisiteIds: [ "sanctum_gate" ],
@@ -1539,7 +1542,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e51, combatPowerRequired: 1.2e51,
description: description:
"The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.", "The halls where everything began — not the physical beginning, but the idea of beginning itself, preserved here as the sanctum's most sacred artefact. To walk these halls is to understand why anything started.",
durationSeconds: 7 * 60 * 60, durationSeconds: 7200,
id: "origin_halls", id: "origin_halls",
name: "The Origin Halls", name: "The Origin Halls",
prerequisiteIds: [ "memory_vaults" ], prerequisiteIds: [ "memory_vaults" ],
@@ -1556,7 +1559,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e51, combatPowerRequired: 4.8e51,
description: description:
"The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.", "The chamber where the first photon was produced — still illuminated by that original light, unchanged for all of time. The warmth here is the warmth of the universe's childhood.",
durationSeconds: 10 * 60 * 60, durationSeconds: 9000,
id: "first_light_hall", id: "first_light_hall",
name: "The Hall of First Light", name: "The Hall of First Light",
prerequisiteIds: [ "origin_halls" ], prerequisiteIds: [ "origin_halls" ],
@@ -1572,7 +1575,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e52, combatPowerRequired: 1.8e52,
description: description:
"A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.", "A region of the sanctum that predates the concept of sequence — where cause does not reliably precede effect, and your guild must navigate by intention rather than direction.",
durationSeconds: 14 * 60 * 60, durationSeconds: 11_400,
id: "before_time", id: "before_time",
name: "Before Time", name: "Before Time",
prerequisiteIds: [ "first_light_hall" ], prerequisiteIds: [ "first_light_hall" ],
@@ -1588,7 +1591,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e52, combatPowerRequired: 7.2e52,
description: description:
"The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.", "The complete record of all primeval things — every first moment of every concept that has ever existed, bound together in something that predates writing, reading, and the idea of records. Your guild understands it anyway.",
durationSeconds: 21 * 60 * 60, durationSeconds: 14_400,
id: "sanctum_chronicle", id: "sanctum_chronicle",
name: "The Sanctum Chronicle", name: "The Sanctum Chronicle",
prerequisiteIds: [ "before_time" ], prerequisiteIds: [ "before_time" ],
@@ -1604,7 +1607,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e92, combatPowerRequired: 3e92,
description: description:
"The deepest chambers of the sanctum — where the primordia are not preserved but still occurring, still being, still becoming for the first and only time. The floor hums with unfinished creation.", "The deepest chambers of the sanctum — where the primordia are not preserved but still occurring, still being, still becoming for the first and only time. The floor hums with unfinished creation.",
durationSeconds: 24 * 60 * 60, durationSeconds: 18_000,
id: "sanctum_deep", id: "sanctum_deep",
name: "The Deep Sanctum", name: "The Deep Sanctum",
prerequisiteIds: [ "sanctum_chronicle" ], prerequisiteIds: [ "sanctum_chronicle" ],
@@ -1620,7 +1623,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 8e98, combatPowerRequired: 8e98,
description: description:
"The crossroads between everything primeval and everything that came after — a threshold so old that every subsequent age of the universe is, from its perspective, still ongoing.", "The crossroads between everything primeval and everything that came after — a threshold so old that every subsequent age of the universe is, from its perspective, still ongoing.",
durationSeconds: 24 * 60 * 60, durationSeconds: 22_500,
id: "sanctum_nexus", id: "sanctum_nexus",
name: "The Primeval Nexus", name: "The Primeval Nexus",
prerequisiteIds: [ "sanctum_deep" ], prerequisiteIds: [ "sanctum_deep" ],
@@ -1636,7 +1639,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2e105, combatPowerRequired: 2e105,
description: description:
"The sanctum's final gift to those who reached its depths: a full accounting of what it means to have existed before time had opinions about how things should go. Your guild is the first to read it.", "The sanctum's final gift to those who reached its depths: a full accounting of what it means to have existed before time had opinions about how things should go. Your guild is the first to read it.",
durationSeconds: 24 * 60 * 60, durationSeconds: 27_900,
id: "primeval_archive", id: "primeval_archive",
name: "The Primeval Archive", name: "The Primeval Archive",
prerequisiteIds: [ "sanctum_nexus" ], prerequisiteIds: [ "sanctum_nexus" ],
@@ -1653,7 +1656,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e53, combatPowerRequired: 3e53,
description: description:
"The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.", "The beginning of the end of everything. Your guild crosses it and feels, for the first time, that they have gone somewhere genuinely, ontologically final.",
durationSeconds: 3 * 60 * 60, durationSeconds: 5100,
id: "absolute_threshold", id: "absolute_threshold",
name: "The Absolute Threshold", name: "The Absolute Threshold",
prerequisiteIds: [], prerequisiteIds: [],
@@ -1669,7 +1672,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.2e54, combatPowerRequired: 1.2e54,
description: description:
"Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.", "Not empty — nothing. A region where even the concept of region is a courtesy your guild extends to the space by thinking about it. The moment they stop thinking, it stops being a space.",
durationSeconds: 5 * 60 * 60, durationSeconds: 6300,
id: "nothing_wastes", id: "nothing_wastes",
name: "The Nothing Wastes", name: "The Nothing Wastes",
prerequisiteIds: [ "absolute_threshold" ], prerequisiteIds: [ "absolute_threshold" ],
@@ -1685,7 +1688,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 4.8e54, combatPowerRequired: 4.8e54,
description: description:
"A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.", "A region that exists by virtue of containing the contradiction of existence and non-existence simultaneously — a place that is also not a place, navigable only by those who have stopped needing either to be true.",
durationSeconds: 8 * 60 * 60, durationSeconds: 7800,
id: "final_paradox", id: "final_paradox",
name: "The Final Paradox", name: "The Final Paradox",
prerequisiteIds: [ "nothing_wastes" ], prerequisiteIds: [ "nothing_wastes" ],
@@ -1701,7 +1704,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1.8e55, combatPowerRequired: 1.8e55,
description: description:
"Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.", "Everything that has ever ended is stored here — every life, every civilisation, every universe, every concept that has run its course. The collection is comprehensive. Your guild is not in it yet.",
durationSeconds: 11 * 60 * 60, durationSeconds: 9600,
id: "end_vault", id: "end_vault",
name: "The Vault of Ends", name: "The Vault of Ends",
prerequisiteIds: [ "final_paradox" ], prerequisiteIds: [ "final_paradox" ],
@@ -1717,7 +1720,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 7.2e55, combatPowerRequired: 7.2e55,
description: description:
"The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.", "The last path before the last thing. Every step here is a step that has never been taken before and will never be taken again. The Absolute awaits at the end of it, and it is aware of your guild.",
durationSeconds: 17 * 60 * 60, durationSeconds: 12_000,
id: "terminal_approach", id: "terminal_approach",
name: "The Terminal Approach", name: "The Terminal Approach",
prerequisiteIds: [ "end_vault" ], prerequisiteIds: [ "end_vault" ],
@@ -1733,7 +1736,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e56, combatPowerRequired: 3e56,
description: description:
"This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.", "This is it. Not the throne — not power — not victory. Just the knowledge, confirmed and total, that your guild reached the end of everything and was not ended. That is, in every measurable way, enough.",
durationSeconds: 24 * 60 * 60, durationSeconds: 15_300,
id: "absolute_dominion", id: "absolute_dominion",
name: "Absolute Dominion", name: "Absolute Dominion",
prerequisiteIds: [ "terminal_approach" ], prerequisiteIds: [ "terminal_approach" ],
@@ -1749,7 +1752,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 5e111, combatPowerRequired: 5e111,
description: description:
"Beyond the end of everything, there is more. Not in contradiction — but in the way that answers, once found, reveal the next question. Your guild goes further than the concept of further was designed to accommodate.", "Beyond the end of everything, there is more. Not in contradiction — but in the way that answers, once found, reveal the next question. Your guild goes further than the concept of further was designed to accommodate.",
durationSeconds: 24 * 60 * 60, durationSeconds: 18_900,
id: "absolute_beyond", id: "absolute_beyond",
name: "Beyond the Absolute", name: "Beyond the Absolute",
prerequisiteIds: [ "absolute_dominion" ], prerequisiteIds: [ "absolute_dominion" ],
@@ -1765,7 +1768,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e118, combatPowerRequired: 1e118,
description: description:
"The region that exists past the end of existence — a space defined not by what it contains but by being the place where containment no longer applies. Your guild navigates it by not needing it to make sense.", "The region that exists past the end of existence — a space defined not by what it contains but by being the place where containment no longer applies. Your guild navigates it by not needing it to make sense.",
durationSeconds: 24 * 60 * 60, durationSeconds: 23_400,
id: "absolute_depth", id: "absolute_depth",
name: "The Absolute Depth", name: "The Absolute Depth",
prerequisiteIds: [ "absolute_beyond" ], prerequisiteIds: [ "absolute_beyond" ],
@@ -1781,7 +1784,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 3e124, combatPowerRequired: 3e124,
description: description:
"The final record: not of what happened, but of the fact that it happened at all. A guild from a mortal realm reached the end of all things and chose to keep going. The universe notes this with something that is not quite surprise.", "The final record: not of what happened, but of the fact that it happened at all. A guild from a mortal realm reached the end of all things and chose to keep going. The universe notes this with something that is not quite surprise.",
durationSeconds: 24 * 60 * 60, durationSeconds: 29_700,
id: "absolute_chronicle", id: "absolute_chronicle",
name: "The Absolute Chronicle", name: "The Absolute Chronicle",
prerequisiteIds: [ "absolute_depth" ], prerequisiteIds: [ "absolute_depth" ],
@@ -1797,7 +1800,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 2e130, combatPowerRequired: 2e130,
description: description:
"A region beyond the final record — where even the concept of record has ended and only raw, unwitnessed existence remains. Your guild walks it anyway, because that is what your guild does.", "A region beyond the final record — where even the concept of record has ended and only raw, unwitnessed existence remains. Your guild walks it anyway, because that is what your guild does.",
durationSeconds: 24 * 60 * 60, durationSeconds: 36_900,
id: "post_absolute_wastes", id: "post_absolute_wastes",
name: "The Post-Absolute Wastes", name: "The Post-Absolute Wastes",
prerequisiteIds: [ "absolute_chronicle" ], prerequisiteIds: [ "absolute_chronicle" ],
@@ -1813,7 +1816,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 1e137, combatPowerRequired: 1e137,
description: description:
"The space between the final end and whatever follows it — a silence so complete that your guild's presence is the loudest thing that has ever existed here. They proceed in hushed awe.", "The space between the final end and whatever follows it — a silence so complete that your guild's presence is the loudest thing that has ever existed here. They proceed in hushed awe.",
durationSeconds: 24 * 60 * 60, durationSeconds: 46_200,
id: "terminal_silence", id: "terminal_silence",
name: "The Terminal Silence", name: "The Terminal Silence",
prerequisiteIds: [ "post_absolute_wastes" ], prerequisiteIds: [ "post_absolute_wastes" ],
@@ -1829,7 +1832,7 @@ export const defaultQuests: Array<Quest> = [
combatPowerRequired: 5e141, combatPowerRequired: 5e141,
description: description:
"The last thing your guild will ever witness before the Absolute One. Not a place. Not a moment. A threshold so final that crossing it means there is only one thing left in existence worth confronting. Your guild crosses it.", "The last thing your guild will ever witness before the Absolute One. Not a place. Not a moment. A threshold so final that crossing it means there is only one thing left in existence worth confronting. Your guild crosses it.",
durationSeconds: 24 * 60 * 60, durationSeconds: 57_600,
id: "final_threshold", id: "final_threshold",
name: "The Final Threshold", name: "The Final Threshold",
prerequisiteIds: [ "terminal_silence" ], prerequisiteIds: [ "terminal_silence" ],
+4 -4
View File
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 13: primordial_chaos // Zone 13: primordial_chaos
{ {
bonus: { type: "click_power", value: 1.2 }, bonus: { type: "click_power", value: 1.22 },
description: description:
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.", "Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
id: "chaos_lens", id: "chaos_lens",
@@ -387,7 +387,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "reality_forge", zoneId: "reality_forge",
}, },
{ {
bonus: { type: "click_power", value: 1.22 }, bonus: { type: "click_power", value: 1.25 },
description: description:
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.", "A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
id: "universe_seed", id: "universe_seed",
@@ -439,7 +439,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
}, },
{ {
bonus: { type: "click_power", value: 1.25 }, bonus: { type: "click_power", value: 1.28 },
description: description:
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.", "The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
id: "first_artefact", id: "first_artefact",
@@ -522,7 +522,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 18: the_absolute // Zone 18: the_absolute
{ {
bonus: { type: "click_power", value: 1.28 }, bonus: { type: "click_power", value: 1.3 },
description: 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.", "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", id: "absolute_focus",
+1 -1
View File
@@ -8,4 +8,4 @@
/** /**
* The current game state schema version. Bump this whenever a breaking change is made to GameState. * The current game state schema version. Bump this whenever a breaking change is made to GameState.
*/ */
export const currentSchemaVersion = 1; export const currentSchemaVersion = 2;
+37
View File
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false, unlocked: false,
}, },
// ── Purchasable essence/crystal sink upgrades ───────────────────────────── // ── Purchasable essence/crystal sink upgrades ─────────────────────────────
{
costCrystals: 3000,
costEssence: 0,
costGold: 0,
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
id: "crystal_pulse",
multiplier: 1.5,
name: "Crystal Pulse",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 20_000,
costEssence: 0,
costGold: 0,
description:
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
id: "crystal_surge",
multiplier: 2,
name: "Crystal Surge",
purchased: false,
target: "global",
unlocked: true,
},
{
costCrystals: 150_000,
costEssence: 0,
costGold: 0,
description: "Your guild's operations are saturated with crystalline power. All income x3.",
id: "crystal_tempest",
multiplier: 3,
name: "Crystal Tempest",
purchased: false,
target: "global",
unlocked: true,
},
{ {
costCrystals: 0, costCrystals: 0,
costEssence: 5_000_000, costEssence: 5_000_000,
+4 -4
View File
@@ -46,7 +46,7 @@ export const defaultZones: Array<Zone> = [
name: "The Shadow Marshes", name: "The Shadow Marshes",
status: "locked", status: "locked",
unlockBossId: "void_titan", unlockBossId: "void_titan",
unlockQuestId: "storm_citadel", unlockQuestId: "frozen_throne",
}, },
{ {
description: description:
@@ -166,7 +166,7 @@ export const defaultZones: Array<Zone> = [
name: "The Cosmic Maelstrom", name: "The Cosmic Maelstrom",
status: "locked", status: "locked",
unlockBossId: "reality_architect", unlockBossId: "reality_architect",
unlockQuestId: "forge_chronicle", unlockQuestId: "creation_archive",
}, },
{ {
description: description:
@@ -176,7 +176,7 @@ export const defaultZones: Array<Zone> = [
name: "The Primeval Sanctum", name: "The Primeval Sanctum",
status: "locked", status: "locked",
unlockBossId: "cosmic_annihilator", unlockBossId: "cosmic_annihilator",
unlockQuestId: "maelstrom_codex", unlockQuestId: "storm_chronicle",
}, },
{ {
description: description:
@@ -186,6 +186,6 @@ export const defaultZones: Array<Zone> = [
name: "The Absolute", name: "The Absolute",
status: "locked", status: "locked",
unlockBossId: "primeval_god", unlockBossId: "primeval_god",
unlockQuestId: "sanctum_chronicle", unlockQuestId: "primeval_archive",
}, },
]; ];
+2
View File
@@ -20,6 +20,7 @@ import { gameRouter } from "./routes/game.js";
import { leaderboardRouter } from "./routes/leaderboards.js"; import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js"; import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js"; import { profileRouter } from "./routes/profile.js";
import { timersRouter } from "./routes/timers.js";
import { transcendenceRouter } from "./routes/transcendence.js"; import { transcendenceRouter } from "./routes/transcendence.js";
import { connectGateway } from "./services/gateway.js"; import { connectGateway } from "./services/gateway.js";
import { logger } from "./services/logger.js"; import { logger } from "./services/logger.js";
@@ -49,6 +50,7 @@ app.route("/transcendence", transcendenceRouter);
app.route("/apotheosis", apotheosisRouter); app.route("/apotheosis", apotheosisRouter);
app.route("/leaderboards", leaderboardRouter); app.route("/leaderboards", leaderboardRouter);
app.route("/profile", profileRouter); app.route("/profile", profileRouter);
app.route("/timers", timersRouter);
app.get("/health", (context) => { app.get("/health", (context) => {
return context.json({ status: "ok" }); return context.json({ status: "ok" });
+4
View File
@@ -35,12 +35,16 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
const payload = verifyToken(token); const payload = verifyToken(token);
context.set("discordId", payload.discordId); context.set("discordId", payload.discordId);
} catch (error) { } catch (error) {
const isExpiredToken
= error instanceof Error && error.message === "Token has expired";
if (!isExpiredToken) {
void logger.error( void logger.error(
"auth_middleware", "auth_middleware",
error instanceof Error error instanceof Error
? error ? error
: new Error(String(error)), : new Error(String(error)),
); );
}
return context.json({ error: "Invalid or expired token" }, 401); return context.json({ error: "Invalid or expired token" }, 401);
} }
+3 -2
View File
@@ -7,6 +7,7 @@
/* eslint-disable stylistic/max-len -- URL cannot be shortened */ /* eslint-disable stylistic/max-len -- URL cannot be shortened */
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */ /* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
import { Hono } from "hono"; import { Hono } from "hono";
import { pendingReleases } from "../data/pendingReleases.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { AboutResponse, GiteaRelease } from "@elysium/types"; import type { AboutResponse, GiteaRelease } from "@elysium/types";
@@ -50,8 +51,8 @@ aboutRouter.get("/", async(context) => {
try { try {
const releases = await fetchReleases(); const releases = await fetchReleases();
const body: AboutResponse = { const body: AboutResponse = {
apiVersion, apiVersion: apiVersion,
releases, releases: [ ...pendingReleases, ...releases ],
}; };
return context.json(body); return context.json(body);
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
+12 -2
View File
@@ -65,10 +65,20 @@ apotheosisRouter.post("/", async(context) => {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 3 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((a) => { const totalAchievementsUnlocked = state.achievements.filter((a) => {
return a.unlockedAt !== null; return a.unlockedAt !== null;
}).length; }).length;
/*
* Achievements now persist across resets, so `totalAchievementsUnlocked`
* already includes everything counted at previous resets — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked - state.player.lifetimeAchievementsUnlocked,
);
const { updatedState, updatedApotheosisData } = buildPostApotheosisState( const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
state, state,
state.player.characterName, state.player.characterName,
@@ -87,7 +97,7 @@ apotheosisRouter.post("/", async(context) => {
lastSavedAt: now, lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAchievementsUnlocked: { increment: achievementsUnlockedDelta },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
+64 -2
View File
@@ -9,6 +9,7 @@
/* eslint-disable complexity -- Boss handler has inherent complexity */ /* eslint-disable complexity -- Boss handler has inherent complexity */
/* eslint-disable stylistic/max-len -- Long lines in combat logic */ /* eslint-disable stylistic/max-len -- Long lines in combat logic */
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */ /* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
import { createHmac } from "node:crypto";
import { import {
computeSetBonuses, computeSetBonuses,
getActiveCompanionBonus, getActiveCompanionBonus,
@@ -18,12 +19,24 @@ import {
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultBosses } from "../data/bosses.js"; import { defaultBosses } from "../data/bosses.js";
import { defaultEquipmentSets } from "../data/equipmentSets.js"; import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { defaultExplorations } from "../data/explorations.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
/**
* Computes the HMAC-SHA256 of data using the given secret.
* @param data - The data string to sign.
* @param secret - The HMAC secret key.
* @returns The hex-encoded HMAC digest.
*/
const computeHmac = (data: string, secret: string): string => {
return createHmac("sha256", secret).update(data).
digest("hex");
};
/** /**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount). * 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. * Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
@@ -31,6 +44,25 @@ import type { HonoEnvironment } from "../types/hono.js";
*/ */
const prestigeCombatBase = 4; const prestigeCombatBase = 4;
/**
* Returns whether a boss's own unlock quest (if any) has been completed.
* @param questId - The quest ID gating the boss, or null/undefined if none.
* @param quests - The player's current quest states.
* @returns True when there's no quest gate, or the gate quest is completed.
*/
const isBossQuestSatisfied = (
questId: string | null | undefined,
quests: GameState["quests"],
): boolean => {
return (
questId === null
|| questId === undefined
|| quests.some((q) => {
return q.id === questId && q.status === "completed";
})
);
};
const bossRouter = new Hono<HonoEnvironment>(); const bossRouter = new Hono<HonoEnvironment>();
bossRouter.use("*", authMiddleware); bossRouter.use("*", authMiddleware);
@@ -163,6 +195,10 @@ bossRouter.post("/challenge", async(context) => {
return context.json({ error: "Prestige requirement not met" }, 403); return context.json({ error: "Prestige requirement not met" }, 403);
} }
if (!isBossQuestSatisfied(boss.unlockQuestId, state.quests)) {
return context.json({ error: "Quest requirement not met" }, 403);
}
const { partyDPS, partyMaxHp } = calculatePartyStats(state); const { partyDPS, partyMaxHp } = calculatePartyStats(state);
if ( if (
@@ -205,9 +241,11 @@ bossRouter.post("/challenge", async(context) => {
boss.status = "defeated"; boss.status = "defeated";
boss.currentHp = 0; boss.currentHp = 0;
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
state.resources.gold = state.resources.gold + boss.goldReward; state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward; state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward; const crystalAward = boss.crystalReward * crystalMult;
state.resources.crystals = state.resources.crystals + crystalAward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward; state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) { for (const upgradeId of boss.upgradeRewards) {
@@ -228,6 +266,7 @@ bossRouter.post("/challenge", async(context) => {
}); });
if (equipment) { if (equipment) {
equipment.owned = true; equipment.owned = true;
equipment.everOwned = true;
const slotAlreadyEquipped = state.equipment.some((item) => { const slotAlreadyEquipped = state.equipment.some((item) => {
return item.type === equipment.type && item.equipped; return item.type === equipment.type && item.equipped;
@@ -249,6 +288,7 @@ bossRouter.post("/challenge", async(context) => {
if ( if (
nextZoneBoss nextZoneBoss
&& nextZoneBoss.prestigeRequirement <= state.prestige.count && nextZoneBoss.prestigeRequirement <= state.prestige.count
&& isBossQuestSatisfied(nextZoneBoss.unlockQuestId, state.quests)
) { ) {
const nextBossInState = state.bosses.find((b) => { const nextBossInState = state.bosses.find((b) => {
return b.id === nextZoneBoss.id; return b.id === nextZoneBoss.id;
@@ -282,6 +322,19 @@ bossRouter.post("/challenge", async(context) => {
continue; continue;
} }
zone.status = "unlocked"; zone.status = "unlocked";
// Unlock exploration areas for the newly unlocked zone
for (const area of state.exploration?.areas ?? []) {
const areaDefinition = defaultExplorations.find((explorationArea) => {
return explorationArea.id === area.id;
});
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
area.status = "available";
}
}
const updatedZoneBosses = state.bosses.filter((b) => { const updatedZoneBosses = state.bosses.filter((b) => {
return b.zoneId === zone.id; return b.zoneId === zone.id;
}); });
@@ -289,6 +342,7 @@ bossRouter.post("/challenge", async(context) => {
if ( if (
firstUpdatedBoss firstUpdatedBoss
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count && firstUpdatedBoss.prestigeRequirement <= state.prestige.count
&& isBossQuestSatisfied(firstUpdatedBoss.unlockQuestId, state.quests)
) { ) {
firstUpdatedBoss.status = "available"; firstUpdatedBoss.status = "available";
} }
@@ -323,7 +377,7 @@ bossRouter.post("/challenge", async(context) => {
rewards = { rewards = {
bountyRunestones: bountyRunestones, bountyRunestones: bountyRunestones,
crystals: boss.crystalReward, crystals: crystalAward,
equipmentIds: boss.equipmentRewards, equipmentIds: boss.equipmentRewards,
essence: boss.essenceReward, essence: boss.essenceReward,
gold: boss.goldReward, gold: boss.goldReward,
@@ -363,6 +417,11 @@ bossRouter.post("/challenge", async(context) => {
where: { discordId }, where: { discordId },
}); });
const secret = process.env.ANTI_CHEAT_SECRET;
const updatedSignature = secret === undefined
? undefined
: computeHmac(JSON.stringify(state), secret);
const { bossId } = body; const { bossId } = body;
void logger.metric("boss_challenge", 1, { bossId, discordId, won }); void logger.metric("boss_challenge", 1, { bossId, discordId, won });
@@ -385,6 +444,9 @@ bossRouter.post("/challenge", async(context) => {
if (casualties !== undefined) { if (casualties !== undefined) {
response.casualties = casualties; response.casualties = casualties;
} }
if (updatedSignature !== undefined) {
response.signature = updatedSignature;
}
return context.json(response); return context.json(response);
} catch (error) { } catch (error) {
+11
View File
@@ -11,6 +11,7 @@ import { Hono } from "hono";
import { defaultRecipes } from "../data/recipes.js"; import { defaultRecipes } from "../data/recipes.js";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { import type {
@@ -138,6 +139,16 @@ craftRouter.post("/", async(context) => {
state.exploration.craftedCombatMultiplier state.exploration.craftedCombatMultiplier
= updatedMultipliers.craftedCombatMultiplier; = updatedMultipliers.craftedCombatMultiplier;
if (state.dailyChallenges !== undefined) {
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
state.dailyChallenges,
"crafting",
1,
);
state.dailyChallenges = updatedChallenges;
state.resources.crystals = state.resources.crystals + crystalsAwarded;
}
await prisma.gameState.update({ await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: Date.now() }, data: { state: state as object, updatedAt: Date.now() },
+19 -1
View File
@@ -166,6 +166,7 @@ interface BossUnlockCheck {
previousBossId: string | undefined; previousBossId: string | undefined;
isFirstInZone: boolean; isFirstInZone: boolean;
prestigeRequirement: number; prestigeRequirement: number;
unlockQuestId: string | null | undefined;
state: GameState; state: GameState;
prestigeCount: number; prestigeCount: number;
} }
@@ -177,6 +178,7 @@ interface BossUnlockCheck {
* @param options.previousBossId - The ID of the previous boss in the zone. * @param options.previousBossId - The ID of the previous boss in the zone.
* @param options.isFirstInZone - Whether this boss is the first in its zone. * @param options.isFirstInZone - Whether this boss is the first in its zone.
* @param options.prestigeRequirement - The prestige level required for this boss. * @param options.prestigeRequirement - The prestige level required for this boss.
* @param options.unlockQuestId - The quest ID that must be completed, if any.
* @param options.state - The current game state. * @param options.state - The current game state.
* @param options.prestigeCount - The player's current prestige count. * @param options.prestigeCount - The player's current prestige count.
* @returns True when the boss should be made available. * @returns True when the boss should be made available.
@@ -186,6 +188,7 @@ const shouldUnlockBoss = ({
previousBossId, previousBossId,
isFirstInZone, isFirstInZone,
prestigeRequirement, prestigeRequirement,
unlockQuestId,
state, state,
prestigeCount, prestigeCount,
}: BossUnlockCheck): boolean => { }: BossUnlockCheck): boolean => {
@@ -198,6 +201,15 @@ const shouldUnlockBoss = ({
if (prestigeRequirement > prestigeCount) { if (prestigeRequirement > prestigeCount) {
return false; return false;
} }
const questSatisfied
= unlockQuestId === null
|| unlockQuestId === undefined
|| state.quests.some((q) => {
return q.id === unlockQuestId && q.status === "completed";
});
if (!questSatisfied) {
return false;
}
if (isFirstInZone) { if (isFirstInZone) {
return true; return true;
} }
@@ -249,6 +261,7 @@ const applyBossUnlocks = (state: GameState): number => {
prestigeRequirement: bossDefinition.prestigeRequirement, prestigeRequirement: bossDefinition.prestigeRequirement,
previousBossId: previousBossDefinition?.id, previousBossId: previousBossDefinition?.id,
state: state, state: state,
unlockQuestId: bossDefinition.unlockQuestId,
}); });
if (unlock) { if (unlock) {
const bossInState = state.bosses.find((b) => { const bossInState = state.bosses.find((b) => {
@@ -407,6 +420,7 @@ const applyEquipmentUnlocks = (state: GameState): number => {
for (const item of state.equipment) { for (const item of state.equipment) {
if (!item.owned && earnedEquipmentIds.has(item.id)) { if (!item.owned && earnedEquipmentIds.has(item.id)) {
item.owned = true; item.owned = true;
item.everOwned = true;
count = count + 1; count = count + 1;
} }
} }
@@ -723,6 +737,8 @@ const patchBossStats = (state: GameState): number => {
} }
const savedRewards = JSON.stringify(savedBoss.equipmentRewards); const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards); const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
const savedUnlockQuestId = savedBoss.unlockQuestId ?? null;
const defaultUnlockQuestId = defaultBoss.unlockQuestId ?? null;
const hasChanged const hasChanged
= savedBoss.name !== defaultBoss.name = savedBoss.name !== defaultBoss.name
|| savedBoss.description !== defaultBoss.description || savedBoss.description !== defaultBoss.description
@@ -734,7 +750,8 @@ const patchBossStats = (state: GameState): number => {
|| savedRewards !== defaultRewards || savedRewards !== defaultRewards
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement || savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|| savedBoss.zoneId !== defaultBoss.zoneId || savedBoss.zoneId !== defaultBoss.zoneId
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones; || savedBoss.bountyRunestones !== defaultBoss.bountyRunestones
|| savedUnlockQuestId !== defaultUnlockQuestId;
savedBoss.name = defaultBoss.name; savedBoss.name = defaultBoss.name;
savedBoss.description = defaultBoss.description; savedBoss.description = defaultBoss.description;
savedBoss.maxHp = defaultBoss.maxHp; savedBoss.maxHp = defaultBoss.maxHp;
@@ -746,6 +763,7 @@ const patchBossStats = (state: GameState): number => {
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement; savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId; savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones; savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
savedBoss.unlockQuestId = defaultUnlockQuestId;
if (hasChanged) { if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
+3 -3
View File
@@ -191,17 +191,17 @@ exploreRouter.post("/start", async(context) => {
} }
const now = Date.now(); const now = Date.now();
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
area.status = "in_progress"; area.status = "in_progress";
area.startedAt = now; area.startedAt = now;
area.endsAt = endsAt;
await prisma.gameState.update({ await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now }, data: { state: state as object, updatedAt: now },
where: { discordId }, where: { discordId },
}); });
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
const response: ExploreStartResponse = { const response: ExploreStartResponse = {
areaId, areaId,
endsAt, endsAt,
+65 -6
View File
@@ -546,6 +546,17 @@ const validateAndSanitize = (
? previous.prestige ? previous.prestige
: incoming.prestige; : incoming.prestige;
/*
* If the DB prestige count is higher than the client's, the client is sending a
* stale pre-prestige save. Discard its upgrades (which have purchased: true) in
* favour of the DB's post-prestige upgrades (purchased: false) so that upgrade
* multipliers cannot persist across prestige via a race-condition auto-save.
*/
const upgrades
= incoming.prestige.count < previous.prestige.count
? previous.upgrades
: incoming.upgrades;
/* /*
* Echoes are only granted server-side via transcendence and can only decrease between * Echoes are only granted server-side via transcendence and can only decrease between
* saves (spent on echo upgrades). Cap at the previous value to block inflation. * saves (spent on echo upgrades). Cap at the previous value to block inflation.
@@ -611,11 +622,17 @@ const validateAndSanitize = (
= Math.min(material.quantity, previousQuantity); = Math.min(material.quantity, previousQuantity);
return { ...material, quantity: cappedQuantity }; return { ...material, quantity: cappedQuantity };
}); });
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter(
(recipeId) => { /*
return previousExploration.craftedRecipeIds.includes(recipeId); * Merge crafted recipe IDs from both states so the list can only ever grow.
}, * A stale auto-save arriving after a craft must not silently un-craft items.
); */
const craftedRecipeIds = [
...new Set([
...previousExploration.craftedRecipeIds,
...incoming.exploration.craftedRecipeIds,
]),
];
explorationSpread = { explorationSpread = {
exploration: { exploration: {
...incoming.exploration, ...incoming.exploration,
@@ -664,6 +681,45 @@ const validateAndSanitize = (
storySpread = { story: previous.story }; storySpread = { story: previous.story };
} }
/*
* Merge daily challenge progress: take the maximum progress for each
* challenge so a stale auto-save arriving after a craft/boss/etc. update
* cannot silently roll back server-side challenge completions.
*/
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 35 -- @preserve */
let dailyChallengesSpread: object = {};
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (incoming.dailyChallenges !== undefined && previous.dailyChallenges !== undefined) {
const previousChallengeMap = new Map(
previous.dailyChallenges.challenges.map((challenge) => {
return [ challenge.id, challenge ];
}),
);
// eslint-disable-next-line stylistic/max-len -- Long chain; splitting would reduce readability
const mergedChallenges = incoming.dailyChallenges.challenges.map((challenge) => {
const serverChallenge = previousChallengeMap.get(challenge.id);
if (serverChallenge === undefined) {
return challenge;
}
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
const bestProgress = Math.max(challenge.progress, serverChallenge.progress);
return {
...challenge,
completed: bestProgress >= challenge.target,
progress: bestProgress,
};
});
dailyChallengesSpread = {
dailyChallenges: {
...incoming.dailyChallenges,
challenges: mergedChallenges,
},
};
} else if (previous.dailyChallenges !== undefined) {
dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges };
}
return { return {
...incoming, ...incoming,
achievements, achievements,
@@ -671,10 +727,12 @@ const validateAndSanitize = (
prestige, prestige,
quests, quests,
resources, resources,
upgrades,
...transcendenceSpread, ...transcendenceSpread,
...apotheosisSpread, ...apotheosisSpread,
...explorationSpread, ...explorationSpread,
...storySpread, ...storySpread,
...dailyChallengesSpread,
}; };
}; };
@@ -1006,7 +1064,8 @@ gameRouter.post("/save", async(context) => {
const companionUnlocks = computeUnlockedCompanionIds({ const companionUnlocks = computeUnlockedCompanionIds({
apotheosisCount: stateToSave.apotheosis?.count ?? 0, apotheosisCount: stateToSave.apotheosis?.count ?? 0,
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0, lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0, // eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability
lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned,
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0, lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
prestigeCount: stateToSave.prestige.count, prestigeCount: stateToSave.prestige.count,
transcendenceCount: stateToSave.transcendence?.count ?? 0, transcendenceCount: stateToSave.transcendence?.count ?? 0,
+25 -7
View File
@@ -15,6 +15,7 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import { import {
buildPostPrestigeState, buildPostPrestigeState,
calculatePrestigeThreshold,
computeRunestoneMultipliers, computeRunestoneMultipliers,
isEligibleForPrestige, isEligibleForPrestige,
} from "../services/prestige.js"; } from "../services/prestige.js";
@@ -40,10 +41,15 @@ prestigeRouter.post("/", async(context) => {
const state = record.state as unknown as GameState; const state = record.state as unknown as GameState;
if (!isEligibleForPrestige(state)) { if (!isEligibleForPrestige(state)) {
const thresholdMultiplier
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
const required = calculatePrestigeThreshold(
state.prestige.count,
thresholdMultiplier,
);
return context.json( return context.json(
{ {
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened error: `Not eligible for prestige — collect ${required.toLocaleString()} total gold first`,
error: "Not eligible for prestige — collect 1,000,000 total gold first",
}, },
400, 400,
); );
@@ -96,10 +102,22 @@ prestigeRouter.post("/", async(context) => {
} }
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 5 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => { const totalAchievementsUnlocked = state.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null; return achievement.unlockedAt !== null;
}).length; },
).length;
/*
* Achievements now persist across resets, so `totalAchievementsUnlocked`
* already includes everything counted at previous resets — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked - state.player.lifetimeAchievementsUnlocked,
);
const now = Date.now(); const now = Date.now();
const { updatedAt } = record; const { updatedAt } = record;
@@ -125,7 +143,7 @@ prestigeRouter.post("/", async(context) => {
lastSavedAt: now, lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAchievementsUnlocked: { increment: achievementsUnlockedDelta },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
@@ -158,7 +176,7 @@ prestigeRouter.post("/", async(context) => {
const announcementsEnabled const announcementsEnabled
= playerSettings?.enablePrestigeAnnouncements !== false; = playerSettings?.enablePrestigeAnnouncements !== false;
if (announcementsEnabled) { if (announcementsEnabled && prestigeCount % 5 === 0) {
void postMilestoneWebhook(discordId, "prestige", { void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
+1 -1
View File
@@ -44,7 +44,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
const numberFormat = validNumberFormats.has(parsedNumberFormat) const numberFormat = validNumberFormats.has(parsedNumberFormat)
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
? (parsedNumberFormat as ProfileSettings["numberFormat"]) ? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix"; : DEFAULT_PROFILE_SETTINGS.numberFormat;
return { return {
enableNotifications: rawObject.enableNotifications === true, enableNotifications: rawObject.enableNotifications === true,
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false, enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
+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 };
+16 -4
View File
@@ -68,10 +68,22 @@ transcendenceRouter.post("/", async(context) => {
} }
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */ /* v8 ignore next 5 -- @preserve */
const runAchievementsUnlocked = state.achievements.filter((achievement) => { const totalAchievementsUnlocked = state.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null; return achievement.unlockedAt !== null;
}).length; },
).length;
/*
* Achievements now persist across resets, so `totalAchievementsUnlocked`
* already includes everything counted at previous resets — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked - state.player.lifetimeAchievementsUnlocked,
);
const now = Date.now(); const now = Date.now();
await prisma.gameState.update({ await prisma.gameState.update({
@@ -86,7 +98,7 @@ transcendenceRouter.post("/", async(context) => {
lastSavedAt: now, lastSavedAt: now,
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked }, lifetimeAchievementsUnlocked: { increment: achievementsUnlockedDelta },
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited }, lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
+91 -1
View File
@@ -4,10 +4,16 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- buildPostApotheosisState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { import {
defaultTranscendenceUpgrades, defaultTranscendenceUpgrades,
} from "../data/transcendenceUpgrades.js"; } from "../data/transcendenceUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { ApotheosisData, GameState } from "@elysium/types"; import type { ApotheosisData, GameState } from "@elysium/types";
/** /**
@@ -34,7 +40,8 @@ const isEligibleForApotheosis = (state: GameState): boolean => {
/** /**
* Builds the updated game state after Apotheosis — the ultimate nuclear reset. * Builds the updated game state after Apotheosis — the ultimate nuclear reset.
* Wipes absolutely everything including prestige and transcendence. * Wipes absolutely everything including prestige and transcendence.
* Only codex lore entries and the apotheosis count itself are preserved. * Only codex lore entries, story progress, the active companion selection, and
* the apotheosis count itself are preserved.
* @param currentState - The current game state before apotheosis. * @param currentState - The current game state before apotheosis.
* @param characterName - The character name to carry over. * @param characterName - The character name to carry over.
* @returns The updated game state and apotheosis data. * @returns The updated game state and apotheosis data.
@@ -47,9 +54,88 @@ const buildPostApotheosisState = (
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount }; const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the apotheosis reset.
*/
const bossesWithHistory = preserveBossHistory(
freshState.bosses,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the apotheosis reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the apotheosis reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after apotheosis.
*/
const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
const runQuestsCompleted = currentState.quests.filter((quest) => {
return quest.status === "completed";
}).length;
const runAdventurersRecruited = currentState.adventurers.reduce(
(sum, adventurer) => {
return sum + adventurer.count;
},
0,
);
const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const updatedState: GameState = { const updatedState: GameState = {
...freshState, ...freshState,
// Achievements are permanent — earned achievements survive apotheosis
achievements: currentState.achievements,
bosses: bossesWithHistory,
equipment: equipmentWithHistory,
lastTickAt: Date.now(), lastTickAt: Date.now(),
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ achievementsUnlockedDelta,
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,
},
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
// Codex lore persists through all resets — players keep their discovered entries // Codex lore persists through all resets — players keep their discovered entries
...currentState.codex ...currentState.codex
? { codex: currentState.codex } ? { codex: currentState.codex }
@@ -60,6 +146,10 @@ const buildPostApotheosisState = (
...currentState.story ...currentState.story
? { story: currentState.story } ? { story: currentState.story }
: {}, : {},
// Active companion selection persists across apotheosis
...currentState.companions
? { companions: currentState.companions }
: {},
}; };
return { updatedApotheosisData, updatedState }; return { updatedApotheosisData, updatedState };
+16 -3
View File
@@ -71,6 +71,10 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
return result; return result;
}; };
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
"crafting",
];
const progressionChallengeTypes: Array<DailyChallengeType> = [ const progressionChallengeTypes: Array<DailyChallengeType> = [
"bossesDefeated", "bossesDefeated",
"questsCompleted", "questsCompleted",
@@ -79,8 +83,10 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
/** /**
* Generates 3 daily challenges for the given date string, deterministically. * Generates 3 daily challenges for the given date string, deterministically.
* Always includes a "clicks" challenge (always completable regardless of * Always includes a "clicks" challenge and a "crafting" challenge (both
* progression), then picks 2 more from the remaining types. * completable regardless of zone/boss progression), then picks 1 more from
* the progression types. This ensures stuck players always have 2 completable
* challenges available.
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for. * @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
* @returns An array of 3 DailyChallenge objects. * @returns An array of 3 DailyChallenge objects.
*/ */
@@ -90,7 +96,14 @@ const generateDailyChallenges = (
const seed = dateSeed(dateString); const seed = dateSeed(dateString);
const selectedTypes: Array<DailyChallengeType> = [ const selectedTypes: Array<DailyChallengeType> = [
"clicks", "clicks",
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2), ...shuffleWithSeed(
[ ...nonProgressionChallengeTypes ],
seed + 500,
).slice(0, 1),
...shuffleWithSeed(
[ ...progressionChallengeTypes ],
seed,
).slice(0, 1),
]; ];
return selectedTypes.map((type, index) => { return selectedTypes.map((type, index) => {
+64 -9
View File
@@ -8,6 +8,11 @@
/* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */ /* eslint-disable complexity -- buildPostPrestigeState has many optional fields that each add a branch point */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js"; import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { import type {
GameState, GameState,
PrestigeData, PrestigeData,
@@ -15,21 +20,21 @@ import type {
} from "@elysium/types"; } from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000; const basePrestigeGoldThreshold = 1_000_000;
const runestonesPerPrestigeLevel = 15; const runestonesPerPrestigeLevel = 20;
const milestoneInterval = 5; const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25; const milestoneRunestonesPerInterval = 25;
/* /*
* Hard cap on the base runestone yield (before multipliers) to prevent * Hard cap on the base runestone yield (before multipliers) to prevent
* extreme AFK accumulation from producing game-breaking runestone counts. * extreme AFK accumulation from producing game-breaking runestone counts.
* With all upgrades (5.625× max) this caps out at ~1,125 per prestige. * With all upgrades (5.625× max) this caps out at ~2,250 per prestige.
*/ */
const maxBaseRunestones = 200; const maxBaseRunestones = 400;
/** /**
* Calculates the gold threshold required for the next prestige. * Calculates the gold threshold required for the next prestige.
* Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 810 * Formula: BASE * (count + 1)^2.5steeper growth to keep late prestiges
* then gets easier as the production multiplier overtakes it. * meaningful even as the production multiplier scales.
* @param prestigeCount - The current number of prestiges completed. * @param prestigeCount - The current number of prestiges completed.
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold. * @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
* @returns The gold amount required to prestige. * @returns The gold amount required to prestige.
@@ -40,7 +45,7 @@ const calculatePrestigeThreshold = (
): number => { ): number => {
return ( return (
basePrestigeGoldThreshold basePrestigeGoldThreshold
* Math.pow(prestigeCount + 1, 2) * Math.pow(prestigeCount + 1, 2.5)
* thresholdMultiplier * thresholdMultiplier
); );
}; };
@@ -189,6 +194,7 @@ const buildPostPrestigeState = (
} => { } => {
const { const {
autoPrestigeEnabled, autoPrestigeEnabled,
autoPrestigeMaxRunestonesOnly,
count: currentPrestigeCount, count: currentPrestigeCount,
purchasedUpgradeIds, purchasedUpgradeIds,
runestones: currentRunestones, runestones: currentRunestones,
@@ -215,6 +221,9 @@ const buildPostPrestigeState = (
...autoPrestigeEnabled === undefined ...autoPrestigeEnabled === undefined
? {} ? {}
: { autoPrestigeEnabled }, : { autoPrestigeEnabled },
...autoPrestigeMaxRunestonesOnly === undefined
? {}
: { autoPrestigeMaxRunestonesOnly },
}; };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
@@ -236,6 +245,33 @@ const buildPostPrestigeState = (
return freshBoss; return freshBoss;
}); });
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the prestige reset.
*/
const bossesWithHistory = preserveBossHistory(
bossesWithBountyClaimed,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the prestige reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the prestige reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
// Compute current-run contributions to accumulate into lifetime totals // Compute current-run contributions to accumulate into lifetime totals
const runBossesDefeated = currentState.bosses.filter((boss) => { const runBossesDefeated = currentState.bosses.filter((boss) => {
return boss.status === "defeated"; return boss.status === "defeated";
@@ -247,12 +283,23 @@ const buildPostPrestigeState = (
for (const adventurer of currentState.adventurers) { for (const adventurer of currentState.adventurers) {
runAdventurersRecruited = runAdventurersRecruited + adventurer.count; runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
} }
const runAchievementsUnlocked = currentState.achievements.filter( const totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => { (achievement) => {
return achievement.unlockedAt !== null; return achievement.unlockedAt !== null;
}, },
).length; ).length;
/*
* Achievements persist across prestiges, so `totalAchievementsUnlocked`
* already includes everything counted at previous prestiges — only the
* delta since then should be folded into the lifetime stat.
*/
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const prestigeState: GameState = { const prestigeState: GameState = {
...freshState, ...freshState,
@@ -268,7 +315,9 @@ const buildPostPrestigeState = (
autoQuest: currentState.autoQuest ?? false, autoQuest: currentState.autoQuest ?? false,
// Boss statuses reset for gameplay, but first-kill claimed flag is preserved // Boss statuses reset for gameplay, but first-kill claimed flag is preserved
bosses: bossesWithBountyClaimed, bosses: bossesWithHistory,
// Ownership resets for gameplay, but ever-owned history is preserved
equipment: equipmentWithHistory,
lastTickAt: Date.now(), lastTickAt: Date.now(),
/* /*
@@ -279,7 +328,7 @@ const buildPostPrestigeState = (
...freshState.player, ...freshState.player,
lifetimeAchievementsUnlocked: lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked freshState.player.lifetimeAchievementsUnlocked
+ runAchievementsUnlocked, + achievementsUnlockedDelta,
lifetimeAdventurersRecruited: lifetimeAdventurersRecruited:
freshState.player.lifetimeAdventurersRecruited freshState.player.lifetimeAdventurersRecruited
+ runAdventurersRecruited, + runAdventurersRecruited,
@@ -294,6 +343,12 @@ const buildPostPrestigeState = (
freshState.player.lifetimeQuestsCompleted + runQuestsCompleted, freshState.player.lifetimeQuestsCompleted + runQuestsCompleted,
}, },
prestige: prestigeData, prestige: prestigeData,
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
// Active companion selection persists across prestige
...currentState.companions === undefined
? {}
: { companions: currentState.companions },
// Codex lore persists across prestiges — players keep their discovered entries // Codex lore persists across prestiges — players keep their discovered entries
...currentState.codex === undefined ...currentState.codex === undefined
? {} ? {}
+89
View File
@@ -0,0 +1,89 @@
/**
* @file Shared helpers for merging permanent per-item history across
* prestige/transcendence/apotheosis resets.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Boss, Equipment, Quest } from "@elysium/types";
/**
* Carries forward each equipment item's ownership history across a reset.
* Ownership itself (and any stat bonuses it grants) still resets to the fresh
* defaults — only the "have I ever owned this" flag used by collection
* achievements survives, mirroring how boss bounty-claimed flags persist.
* @param freshEquipment - The freshly-reset equipment array.
* @param currentEquipment - The equipment array from the state being reset.
* @returns The fresh equipment array with `everOwned` merged in per item id.
*/
const preserveEquipmentHistory = (
freshEquipment: Array<Equipment>,
currentEquipment: Array<Equipment>,
): Array<Equipment> => {
return freshEquipment.map((freshItem) => {
const currentItem = currentEquipment.find((item) => {
return item.id === freshItem.id;
});
const everOwned
= currentItem?.everOwned === true || currentItem?.owned === true;
return everOwned
? { ...freshItem, everOwned: true }
: freshItem;
});
};
/**
* Carries forward each quest's completion history across a reset. Quest
* status itself still resets to the fresh defaults — only the "have I ever
* completed this" flag used by the all-quests achievement survives, so
* repeatedly re-completing the same subset of quests across resets can't
* be mistaken for having completed every distinct quest.
* @param freshQuests - The freshly-reset quests array.
* @param currentQuests - The quests array from the state being reset.
* @returns The fresh quests array with `everCompleted` merged in per quest id.
*/
const preserveQuestHistory = (
freshQuests: Array<Quest>,
currentQuests: Array<Quest>,
): Array<Quest> => {
return freshQuests.map((freshQuest) => {
const currentQuest = currentQuests.find((quest) => {
return quest.id === freshQuest.id;
});
const everCompleted
= currentQuest?.everCompleted === true
|| currentQuest?.status === "completed";
return everCompleted
? { ...freshQuest, everCompleted: true }
: freshQuest;
});
};
/**
* Carries forward each boss's defeat history across a reset. Boss status
* itself still resets to the fresh defaults — only the "have I ever
* defeated this" flag used by the all-bosses achievement survives, so
* repeatedly re-defeating the same subset of bosses across resets can't
* be mistaken for having defeated every distinct boss.
* @param freshBosses - The freshly-reset bosses array.
* @param currentBosses - The bosses array from the state being reset.
* @returns The fresh bosses array with `everDefeated` merged in per boss id.
*/
const preserveBossHistory = (
freshBosses: Array<Boss>,
currentBosses: Array<Boss>,
): Array<Boss> => {
return freshBosses.map((freshBoss) => {
const currentBoss = currentBosses.find((boss) => {
return boss.id === freshBoss.id;
});
const everDefeated
= currentBoss?.everDefeated === true
|| currentBoss?.status === "defeated";
return everDefeated
? { ...freshBoss, everDefeated: true }
: freshBoss;
});
};
export { preserveBossHistory, preserveEquipmentHistory, preserveQuestHistory };
+87
View File
@@ -4,8 +4,14 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- buildPostTranscendenceState requires constructing a large composite state object */
import { initialGameState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js"; import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "./resetHelpers.js";
import type { import type {
GameState, GameState,
TranscendenceData, TranscendenceData,
@@ -106,6 +112,8 @@ const buildPermanentSpreads = (
transcendenceData: TranscendenceData, transcendenceData: TranscendenceData,
): Partial<GameState> => { ): Partial<GameState> => {
return { return {
// Achievements are permanent — earned achievements survive all transcendences
achievements: currentState.achievements,
transcendence: transcendenceData, transcendence: transcendenceData,
...currentState.codex === undefined ...currentState.codex === undefined
? {} ? {}
@@ -116,6 +124,10 @@ const buildPermanentSpreads = (
...currentState.story === undefined ...currentState.story === undefined
? {} ? {}
: { story: currentState.story }, : { story: currentState.story },
// Active companion selection persists across transcendence
...currentState.companions === undefined
? {}
: { companions: currentState.companions },
}; };
}; };
@@ -153,9 +165,84 @@ const buildPostTranscendenceState = (
}; };
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
/*
* Preserve boss defeat history so the all-bosses achievement (which
* requires defeating every distinct boss) survives the transcendence reset.
*/
const bossesWithHistory = preserveBossHistory(
freshState.bosses,
currentState.bosses,
);
/*
* Preserve equipment ownership history so collection achievements
* (which require lifetime ownership) survive the transcendence reset.
*/
const equipmentWithHistory = preserveEquipmentHistory(
freshState.equipment,
currentState.equipment,
);
/*
* Preserve quest completion history so the all-quests achievement (which
* requires completing every distinct quest) survives the transcendence reset.
*/
const questsWithHistory = preserveQuestHistory(
freshState.quests,
currentState.quests,
);
/*
* Fold current-run totals into lifetime stats so the GameState reflects
* the true all-time values immediately after transcendence.
*/
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 totalAchievementsUnlocked = currentState.achievements.filter(
(achievement) => {
return achievement.unlockedAt !== null;
},
).length;
const achievementsUnlockedDelta = Math.max(
0,
totalAchievementsUnlocked
- currentState.player.lifetimeAchievementsUnlocked,
);
const transcendenceState: GameState = { const transcendenceState: GameState = {
...freshState, ...freshState,
bosses: bossesWithHistory,
equipment: equipmentWithHistory,
lastTickAt: Date.now(), lastTickAt: Date.now(),
player: {
...freshState.player,
lifetimeAchievementsUnlocked:
freshState.player.lifetimeAchievementsUnlocked
+ achievementsUnlockedDelta,
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,
},
// Statuses reset for gameplay, but ever-completed history is preserved
quests: questsWithHistory,
...buildPermanentSpreads(currentState, transcendenceData), ...buildPermanentSpreads(currentState, transcendenceData),
}; };
+13 -1
View File
@@ -107,6 +107,17 @@ const milestoneVerbs: Record<MilestoneType, string> = {
transcendence: "transcended", transcendence: "transcended",
}; };
type MilestoneSuffixBuilder = (counts: MilestoneCounts)=> string;
const milestoneSuffixes: Partial<
Record<MilestoneType, MilestoneSuffixBuilder>
> = {
transcendence: (counts) => {
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts.prestige is a number, intentionally stringified */
return ` at ${counts.prestige} prestige`;
},
};
/** /**
* Posts a milestone announcement to the configured Discord webhook. * Posts a milestone announcement to the configured Discord webhook.
* Fails silently so webhook errors do not affect the game action. * Fails silently so webhook errors do not affect the game action.
@@ -126,8 +137,9 @@ const postMilestoneWebhook = async(
} }
const verb = milestoneVerbs[milestone]; const verb = milestoneVerbs[milestone];
const suffix = milestoneSuffixes[milestone]?.(counts) ?? "";
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */ /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`; const content = `<@${discordId}> has ${verb}${suffix}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
try { try {
await fetch(webhookUrl, { await fetch(webhookUrl, {
+36 -5
View File
@@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({
verifyToken: vi.fn(), verifyToken: vi.fn(),
})); }));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
},
}));
describe("authMiddleware", () => { describe("authMiddleware", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks();
}); });
const makeApp = async () => { const makeApp = async () => {
const { authMiddleware } = await import("../../src/middleware/auth.js"); const { authMiddleware } = await import("../../src/middleware/auth.js");
const { verifyToken } = await import("../../src/services/jwt.js"); const { verifyToken } = await import("../../src/services/jwt.js");
const { logger } = await import("../../src/services/logger.js");
const app = new Hono<{ Variables: { discordId: string } }>(); const app = new Hono<{ Variables: { discordId: string } }>();
app.use("*", authMiddleware); app.use("*", authMiddleware);
app.get("/test", (c) => c.json({ discordId: c.get("discordId") })); app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
return { app, verifyToken }; return { app, logger, verifyToken };
}; };
it("returns 401 when Authorization header is missing", async () => { it("returns 401 when Authorization header is missing", async () => {
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
expect(body.discordId).toBe("user_123"); expect(body.discordId).toBe("user_123");
}); });
it("returns 401 when verifyToken throws", async () => { it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
const { app, verifyToken } = await makeApp(); const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => { vi.mocked(verifyToken).mockImplementationOnce(() => {
throw new Error("Invalid token"); throw new Error("Invalid token");
}); });
@@ -54,10 +62,15 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_token" }, headers: { Authorization: "Bearer bad_token" },
})); }));
expect(res.status).toBe(401); expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
}); });
it("returns 401 when verifyToken throws a non-Error value", async () => { it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
const { app, verifyToken } = await makeApp(); const { app, logger, verifyToken } = await makeApp();
vi.mocked(verifyToken).mockImplementationOnce(() => { vi.mocked(verifyToken).mockImplementationOnce(() => {
throw "raw string error"; throw "raw string error";
}); });
@@ -65,5 +78,23 @@ describe("authMiddleware", () => {
headers: { Authorization: "Bearer bad_token" }, headers: { Authorization: "Bearer bad_token" },
})); }));
expect(res.status).toBe(401); expect(res.status).toBe(401);
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"auth_middleware",
expect.any(Error),
);
});
it("returns 401 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();
}); });
}); });
+59 -35
View File
@@ -1,6 +1,21 @@
/**
* @file Tests for the about route, covering release fetching, caching, and the
* fallback to pending releases when the upstream request fails.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ /* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono"; import { Hono } from "hono";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pendingReleases } from "../../src/data/pendingReleases.js";
const makeApp = async(): Promise<Hono> => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
describe("about route", () => { describe("about route", () => {
const mockFetch = vi.fn(); const mockFetch = vi.fn();
@@ -15,59 +30,68 @@ describe("about route", () => {
mockFetch.mockReset(); mockFetch.mockReset();
}); });
const makeApp = async () => { it("returns pending and fetched releases on a successful fetch", async() => {
const { aboutRouter } = await import("../../src/routes/about.js"); expect.assertions(2);
const app = new Hono(); const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
app.route("/about", aboutRouter); mockFetch.mockResolvedValueOnce({ json: () => {
return app; return Promise.resolve(releases);
}; }, ok: true });
it("returns releases from a successful fetch", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { releases: unknown[] }; const body: { releases: Array<unknown> } = await response.json();
expect(body.releases).toEqual(releases); expect(body.releases, "should merge pending and fetched releases").
toStrictEqual([ ...pendingReleases, ...releases ]);
}); });
it("returns empty releases when fetch is not ok", async () => { it("returns only pending releases when fetch is not ok", async() => {
expect.assertions(2);
mockFetch.mockResolvedValueOnce({ ok: false }); mockFetch.mockResolvedValueOnce({ ok: false });
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { releases: unknown[] }; const body: { releases: Array<unknown> } = await response.json();
expect(body.releases).toEqual([]); expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
}); });
it("returns empty releases when fetch throws", async () => { it("returns only pending releases when fetch throws", async() => {
expect.assertions(2);
mockFetch.mockRejectedValueOnce(new Error("Network error")); mockFetch.mockRejectedValueOnce(new Error("Network error"));
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { releases: unknown[] }; const body: { releases: Array<unknown> } = await response.json();
expect(body.releases).toEqual([]); expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
}); });
it("returns cached releases on second call within TTL", async() => { it("returns cached releases on second call within TTL", async() => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }]; expect.assertions(2);
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) }); const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve(releases);
}, ok: true });
const app = await makeApp(); const app = await makeApp();
// First call populates cache // First call populates cache
await app.fetch(new Request("http://localhost/about")); await app.fetch(new Request("http://localhost/about"));
// Second call should use cache, not call fetch again // Second call should use cache, not call fetch again
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch, "should not re-fetch within the TTL").
toHaveBeenCalledTimes(1);
}); });
it("includes apiVersion in response", async() => { it("includes apiVersion in response", async() => {
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); expect.assertions(2);
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve([]);
}, ok: true });
const app = await makeApp(); const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about")); const response = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200); expect(response.status, "should respond with 200").toBe(200);
const body = await res.json() as { apiVersion: string }; const body: { apiVersion: string } = await response.json();
expect(typeof body.apiVersion).toBe("string"); expect(typeof body.apiVersion, "should expose apiVersion as a string").
toBe("string");
}); });
}); });
+204
View File
@@ -121,6 +121,133 @@ describe("boss route", () => {
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
it("returns 403 when boss's own unlock quest is not completed", async () => {
const state = makeState({
bosses: [makeBoss({ unlockQuestId: "gate_quest" })] as GameState["bosses"],
quests: [{ id: "gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(403);
});
it("allows challenge when boss's own unlock quest is completed", async () => {
const state = makeState({
bosses: [makeBoss({
unlockQuestId: "gate_quest",
currentHp: 100,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
quests: [{ id: "gate_quest", status: "completed" }] as GameState["quests"],
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 };
expect(body.won).toBe(true);
});
it("does not unlock next zone boss when its own unlock quest is not completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "active" }] as GameState["quests"],
});
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);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("locked");
});
it("unlocks next zone boss when its own unlock quest is completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "completed" }] as GameState["quests"],
});
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);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("available");
});
it("does not unlock first zone boss when its own unlock quest is not completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "active" }] as GameState["quests"],
});
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);
const savedZone = savedState?.zones.find((z) => z.id === "new_zone");
expect(savedZone?.status).toBe("unlocked");
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("locked");
});
it("unlocks first zone boss when its own unlock quest is completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "completed" }] as GameState["quests"],
});
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);
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("available");
});
it("returns 400 when party has no adventurers", async () => { it("returns 400 when party has no adventurers", async () => {
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] }); const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
@@ -294,6 +421,83 @@ describe("boss route", () => {
expect(body.won).toBe(true); expect(body.won).toBe(true);
}); });
it("handles zone unlock gracefully when exploration state is undefined", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [],
exploration: undefined,
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks exploration areas when a zone is unlocked on boss defeat", async () => {
const state = makeState({
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [],
exploration: {
areas: [{ id: "test_area", status: "locked" as const }],
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
// Exploration area should remain locked — no matching defaultExploration for "test_area"
const area = savedState?.exploration?.areas.find((a) => a.id === "test_area");
expect(area?.status).toBe("locked");
});
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("omits signature in response when ANTI_CHEAT_SECRET is not set", async () => {
delete process.env.ANTI_CHEAT_SECRET;
const state = makeState({
bosses: [makeBoss()] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeUndefined();
});
it("returns 500 when the database throws", async () => { it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_boss" }); const res = await challenge({ bossId: "test_boss" });
+28
View File
@@ -144,6 +144,34 @@ describe("craft route", () => {
expect(body.bonusType).toBe("gold_income"); expect(body.bonusType).toBe("gold_income");
}); });
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
const state = makeState({
dailyChallenges: {
date: "2024-01-15",
challenges: [
{
completed: false,
id: "2024-01-15_crafting",
label: "Craft 1 recipe",
progress: 0,
rewardCrystals: 75,
target: 1,
type: "crafting",
},
],
},
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: GameState };
};
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
expect(updateArg.data.state.resources.crystals).toBe(75);
});
it("returns 500 when the database throws", async () => { it("returns 500 when the database throws", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID }); const res = await post({ recipeId: TEST_RECIPE_ID });
+44 -2
View File
@@ -312,6 +312,34 @@ describe("debug route", () => {
expect(body.bossesUnlocked).toBe(1); expect(body.bossesUnlocked).toBe(1);
}); });
it("does not unlock boss when its own unlock quest is not completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "active" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("unlocks boss when its own unlock quest is completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "completed" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(1);
});
it("returns explorationUnlocked=0 when exploration is undefined", async () => { it("returns explorationUnlocked=0 when exploration is undefined", async () => {
const state = makeState({ const state = makeState({
exploration: undefined as unknown as GameState["exploration"], exploration: undefined as unknown as GameState["exploration"],
@@ -597,7 +625,7 @@ describe("debug route", () => {
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => { it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({ const state = makeState({
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 100, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"], adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 65, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
}); });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
@@ -830,7 +858,7 @@ describe("debug route", () => {
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => { it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({ const state = makeState({
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"], quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 150, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
}); });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
@@ -905,6 +933,20 @@ describe("debug route", () => {
expect(body.bossesPatched).toBe(1); expect(body.bossesPatched).toBe(1);
}); });
it("patches boss stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 1, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head.", unlockQuestId: "some_ghost_quest" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number; state: GameState };
expect(body.bossesPatched).toBe(1);
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.unlockQuestId).toBeNull();
});
it("skips boss stat patching for bosses not in defaults", async () => { it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({ const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"], bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
+16
View File
@@ -246,6 +246,22 @@ describe("explore route", () => {
expect(body.endsAt).toBeGreaterThan(Date.now()); expect(body.endsAt).toBeGreaterThan(Date.now());
}); });
it("persists endsAt to the DB state on exploration start", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { areaId: string; endsAt: number };
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]?.[0];
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test accesses nested mock data */
const savedState = (updateCall?.data as { state?: { exploration?: { areas?: Array<{ id: string; endsAt?: number }> } } }).state;
const savedArea = savedState?.exploration?.areas?.find((a) => {
return a.id === TEST_AREA_ID;
});
expect(savedArea?.endsAt).toBe(body.endsAt);
});
it("backfills exploration state for old saves without exploration", async () => { it("backfills exploration state for old saves without exploration", async () => {
const state = makeState({ exploration: undefined }); const state = makeState({ exploration: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
+23 -1
View File
@@ -24,7 +24,7 @@ vi.mock("../../src/services/discord.js", () => ({
})); }));
const DISCORD_ID = "test_discord_id"; const DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1; const CURRENT_SCHEMA_VERSION = 2;
const makeState = (overrides: Partial<GameState> = {}): GameState => ({ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
@@ -477,6 +477,28 @@ describe("game route", () => {
expect(body.savedAt).toBeGreaterThan(0); expect(body.savedAt).toBeGreaterThan(0);
}); });
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
const prevUpgrades = [
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
] as GameState["upgrades"];
const prevState = makeState({
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
upgrades: prevUpgrades,
});
const incomingState = makeState({
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
upgrades: [
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
});
it("validates companion when active companion is legitimately unlocked", async () => { it("validates companion when active companion is legitimately unlocked", async () => {
const prevState = makeState(); const prevState = makeState();
const stateWithCompanion = makeState({ const stateWithCompanion = makeState({
+56
View File
@@ -81,6 +81,16 @@ describe("prestige route", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns runestones on successful prestige", async () => { it("returns runestones on successful prestige", async () => {
const state = makeState(); const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
@@ -140,6 +150,52 @@ describe("prestige route", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything()); expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
}); });
it("skips webhook when the new prestige count is not a multiple of 5", 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: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
it("posts webhook when the new prestige count is a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
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: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(5);
expect(postMilestoneWebhook).toHaveBeenCalledWith(DISCORD_ID, "prestige", expect.objectContaining({ prestige: 5 }));
});
it("skips webhook on a multiple of 5 when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
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.toHaveBeenCalled();
});
}); });
describe("POST /buy-upgrade", () => { describe("POST /buy-upgrade", () => {
+2 -2
View File
@@ -157,7 +157,7 @@ describe("profile route", () => {
expect(body.profileSettings.showTotalGold).toBe(false); expect(body.profileSettings.showTotalGold).toBe(false);
}); });
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => { it("falls back to scientific numberFormat in GET when stored profileSettings has invalid format", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never, makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
); );
@@ -165,7 +165,7 @@ describe("profile route", () => {
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`)); const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string } }; const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix"); expect(body.profileSettings.numberFormat).toBe("scientific");
}); });
it("maps known and unknown unlocked title IDs to name and fallback id", async () => { it("maps known and unknown unlocked title IDs to name and fallback id", async () => {
+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);
});
});
+87 -1
View File
@@ -10,9 +10,26 @@ import type { GameState } from "@elysium/types";
const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id); const ALL_UPGRADE_IDS = defaultTranscendenceUpgrades.map((u) => u.id);
const makePlayer = (overrides: Partial<GameState["player"]> = {}) => ({
avatar: null,
characterName: "T",
discordId: "t",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
totalClicks: 0,
totalGoldEarned: 0,
username: "u",
...overrides,
});
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({ ({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, player: makePlayer(),
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [], adventurers: [],
upgrades: [], upgrades: [],
@@ -98,6 +115,19 @@ describe("buildPostApotheosisState", () => {
expect(updatedState.story).toEqual(story); expect(updatedState.story).toEqual(story);
}); });
it("persists active companion selection across apotheosis", () => {
const companions = { unlockedCompanionIds: ["lyra", "pria"], activeCompanionId: "pria" };
const state = makeMinimalState({ companions });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("wipes prestige data", () => { it("wipes prestige data", () => {
const state = makeMinimalState({ const state = makeMinimalState({
prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] }, prestige: { count: 10, runestones: 1000, productionMultiplier: 3, purchasedUpgradeIds: [] },
@@ -112,4 +142,60 @@ describe("buildPostApotheosisState", () => {
const { updatedState } = buildPostApotheosisState(state, "T"); const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.apotheosis?.count).toBe(1); expect(updatedState.apotheosis?.count).toBe(1);
}); });
it("accumulates current-run gold and clicks into lifetime totals", () => {
const state = makeMinimalState({
player: makePlayer({ totalGoldEarned: 4_000_000, lifetimeGoldEarned: 1_000_000, totalClicks: 500 }),
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeGoldEarned).toBe(5_000_000);
expect(updatedState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses, completed quests, and recruited adventurers into lifetime totals", () => {
const boss = { id: "boss_1", status: "defeated" as const };
const quest = { id: "q_1", status: "completed" as const };
const adventurer = { id: "adv_1", count: 5 };
const state = makeMinimalState({
bosses: [ boss ] as GameState["bosses"],
quests: [ quest ] as GameState["quests"],
adventurers: [ adventurer ] as GameState["adventurers"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeBossesDefeated).toBe(1);
expect(updatedState.player.lifetimeQuestsCompleted).toBe(1);
expect(updatedState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements and accumulates newly-unlocked ones into the lifetime total", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.achievements).toEqual([ achievement ]);
expect(updatedState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("does not re-count achievements already folded into the lifetime total by a previous reset", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
player: makePlayer({ lifetimeAchievementsUnlocked: 1 }),
});
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("preserves equipment ownership history across apotheosis", () => {
const equipment = { id: "rusty_sword", owned: true, everOwned: true };
const state = makeMinimalState({
equipment: [ equipment ] as GameState["equipment"],
});
const { updatedState } = buildPostApotheosisState(state, "T");
const matchingItem = updatedState.equipment.find((item) => {
return item.id === "rusty_sword";
});
expect(matchingItem?.everOwned).toBe(true);
});
}); });
+18 -5
View File
@@ -55,15 +55,28 @@ describe("generateDailyChallenges", () => {
expect(day2.some((c) => c.type === "clicks")).toBe(true); expect(day2.some((c) => c.type === "clicks")).toBe(true);
}); });
it("generates different challenges for different dates", async () => { it("always includes a crafting challenge regardless of date", async () => {
vi.setSystemTime(LA_MIDNIGHT_2024_01_15); vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js"); const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
const day1 = generateDailyChallenges("2024-01-15"); const day1 = generateDailyChallenges("2024-01-15");
const day2 = generateDailyChallenges("2024-01-16"); const day2 = generateDailyChallenges("2024-01-16");
// The 2 non-clicks types should vary by seed between dates expect(day1.some((c) => c.type === "crafting")).toBe(true);
const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type); expect(day2.some((c) => c.type === "crafting")).toBe(true);
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type); });
expect(day1NonClicks).not.toEqual(day2NonClicks);
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);
}); });
}); });
+42 -15
View File
@@ -55,18 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
describe("calculatePrestigeThreshold", () => { describe("calculatePrestigeThreshold", () => {
it("returns base threshold at count 0", () => { it("returns base threshold at count 0", () => {
// base × (0+1)^2 = 1_000_000 × 1 = 1_000_000 // base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
expect(calculatePrestigeThreshold(0)).toBe(1_000_000); expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
}); });
it("returns 4× base at count 1", () => { it("returns base × 2^2.5 at count 1", () => {
// base × (1+1)^2 = 1_000_000 × 4 = 4_000_000 // base × (1+1)^2.5 = 1_000_000 × 2^2.5
expect(calculatePrestigeThreshold(1)).toBe(4_000_000); expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
}); });
it("returns 9× base at count 2", () => { it("returns base × 3^2.5 at count 2", () => {
// base × (2+1)^2 = 1_000_000 × 9 = 9_000_000 // base × (2+1)^2.5 = 1_000_000 × 3^2.5
expect(calculatePrestigeThreshold(2)).toBe(9_000_000); expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
}); });
it("applies threshold multiplier correctly", () => { it("applies threshold multiplier correctly", () => {
@@ -102,27 +102,27 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => { describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => { it("calculates basic runestones formula", () => {
// floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15 // floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(15); expect(result).toBe(20);
}); });
it("applies echo runestone multiplier", () => { it("applies echo runestone multiplier", () => {
// floor(cbrt(4)) × 15 = 15; × 2 = 30 // floor(cbrt(4)) × 20 = 20; × 2 = 40
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
expect(result).toBe(30); expect(result).toBe(40);
}); });
it("applies purchased runestone upgrade multiplier", () => { it("applies purchased runestone upgrade multiplier", () => {
// With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18 // With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
expect(result).toBe(18); expect(result).toBe(25);
}); });
it("caps base runestones before multipliers", () => { it("caps base runestones before multipliers", () => {
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200 // cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 400
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] }); const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200); expect(result).toBe(400);
}); });
}); });
@@ -229,6 +229,19 @@ describe("buildPostPrestigeState", () => {
expect(prestigeState.story).toEqual(story); expect(prestigeState.story).toEqual(story);
}); });
it("persists active companion selection across prestige", () => {
const companions = { unlockedCompanionIds: ["lyra", "sera"], activeCompanionId: "lyra" };
const state = makeMinimalState({ companions });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { prestigeState } = buildPostPrestigeState(state, "Tester");
expect(prestigeState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("persists transcendence from current state", () => { it("persists transcendence from current state", () => {
const transcendence = { const transcendence = {
count: 1, echoes: 10, purchasedUpgradeIds: [], count: 1, echoes: 10, purchasedUpgradeIds: [],
@@ -255,6 +268,20 @@ describe("buildPostPrestigeState", () => {
expect(prestigeData.autoPrestigeEnabled).toBeUndefined(); expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
}); });
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
const state = makeMinimalState({
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
});
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
});
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
const state = makeMinimalState();
const { prestigeData } = buildPostPrestigeState(state, "Tester");
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
});
it("preserves apotheosis data across prestige", () => { it("preserves apotheosis data across prestige", () => {
const apotheosis = { count: 2 }; const apotheosis = { count: 2 };
const state = makeMinimalState({ apotheosis }); const state = makeMinimalState({ apotheosis });
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
preserveBossHistory,
preserveEquipmentHistory,
preserveQuestHistory,
} from "../../src/services/resetHelpers.js";
import type { Boss, Equipment, Quest } from "@elysium/types";
const makeItem = (overrides: Partial<Equipment> = {}): Equipment => ({
bonus: {},
description: "An item",
equipped: false,
id: "item_1",
name: "Item",
owned: false,
rarity: "common",
type: "weapon",
...overrides,
});
const makeQuest = (overrides: Partial<Quest> = {}): Quest => ({
description: "A quest",
durationSeconds: 60,
id: "quest_1",
name: "Quest",
prerequisiteIds: [],
rewards: [],
status: "available",
zoneId: "zone_1",
...overrides,
});
const makeBoss = (overrides: Partial<Boss> = {}): Boss => ({
bountyRunestones: 0,
crystalReward: 0,
currentHp: 100,
damagePerSecond: 1,
description: "A boss",
equipmentRewards: [],
essenceReward: 0,
goldReward: 0,
id: "boss_1",
maxHp: 100,
name: "Boss",
prestigeRequirement: 0,
status: "available",
upgradeRewards: [],
zoneId: "zone_1",
...overrides,
});
describe("preserveEquipmentHistory", () => {
it("marks everOwned when the current item has everOwned true", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ everOwned: true, owned: false }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBe(true);
});
it("marks everOwned when the current item is owned but everOwned is unset", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ owned: true }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBe(true);
});
it("leaves the fresh item unchanged when the current item is neither owned nor everOwned", () => {
const fresh = [ makeItem() ];
const current = [ makeItem({ owned: false, everOwned: false }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBeUndefined();
});
it("leaves the fresh item unchanged when no matching id exists in current equipment", () => {
const fresh = [ makeItem({ id: "item_1" }) ];
const current = [ makeItem({ id: "item_2", owned: true, everOwned: true }) ];
const result = preserveEquipmentHistory(fresh, current);
expect(result[0]?.everOwned).toBeUndefined();
});
});
describe("preserveQuestHistory", () => {
it("marks everCompleted when the current quest has everCompleted true", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ everCompleted: true, status: "available" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBe(true);
});
it("marks everCompleted when the current quest is completed but everCompleted is unset", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ status: "completed" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBe(true);
});
it("leaves the fresh quest unchanged when the current quest is neither completed nor everCompleted", () => {
const fresh = [ makeQuest() ];
const current = [ makeQuest({ everCompleted: false, status: "locked" }) ];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBeUndefined();
});
it("leaves the fresh quest unchanged when no matching id exists in current quests", () => {
const fresh = [ makeQuest({ id: "quest_1" }) ];
const current = [
makeQuest({ everCompleted: true, id: "quest_2", status: "completed" }),
];
const result = preserveQuestHistory(fresh, current);
expect(result[0]?.everCompleted).toBeUndefined();
});
});
describe("preserveBossHistory", () => {
it("marks everDefeated when the current boss has everDefeated true", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ everDefeated: true, status: "available" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBe(true);
});
it("marks everDefeated when the current boss is defeated but everDefeated is unset", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ status: "defeated" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBe(true);
});
it("leaves the fresh boss unchanged when the current boss is neither defeated nor everDefeated", () => {
const fresh = [ makeBoss() ];
const current = [ makeBoss({ everDefeated: false, status: "locked" }) ];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBeUndefined();
});
it("leaves the fresh boss unchanged when no matching id exists in current bosses", () => {
const fresh = [ makeBoss({ id: "boss_1" }) ];
const current = [
makeBoss({ everDefeated: true, id: "boss_2", status: "defeated" }),
];
const result = preserveBossHistory(fresh, current);
expect(result[0]?.everDefeated).toBeUndefined();
});
});
+87 -1
View File
@@ -9,9 +9,26 @@ import {
} from "../../src/services/transcendence.js"; } from "../../src/services/transcendence.js";
import type { GameState } from "@elysium/types"; import type { GameState } from "@elysium/types";
const makePlayer = (overrides: Partial<GameState["player"]> = {}) => ({
avatar: null,
characterName: "T",
discordId: "t",
discriminator: "0",
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated: 0,
lifetimeClicks: 0,
lifetimeGoldEarned: 0,
lifetimeQuestsCompleted: 0,
totalClicks: 0,
totalGoldEarned: 0,
username: "u",
...overrides,
});
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState => const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
({ ({
player: { discordId: "t", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, player: makePlayer(),
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [], adventurers: [],
upgrades: [], upgrades: [],
@@ -166,6 +183,19 @@ describe("buildPostTranscendenceState", () => {
expect(transcendenceState.apotheosis).toEqual(apotheosis); expect(transcendenceState.apotheosis).toEqual(apotheosis);
}); });
it("persists active companion selection across transcendence", () => {
const companions = { unlockedCompanionIds: ["lyra", "vex"], activeCompanionId: "vex" };
const state = makeMinimalState({ companions });
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.companions).toEqual(companions);
});
it("falls back to fresh companion state when currentState.companions is undefined", () => {
const state = makeMinimalState({ companions: undefined });
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.companions).toEqual({ activeCompanionId: null, unlockedCompanionIds: [] });
});
it("resets prestige to fresh state", () => { it("resets prestige to fresh state", () => {
const state = makeMinimalState({ const state = makeMinimalState({
prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] }, prestige: { count: 5, runestones: 500, productionMultiplier: 2, purchasedUpgradeIds: [] },
@@ -174,4 +204,60 @@ describe("buildPostTranscendenceState", () => {
expect(transcendenceState.prestige.count).toBe(0); expect(transcendenceState.prestige.count).toBe(0);
expect(transcendenceState.prestige.runestones).toBe(0); expect(transcendenceState.prestige.runestones).toBe(0);
}); });
it("accumulates current-run gold and clicks into lifetime totals", () => {
const state = makeMinimalState({
player: makePlayer({ totalGoldEarned: 4_000_000, lifetimeGoldEarned: 1_000_000, totalClicks: 500 }),
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeGoldEarned).toBe(5_000_000);
expect(transcendenceState.player.lifetimeClicks).toBe(500);
});
it("accumulates defeated bosses, completed quests, and recruited adventurers into lifetime totals", () => {
const boss = { id: "boss_1", status: "defeated" as const };
const quest = { id: "q_1", status: "completed" as const };
const adventurer = { id: "adv_1", count: 5 };
const state = makeMinimalState({
bosses: [ boss ] as GameState["bosses"],
quests: [ quest ] as GameState["quests"],
adventurers: [ adventurer ] as GameState["adventurers"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeBossesDefeated).toBe(1);
expect(transcendenceState.player.lifetimeQuestsCompleted).toBe(1);
expect(transcendenceState.player.lifetimeAdventurersRecruited).toBe(5);
});
it("preserves achievements and accumulates newly-unlocked ones into the lifetime total", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.achievements).toEqual([ achievement ]);
expect(transcendenceState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("does not re-count achievements already folded into the lifetime total by a previous reset", () => {
const achievement = { id: "ach_1", unlockedAt: Date.now() };
const state = makeMinimalState({
achievements: [ achievement ] as GameState["achievements"],
player: makePlayer({ lifetimeAchievementsUnlocked: 1 }),
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
expect(transcendenceState.player.lifetimeAchievementsUnlocked).toBe(1);
});
it("preserves equipment ownership history across transcendence", () => {
const equipment = { id: "rusty_sword", owned: true, everOwned: true };
const state = makeMinimalState({
equipment: [ equipment ] as GameState["equipment"],
});
const { transcendenceState } = buildPostTranscendenceState(state, "T");
const matchingItem = transcendenceState.equipment.find((item) => {
return item.id === "rusty_sword";
});
expect(matchingItem?.everOwned).toBe(true);
});
}); });
+2 -2
View File
@@ -138,10 +138,10 @@ describe("webhook service", () => {
process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc"; process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc";
mockFetch.mockResolvedValueOnce({ ok: true }); mockFetch.mockResolvedValueOnce({ ok: true });
const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
await postMilestoneWebhook("user123", "transcendence", { prestige: 0, transcendence: 1, apotheosis: 0 }); await postMilestoneWebhook("user123", "transcendence", { prestige: 3, transcendence: 1, apotheosis: 0 });
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(options.body as string) as { content: string }; const body = JSON.parse(options.body as string) as { content: string };
expect(body.content).toContain("transcended"); expect(body.content).toContain("transcended at 3 prestige");
}); });
it("posts apotheosis message correctly", async () => { it("posts apotheosis message correctly", async () => {
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.3.2", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,21 +12,21 @@
}, },
"dependencies": { "dependencies": {
"@elysium/types": "workspace:*", "@elysium/types": "workspace:*",
"react": "19.0.0", "react": "19.2.4",
"react-dom": "19.0.0", "react-dom": "19.2.4",
"react-markdown": "10.1.0" "react-markdown": "10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0", "@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"@types/react": "19.0.10", "@types/react": "19.2.14",
"@types/react-dom": "19.0.4", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "6.0.1",
"@vitest/coverage-v8": "3.0.8", "@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0", "eslint": "9.22.0",
"jsdom": "26.0.0", "jsdom": "29.0.1",
"typescript": "5.8.2", "typescript": "5.8.2",
"vite": "6.2.1", "vite": "8.0.5",
"vitest": "3.0.8" "vitest": "3.0.8"
} }
} }
+29
View File
@@ -38,6 +38,26 @@ import type {
const baseUrl = "/api"; const baseUrl = "/api";
/**
* Represents a 4xx API error so callers can distinguish expected server
* rejections from unexpected failures. ValidationErrors are downgraded to
* console.warn and are not forwarded to the error-email pipeline.
*/
class ValidationError extends Error {
public readonly statusCode: number;
/**
* Creates a new ValidationError.
* @param message - The error message from the server response.
* @param statusCode - The HTTP status code (4xx) returned by the server.
*/
public constructor(message: string, statusCode: number) {
super(message);
this.name = "ValidationError";
this.statusCode = statusCode;
}
}
const getToken = (): string | null => { const getToken = (): string | null => {
return globalThis.localStorage.getItem("elysium_token"); return globalThis.localStorage.getItem("elysium_token");
}; };
@@ -72,6 +92,14 @@ const fetchJson = async <T>(
= typeof errorBody.error === "string" = typeof errorBody.error === "string"
? errorBody.error ? errorBody.error
: "Unknown error"; : "Unknown error";
if (response.status === 401) {
globalThis.localStorage.removeItem("elysium_token");
globalThis.localStorage.removeItem("elysium_save_signature");
globalThis.location.href = "/";
}
if (response.status >= 400 && response.status < 500) {
throw new ValidationError(message, response.status);
}
throw new Error(message); throw new Error(message);
} }
@@ -326,6 +354,7 @@ const updateProfile = async(
}; };
export { export {
ValidationError,
achieveApotheosis, achieveApotheosis,
buyEchoUpgrade, buyEchoUpgrade,
buyPrestigeUpgrade, buyPrestigeUpgrade,
+14 -2
View File
@@ -56,7 +56,10 @@ const howToPlay = [
"Challenge zone bosses to earn large one-time rewards and unlock new" "Challenge zone bosses to earn large one-time rewards and unlock new"
+ " zones. Your party's combat power is based on the number and tier of" + " zones. Your party's combat power is based on the number and tier of"
+ " adventurers you've recruited. Defeated bosses cannot be re-fought," + " adventurers you've recruited. Defeated bosses cannot be re-fought,"
+ " but undefeated bosses regenerate HP over time.", + " but undefeated bosses regenerate HP over time. Some late-game bosses"
+ " also require a specific quest to be completed before they become"
+ " available, in addition to defeating the previous boss in the zone —"
+ " the boss card shows exactly what's required while it's locked.",
title: "👹 Boss Fights", title: "👹 Boss Fights",
}, },
{ {
@@ -232,7 +235,7 @@ const howToPlay = [
{ {
body: body:
"Transcendence is the ultimate prestige layer, unlocked by defeating" "Transcendence is the ultimate prestige layer, unlocked by defeating"
+ " The Absolute One (requires Prestige 90). Transcending performs a" + " The Absolute One (requires Prestige 20). Transcending performs a"
+ " nuclear reset — wiping resources, prestige, runestones, upgrades," + " nuclear reset — wiping resources, prestige, runestones, upgrades,"
+ " and equipment — but grants Echoes based on your prestige count" + " and equipment — but grants Echoes based on your prestige count"
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive" + " (fewer prestiges = more Echoes). Echoes are permanent and survive"
@@ -277,6 +280,15 @@ const howToPlay = [
+ " when you first enable them.", + " when you first enable them.",
title: "🔔 Sounds & Notifications", title: "🔔 Sounds & Notifications",
}, },
{
body:
"Have a question, found a bug, or want to suggest a feature? Join the"
+ " NHCarrigan community Discord at https://chat.nhcarrigan.com or open"
+ " a support ticket at https://support.nhcarrigan.com. You can also"
+ " report issues directly on the project repository. We'd love to hear"
+ " from you!",
title: "💬 Community & Support",
},
]; ];
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@@ -7,9 +7,10 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { getAchievementProgress } from "../../engine/achievementProgress.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import type { Achievement, GameState } from "@elysium/types"; import type { Achievement } from "@elysium/types";
/** /**
* Returns the plural form of a word based on a count. * Returns the plural form of a word based on a count.
@@ -18,9 +19,12 @@ import type { Achievement, GameState } from "@elysium/types";
* @returns The pluralised word string. * @returns The pluralised word string.
*/ */
const pluralise = (count: number, word: string): string => { const pluralise = (count: number, word: string): string => {
return count > 1 if (count <= 1) {
? `${word}s` return word;
: word; }
return word.endsWith("s")
? `${word}es`
: `${word}s`;
}; };
/** /**
@@ -39,9 +43,9 @@ const conditionDescription = (
return `Earn ${formatNumber(condition.amount)} total gold`; return `Earn ${formatNumber(condition.amount)} total gold`;
case "totalClicks": case "totalClicks":
return `Click ${formatNumber(condition.amount)} times`; return `Click ${formatNumber(condition.amount)} times`;
case "bossesDefeated": case "uniqueBossesDefeated":
return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`; return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`;
case "questsCompleted": case "uniqueQuestsCompleted":
return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`; return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`;
case "adventurerTotal": case "adventurerTotal":
return `Recruit ${formatNumber(condition.amount)} total adventurers`; return `Recruit ${formatNumber(condition.amount)} total adventurers`;
@@ -54,46 +58,6 @@ const conditionDescription = (
} }
}; };
/**
* Returns the player's current progress value toward an achievement's unlock condition,
* mirroring the logic used by the tick engine's checkAchievements function.
* @param achievement - The achievement to evaluate progress for.
* @param state - The current game state.
* @returns The current numeric progress toward the achievement condition.
*/
const getCurrentProgress = (
achievement: Achievement,
state: GameState,
): number => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
return state.player.totalGoldEarned;
case "totalClicks":
return state.player.totalClicks;
case "bossesDefeated":
return state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length;
case "questsCompleted":
return state.quests.filter((quest) => {
return quest.status === "completed";
}).length;
case "adventurerTotal":
return state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0);
case "prestigeCount":
return state.prestige.count;
case "equipmentOwned":
return state.equipment.filter((item) => {
return item.owned;
}).length;
default:
return 0;
}
};
interface AchievementCardProperties { interface AchievementCardProperties {
readonly achievement: Achievement; readonly achievement: Achievement;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
@@ -156,7 +120,18 @@ const AchievementCard = ({
</div> </div>
<div className="achievement-status"> <div className="achievement-status">
{isUnlocked {isUnlocked
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span> ? <>
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
{achievement.unlockedAt !== null
&& <span className="achievement-unlocked-at">
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
}
</>
: <span className="achievement-locked-badge">{"🔒"}</span> : <span className="achievement-locked-badge">{"🔒"}</span>
} }
</div> </div>
@@ -221,7 +196,9 @@ const AchievementPanel = (): JSX.Element => {
achievement={achievement} achievement={achievement}
formatNumber={formatNumber} formatNumber={formatNumber}
key={achievement.id} key={achievement.id}
progressValue={getCurrentProgress(achievement, state)} progressValue={
getAchievementProgress(achievement.condition, state)
}
/> />
); );
})} })}
+3 -2
View File
@@ -62,6 +62,7 @@ const BattleModal = ({
enableNotifications, enableNotifications,
enableSounds, enableSounds,
flushBossLoreToasts, flushBossLoreToasts,
formatInteger,
formatNumber, formatNumber,
} = useGame(); } = useGame();
@@ -241,14 +242,14 @@ const BattleModal = ({
{result.rewards.crystals > 0 {result.rewards.crystals > 0
&& <span> && <span>
{"💎 "} {"💎 "}
{formatNumber(result.rewards.crystals)} {formatInteger(result.rewards.crystals)}
{" crystals"} {" crystals"}
</span> </span>
} }
{result.rewards.bountyRunestones > 0 {result.rewards.bountyRunestones > 0
&& <span className="battle-bounty"> && <span className="battle-bounty">
{"🔮 "} {"🔮 "}
{formatNumber(result.rewards.bountyRunestones)} {formatInteger(result.rewards.bountyRunestones)}
{" runestones (first kill!)"} {" runestones (first kill!)"}
</span> </span>
} }
+28 -6
View File
@@ -23,6 +23,8 @@ interface BossCardProperties {
readonly onChallenge: (bossId: string)=> void; readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean; readonly isChallenging: boolean;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly questName: string | undefined;
readonly formatInteger: (n: number)=> string;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
} }
@@ -34,6 +36,8 @@ interface BossCardProperties {
* @param props.onChallenge - Callback to challenge this boss. * @param props.onChallenge - Callback to challenge this boss.
* @param props.isChallenging - Whether this boss is currently being challenged. * @param props.isChallenging - Whether this boss is currently being challenged.
* @param props.unlockHint - Optional hint for how to unlock this boss. * @param props.unlockHint - Optional hint for how to unlock this boss.
* @param props.questName - Optional name of this boss's own gating quest.
* @param props.formatInteger - The integer formatting utility function.
* @param props.formatNumber - The number formatting utility function. * @param props.formatNumber - The number formatting utility function.
* @returns The JSX element. * @returns The JSX element.
*/ */
@@ -43,6 +47,8 @@ const BossCard = ({
onChallenge, onChallenge,
isChallenging, isChallenging,
unlockHint, unlockHint,
questName,
formatInteger,
formatNumber, formatNumber,
}: BossCardProperties): JSX.Element => { }: BossCardProperties): JSX.Element => {
const scaled = boss.currentHp * 100; const scaled = boss.currentHp * 100;
@@ -70,6 +76,9 @@ const BossCard = ({
? <p className="prestige-lock"> ? <p className="prestige-lock">
{"🔒 Requires Prestige "} {"🔒 Requires Prestige "}
{boss.prestigeRequirement} {boss.prestigeRequirement}
{questName === undefined
? null
: ` and Completing ${questName}`}
</p> </p>
: null} : null}
{!isPrestigeLocked {!isPrestigeLocked
@@ -117,7 +126,7 @@ const BossCard = ({
{boss.crystalReward > 0 {boss.crystalReward > 0
&& <span> && <span>
{"💎 "} {"💎 "}
{formatNumber(boss.crystalReward)} {formatInteger(boss.crystalReward)}
</span> </span>
} }
{boss.equipmentRewards.length > 0 {boss.equipmentRewards.length > 0
@@ -166,6 +175,7 @@ const BossPanel = (): JSX.Element => {
const { const {
state, state,
challengeBoss, challengeBoss,
formatInteger,
formatNumber, formatNumber,
toggleAutoBoss, toggleAutoBoss,
autoBossLastResult, autoBossLastResult,
@@ -239,6 +249,7 @@ const BossPanel = (): JSX.Element => {
}); });
const bossUnlockHints = new Map<string, string>(); const bossUnlockHints = new Map<string, string>();
const bossQuestNames = new Map<string, string>();
for (const zone of zones) { for (const zone of zones) {
const { id: zoneId, unlockBossId, unlockQuestId } = zone; const { id: zoneId, unlockBossId, unlockQuestId } = zone;
const allZoneBosses = bosses.filter((boss) => { const allZoneBosses = bosses.filter((boss) => {
@@ -249,8 +260,8 @@ const BossPanel = (): JSX.Element => {
if (boss === undefined || boss.status !== "locked") { if (boss === undefined || boss.status !== "locked") {
continue; continue;
} }
if (index === 0) {
const parts: Array<string> = []; const parts: Array<string> = [];
if (index === 0) {
if (unlockBossId !== null) { if (unlockBossId !== null) {
const gateBoss = bosses.find((candidate) => { const gateBoss = bosses.find((candidate) => {
return candidate.id === unlockBossId; return candidate.id === unlockBossId;
@@ -267,15 +278,24 @@ const BossPanel = (): JSX.Element => {
parts.push(`📜 Complete: ${gateQuest.name}`); parts.push(`📜 Complete: ${gateQuest.name}`);
} }
} }
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else { } else {
const previousBoss = allZoneBosses[index - 1]; const previousBoss = allZoneBosses[index - 1];
if (previousBoss !== undefined) { if (previousBoss !== undefined) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`); parts.push(`⚔️ Defeat: ${previousBoss.name} first`);
} }
} }
if (boss.unlockQuestId !== null && boss.unlockQuestId !== undefined) {
const bossGateQuest = quests.find((candidate) => {
return candidate.id === boss.unlockQuestId;
});
if (bossGateQuest !== undefined) {
parts.push(`📜 Complete: ${bossGateQuest.name}`);
bossQuestNames.set(boss.id, bossGateQuest.name);
}
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} }
} }
@@ -395,11 +415,13 @@ const BossPanel = (): JSX.Element => {
return ( return (
<BossCard <BossCard
boss={boss} boss={boss}
formatInteger={formatInteger}
formatNumber={formatNumber} formatNumber={formatNumber}
isChallenging={challengingBossId === bossId} isChallenging={challengingBossId === bossId}
key={bossId} key={bossId}
onChallenge={handleChallengeClick} onChallenge={handleChallengeClick}
prestigeCount={prestigeCount} prestigeCount={prestigeCount}
questName={bossQuestNames.get(bossId)}
unlockHint={bossUnlockHints.get(bossId)} unlockHint={bossUnlockHints.get(bossId)}
/> />
); );
+42 -35
View File
@@ -6,6 +6,7 @@
*/ */
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */ /* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
/* eslint-disable complexity -- Companion card has many conditional render paths */
import { COMPANIONS, type Companion } from "@elysium/types"; import { COMPANIONS, type Companion } from "@elysium/types";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -28,41 +29,13 @@ const unlockLabels: Record<string, string> = {
transcendence: "transcendence(s)", transcendence: "transcendence(s)",
}; };
/**
* Formats a companion unlock threshold for display.
* @param type - The unlock condition type.
* @param threshold - The threshold value.
* @returns The formatted threshold string.
*/
const formatThreshold = (type: string, threshold: number): string => {
if (type === "lifetimeGold") {
if (threshold >= 1e18) {
return `${(threshold / 1e18).toFixed(0)}Qt`;
}
if (threshold >= 1e15) {
return `${(threshold / 1e15).toFixed(0)}Q`;
}
if (threshold >= 1e12) {
return `${(threshold / 1e12).toFixed(0)}T`;
}
if (threshold >= 1e9) {
return `${(threshold / 1e9).toFixed(0)}B`;
}
if (threshold >= 1e6) {
return `${(threshold / 1e6).toFixed(0)}M`;
}
if (threshold >= 1e3) {
return `${(threshold / 1e3).toFixed(0)}K`;
}
}
return threshold.toString();
};
interface CompanionCardProperties { interface CompanionCardProperties {
readonly companion: Companion; readonly companion: Companion;
readonly isUnlocked: boolean; readonly isUnlocked: boolean;
readonly isActive: boolean; readonly isActive: boolean;
readonly onSelect: ()=> void; readonly onSelect: ()=> void;
readonly formatNumber: (n: number)=> string;
readonly currentProgress: number;
} }
/** /**
@@ -72,6 +45,8 @@ interface CompanionCardProperties {
* @param props.isUnlocked - Whether this companion is unlocked. * @param props.isUnlocked - Whether this companion is unlocked.
* @param props.isActive - Whether this companion is currently active. * @param props.isActive - Whether this companion is currently active.
* @param props.onSelect - Callback when the companion is selected/deselected. * @param props.onSelect - Callback when the companion is selected/deselected.
* @param props.formatNumber - The number formatting utility function.
* @param props.currentProgress - The player's current progress toward the unlock threshold.
* @returns The JSX element. * @returns The JSX element.
*/ */
const CompanionCard = ({ const CompanionCard = ({
@@ -79,6 +54,8 @@ const CompanionCard = ({
isUnlocked, isUnlocked,
isActive, isActive,
onSelect, onSelect,
formatNumber,
currentProgress,
}: CompanionCardProperties): JSX.Element => { }: CompanionCardProperties): JSX.Element => {
const bonusSign = companion.bonus.type === "questTime" const bonusSign = companion.bonus.type === "questTime"
? "-" ? "-"
@@ -137,12 +114,28 @@ const CompanionCard = ({
: "Activate"} : "Activate"}
</button> </button>
: <div className="companion-unlock-requirement"> : <div className="companion-unlock-requirement">
<p>
{"🔒 Unlock: "} {"🔒 Unlock: "}
{formatThreshold( {companion.unlock.type === "lifetimeGold"
companion.unlock.type, ? formatNumber(companion.unlock.threshold)
companion.unlock.threshold, : String(companion.unlock.threshold)}{" "}
)}{" "}
{unlockLabels[companion.unlock.type] ?? companion.unlock.type} {unlockLabels[companion.unlock.type] ?? companion.unlock.type}
</p>
<div className="companion-progress">
<progress
max={companion.unlock.threshold}
value={Math.min(currentProgress, companion.unlock.threshold)}
/>
<span className="companion-progress-label">
{companion.unlock.type === "lifetimeGold"
? formatNumber(currentProgress)
: String(currentProgress)}
{" / "}
{companion.unlock.type === "lifetimeGold"
? formatNumber(companion.unlock.threshold)
: String(companion.unlock.threshold)}
</span>
</div>
</div> </div>
} }
</div> </div>
@@ -154,7 +147,7 @@ const CompanionCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const CompanionPanel = (): JSX.Element => { const CompanionPanel = (): JSX.Element => {
const { state, setActiveCompanion } = useGame(); const { formatNumber, setActiveCompanion, state } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -167,6 +160,16 @@ const CompanionPanel = (): JSX.Element => {
const unlockedIds = state.companions?.unlockedCompanionIds ?? []; const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
const activeId = state.companions?.activeCompanionId ?? null; const activeId = state.companions?.activeCompanionId ?? null;
const progressByUnlockType: Record<string, number> = {
apotheosis: state.apotheosis?.count ?? 0,
lifetimeBosses: state.player.lifetimeBossesDefeated,
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
lifetimeQuests: state.player.lifetimeQuestsCompleted,
prestige: state.prestige.count,
transcendence: state.transcendence?.count ?? 0,
};
function handleSelect(companionId: string): void { function handleSelect(companionId: string): void {
setActiveCompanion(activeId === companionId setActiveCompanion(activeId === companionId
? null ? null
@@ -204,6 +207,10 @@ const CompanionPanel = (): JSX.Element => {
return ( return (
<CompanionCard <CompanionCard
companion={companion} companion={companion}
currentProgress={
progressByUnlockType[companion.unlock.type] ?? 0
}
formatNumber={formatNumber}
isActive={activeId === companion.id} isActive={activeId === companion.id}
isUnlocked={unlockedIds.includes(companion.id)} isUnlocked={unlockedIds.includes(companion.id)}
key={companion.id} key={companion.id}
+70 -15
View File
@@ -16,6 +16,7 @@ import {
PRESTIGE_UPGRADES, PRESTIGE_UPGRADES,
} from "../../data/prestigeUpgrades.js"; } from "../../data/prestigeUpgrades.js";
import { import {
computeProjectedMilestoneBonus,
computeProjectedRunestones, computeProjectedRunestones,
} from "../../engine/tick.js"; } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -27,12 +28,12 @@ const baseThreshold = 1_000_000;
/** /**
* Calculates the prestige threshold for a given prestige count. * Calculates the prestige threshold for a given prestige count.
* Mirrors the server formula: BASE * (count + 1)^2. * Mirrors the server formula: BASE * (count + 1)^2.5.
* @param prestigeCount - The current prestige count. * @param prestigeCount - The current prestige count.
* @returns The required gold to prestige. * @returns The required gold to prestige.
*/ */
const calculateThreshold = (prestigeCount: number): number => { const calculateThreshold = (prestigeCount: number): number => {
return baseThreshold * Math.pow(prestigeCount + 1, 2); return baseThreshold * Math.pow(prestigeCount + 1, 2.5);
}; };
/** /**
@@ -41,7 +42,7 @@ const calculateThreshold = (prestigeCount: number): number => {
* @returns The compounding multiplier applied to all income sources. * @returns The compounding multiplier applied to all income sources.
*/ */
const calculateProductionMultiplier = (prestigeCount: number): number => { const calculateProductionMultiplier = (prestigeCount: number): number => {
return Math.pow(1.15, prestigeCount); return Math.pow(1.3, prestigeCount);
}; };
const categoryOrder: Array<PrestigeUpgradeCategory> = [ const categoryOrder: Array<PrestigeUpgradeCategory> = [
@@ -61,13 +62,16 @@ const PrestigePanel = (): JSX.Element => {
const { const {
state, state,
reloadSilent, reloadSilent,
formatInteger,
formatNumber, formatNumber,
buyPrestigeUpgrade, buyPrestigeUpgrade,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
triggerPrestigeToast, triggerPrestigeToast,
notifyPrestiged,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
@@ -91,7 +95,13 @@ const PrestigePanel = (): JSX.Element => {
const threshold = calculateThreshold(prestigeData.count); const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold; const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = computeProjectedRunestones(state); const runestonePreview = computeProjectedRunestones(state);
const milestoneBonusPreview = computeProjectedMilestoneBonus(state);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
const baseRunestones = Math.min(
Math.floor(Math.cbrt(player.totalGoldEarned / threshold)) * 15,
400,
);
const isAtMaxRunestones = baseRunestones >= 400;
async function handlePrestige(): Promise<void> { async function handlePrestige(): Promise<void> {
setIsPending(true); setIsPending(true);
@@ -104,6 +114,7 @@ const PrestigePanel = (): JSX.Element => {
runestones: data.runestones, runestones: data.runestones,
}); });
triggerPrestigeToast(); triggerPrestigeToast();
notifyPrestiged();
if (enableSounds) { if (enableSounds) {
playSound("prestige"); playSound("prestige");
} }
@@ -154,6 +165,10 @@ const PrestigePanel = (): JSX.Element => {
toggleAutoPrestige(); toggleAutoPrestige();
} }
function handleAutoPrestigeMaxRunestonesToggle(): void {
toggleAutoPrestigeMaxRunestones();
}
function handlePrestigeTabClick(): void { function handlePrestigeTabClick(): void {
setActiveTab("prestige"); setActiveTab("prestige");
} }
@@ -187,7 +202,7 @@ const PrestigePanel = (): JSX.Element => {
type="button" type="button"
> >
{"🔮 Runestone Shop ("} {"🔮 Runestone Shop ("}
{formatNumber(prestigeData.runestones)} {formatInteger(prestigeData.runestones)}
{" stones)"} {" stones)"}
</button> </button>
</div> </div>
@@ -198,7 +213,7 @@ const PrestigePanel = (): JSX.Element => {
{"Prestige resets your progress but grants "} {"Prestige resets your progress but grants "}
<strong>{"Runestones"}</strong> <strong>{"Runestones"}</strong>
{"— permanent currency used for powerful upgrades."} {"— permanent currency used for powerful upgrades."}
{" Each prestige multiplies your global production by ×1.15"} {" Each prestige multiplies your global production by ×1.3"}
{" (compounding each run)."} {" (compounding each run)."}
</p> </p>
@@ -219,27 +234,47 @@ const PrestigePanel = (): JSX.Element => {
{"Current production multiplier: "} {"Current production multiplier: "}
<strong> <strong>
{"×"} {"×"}
{prestigeData.productionMultiplier.toFixed(2)} {formatNumber(prestigeData.productionMultiplier)}
</strong> </strong>
</p> </p>
<p> <p>
{"After next prestige: "} {"After next prestige: "}
<strong> <strong>
{"×"} {"×"}
{nextMultiplier.toFixed(2)} {formatNumber(nextMultiplier)}
</strong> </strong>
</p> </p>
<p> <p>
{"Runestones: "} {"Runestones: "}
<strong>{formatNumber(prestigeData.runestones)}</strong> <strong>{formatInteger(prestigeData.runestones)}</strong>
</p> </p>
{isEligible {isEligible
? <p className="runestone-preview"> ? <p className="runestone-preview">
{"Runestones on prestige: "} {"Runestones on prestige: "}
<strong> <strong>
{"+"} {"+"}
{formatNumber(runestonePreview)} {formatInteger(runestonePreview)}
</strong> </strong>
{isAtMaxRunestones
? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
: null
}
</p>
: null}
{isEligible && milestoneBonusPreview > 0
? <p className="runestone-milestone-preview">
{"🎉 Bonus: "}
<strong>
{"+"}
{formatInteger(milestoneBonusPreview)}
</strong>
{" Runestones (milestone prestige)"}
</p>
: null}
{isEligible && !isAtMaxRunestones
? <p className="runestone-progress-hint">
{"Earn more gold to increase your runestone yield "
+ "(capped at ×27³ the prestige threshold)."}
</p> </p>
: null} : null}
{isEligible {isEligible
@@ -268,7 +303,7 @@ const PrestigePanel = (): JSX.Element => {
> >
{isPending {isPending
? "Ascending..." ? "Ascending..."
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`} : `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`}
</button> </button>
{prestigeError === null {prestigeError === null
? null ? null
@@ -280,12 +315,12 @@ const PrestigePanel = (): JSX.Element => {
{"Ascended to Prestige "} {"Ascended to Prestige "}
{result.count} {result.count}
{"! Earned "} {"! Earned "}
{formatNumber(result.runestones)} {formatInteger(result.runestones)}
{" Runestones."} {" Runestones."}
{result.milestoneRunestones > 0 {result.milestoneRunestones > 0
&& <> && <>
{" 🎉 Milestone bonus: +"} {" 🎉 Milestone bonus: +"}
{formatNumber(result.milestoneRunestones)} {formatInteger(result.milestoneRunestones)}
{" Runestones!"} {" Runestones!"}
</> </>
} }
@@ -306,7 +341,7 @@ const PrestigePanel = (): JSX.Element => {
<p className="shop-balance"> <p className="shop-balance">
{"Balance: "} {"Balance: "}
<strong> <strong>
{formatNumber(prestigeData.runestones)} {formatInteger(prestigeData.runestones)}
{" Runestones"} {" Runestones"}
</strong> </strong>
</p> </p>
@@ -331,6 +366,8 @@ const PrestigePanel = (): JSX.Element => {
= upgrade.id === "auto_prestige" && purchased; = upgrade.id === "auto_prestige" && purchased;
const autoPrestigeEnabled const autoPrestigeEnabled
= prestigeData.autoPrestigeEnabled ?? false; = prestigeData.autoPrestigeEnabled ?? false;
const autoPrestigeMaxRunestonesOnly
= prestigeData.autoPrestigeMaxRunestonesOnly ?? false;
function handleBuyClick(): void { function handleBuyClick(): void {
void handleBuyUpgrade(upgrade.id); void handleBuyUpgrade(upgrade.id);
@@ -358,7 +395,7 @@ const PrestigePanel = (): JSX.Element => {
<p className="upgrade-cost"> <p className="upgrade-cost">
{purchased {purchased
? "✅ Purchased" ? "✅ Purchased"
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`} : `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`}
</p> </p>
</div> </div>
{isAutoAdventurerToggle {isAutoAdventurerToggle
@@ -377,7 +414,8 @@ const PrestigePanel = (): JSX.Element => {
</button> </button>
: null} : null}
{isAutoPrestigeToggle {isAutoPrestigeToggle
? <button ? <>
<button
className={`auto-prestige-toggle ${ className={`auto-prestige-toggle ${
autoPrestigeEnabled autoPrestigeEnabled
? "enabled" ? "enabled"
@@ -390,6 +428,23 @@ const PrestigePanel = (): JSX.Element => {
? "⚡ Auto ON" ? "⚡ Auto ON"
: "⏸ Auto OFF"} : "⏸ Auto OFF"}
</button> </button>
{autoPrestigeEnabled
? <button
className={`auto-prestige-toggle ${
autoPrestigeMaxRunestonesOnly
? "enabled"
: "disabled"
}`}
onClick={handleAutoPrestigeMaxRunestonesToggle}
title="Only fire at max runestone yield"
type="button"
>
{autoPrestigeMaxRunestonesOnly
? "⚡ Max Runes Only"
: "⏸ Max Runes OFF"}
</button>
: null}
</>
: null} : null}
{purchased {purchased
? null ? null
+45 -6
View File
@@ -9,6 +9,7 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-statements -- Many local variables needed for quest state */ /* eslint-disable max-statements -- Many local variables needed for quest state */
import { getActiveCompanionBonus, type Quest } from "@elysium/types";
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import {
@@ -18,7 +19,6 @@ import {
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Quest } from "@elysium/types";
/** /**
* Formats a duration in seconds to a human-readable string. * Formats a duration in seconds to a human-readable string.
@@ -42,17 +42,36 @@ const formatDuration = (seconds: number): string => {
return `${String(seconds)}s`; return `${String(seconds)}s`;
}; };
/**
* Computes the effective quest duration in seconds, after applying any active
* companion quest-time reduction (e.g. Wren, Zuri).
* @param quest - The quest to check.
* @param questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The effective duration in seconds.
*/
const effectiveQuestDuration = (
quest: Quest,
questTimeReduction: number,
): number => {
return quest.durationSeconds * (1 - questTimeReduction);
};
/** /**
* Computes the time remaining for an active quest. * Computes the time remaining for an active quest.
* @param quest - The quest to check. * @param quest - The quest to check.
* @param questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The remaining seconds. * @returns The remaining seconds.
*/ */
const questTimeRemaining = (quest: Quest): number => { const questTimeRemaining = (
quest: Quest,
questTimeReduction: number,
): number => {
if (quest.status !== "active" || quest.startedAt === undefined) { if (quest.status !== "active" || quest.startedAt === undefined) {
return 0; return 0;
} }
const elapsed = (Date.now() - quest.startedAt) / 1000; const elapsed = (Date.now() - quest.startedAt) / 1000;
return Math.max(0, quest.durationSeconds - elapsed); const remaining = effectiveQuestDuration(quest, questTimeReduction) - elapsed;
return Math.max(0, remaining);
}; };
interface QuestCardProperties { interface QuestCardProperties {
@@ -60,6 +79,7 @@ interface QuestCardProperties {
readonly partyCombatPower: number; readonly partyCombatPower: number;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly zoneHint: string | undefined; readonly zoneHint: string | undefined;
readonly questTimeReduction: number;
} }
/** /**
@@ -69,6 +89,7 @@ interface QuestCardProperties {
* @param props.partyCombatPower - The current party's combat power. * @param props.partyCombatPower - The current party's combat power.
* @param props.unlockHint - Optional hint for how to unlock this quest. * @param props.unlockHint - Optional hint for how to unlock this quest.
* @param props.zoneHint - Optional hint for which zone to unlock. * @param props.zoneHint - Optional hint for which zone to unlock.
* @param props.questTimeReduction - Fractional reduction (0-1) from an active companion.
* @returns The JSX element. * @returns The JSX element.
*/ */
const QuestCard = ({ const QuestCard = ({
@@ -76,6 +97,7 @@ const QuestCard = ({
partyCombatPower, partyCombatPower,
unlockHint, unlockHint,
zoneHint, zoneHint,
questTimeReduction,
}: QuestCardProperties): JSX.Element => { }: QuestCardProperties): JSX.Element => {
const { startQuest, formatNumber } = useGame(); const { startQuest, formatNumber } = useGame();
const cpRequired = quest.combatPowerRequired ?? 0; const cpRequired = quest.combatPowerRequired ?? 0;
@@ -114,6 +136,9 @@ const QuestCard = ({
} }
<div className="quest-rewards"> <div className="quest-rewards">
{quest.rewards.map((reward, rewardIndex) => { {quest.rewards.map((reward, rewardIndex) => {
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
return null;
}
return ( return (
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}> <span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
{reward.type === "gold" {reward.type === "gold"
@@ -173,14 +198,18 @@ const QuestCard = ({
type="button" type="button"
> >
{"Send Party ("} {"Send Party ("}
{formatDuration(quest.durationSeconds)} {formatDuration(
Math.round(effectiveQuestDuration(quest, questTimeReduction)),
)}
{")"} {")"}
</button> </button>
} }
{quest.status === "active" {quest.status === "active"
&& <span className="quest-badge active"> && <span className="quest-badge active">
{"⏳ "} {"⏳ "}
{formatDuration(Math.ceil(questTimeRemaining(quest)))} {formatDuration(
Math.ceil(questTimeRemaining(quest, questTimeReduction)),
)}
{" remaining"} {" remaining"}
</span> </span>
} }
@@ -211,7 +240,16 @@ const QuestPanel = (): JSX.Element => {
); );
} }
const { autoQuest, bosses, quests, zones } = state; const { autoQuest, bosses, companions, quests, zones } = state;
const companionBonus = getActiveCompanionBonus(
companions?.activeCompanionId,
companions?.unlockedCompanionIds ?? [],
);
const questTimeReduction
= companionBonus?.type === "questTime"
? companionBonus.value
: 0;
const activeZone = zones.find((zone) => { const activeZone = zones.find((zone) => {
return zone.id === activeZoneId; return zone.id === activeZoneId;
@@ -355,6 +393,7 @@ const QuestPanel = (): JSX.Element => {
key={quest.id} key={quest.id}
partyCombatPower={partyCombatPower} partyCombatPower={partyCombatPower}
quest={quest} quest={quest}
questTimeReduction={questTimeReduction}
unlockHint={questUnlockHints.get(quest.id)} unlockHint={questUnlockHints.get(quest.id)}
zoneHint={questZoneHints.get(quest.id)} zoneHint={questZoneHints.get(quest.id)}
/> />
@@ -59,7 +59,7 @@ const StatCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const StatisticsPanel = (): JSX.Element => { const StatisticsPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatInteger, formatNumber } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => {
<StatCard <StatCard
icon="💎" icon="💎"
label="Crystals" label="Crystals"
value={formatNumber(resources.crystals)} value={formatInteger(resources.crystals)}
/> />
<StatCard <StatCard
icon="🔮" icon="🔮"
label="Runestones" label="Runestones"
sub="permanent currency" sub="permanent currency"
value={formatNumber(prestige.runestones)} value={formatInteger(prestige.runestones)}
/> />
</div> </div>
+64 -1
View File
@@ -7,7 +7,13 @@
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */ /* eslint-disable complexity -- Complex component with many conditional render paths */
import { STORY_CHAPTERS } from "@elysium/types"; import { STORY_CHAPTERS } from "@elysium/types";
import { type JSX, useState } from "react"; import {
type JSX,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
useEffect,
useState,
} from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -27,6 +33,16 @@ const substituteCharacterName = (
return text.replaceAll("{characterName}", fallback); return text.replaceAll("{characterName}", fallback);
}; };
/**
* Stops the enlarged image click from bubbling to the overlay's close handler.
* @param event - The click event on the enlarged image.
*/
const handleFullImageClick = (
event: ReactMouseEvent<HTMLImageElement>,
): void => {
event.stopPropagation();
};
/** /**
* Renders the story panel with chapter navigation and content. * Renders the story panel with chapter navigation and content.
* @returns The JSX element. * @returns The JSX element.
@@ -34,6 +50,22 @@ const substituteCharacterName = (
const StoryPanel = (): JSX.Element => { const StoryPanel = (): JSX.Element => {
const { state, completeChapter } = useGame(); const { state, completeChapter } = useGame();
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0); const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
const [ isImageExpanded, setIsImageExpanded ] = useState(false);
useEffect(() => {
if (!isImageExpanded) {
return undefined;
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsImageExpanded(false);
}
}
globalThis.addEventListener("keydown", handleKeyDown);
return (): void => {
globalThis.removeEventListener("keydown", handleKeyDown);
};
}, [ isImageExpanded ]);
if (state === null) { if (state === null) {
return ( return (
@@ -57,6 +89,19 @@ const StoryPanel = (): JSX.Element => {
}) ?? null; }) ?? null;
const isUnread = isUnlocked && completion === null; const isUnread = isUnlocked && completion === null;
function handleImageExpand(): void {
setIsImageExpanded(true);
}
function handleImageCollapse(): void {
setIsImageExpanded(false);
}
function handleImageKeyDown(
event: ReactKeyboardEvent<HTMLImageElement>,
): void {
if (event.key === "Enter" || event.key === " ") {
setIsImageExpanded(true);
}
}
return ( return (
<div className="story-panel"> <div className="story-panel">
<div className="story-chapter-tabs"> <div className="story-chapter-tabs">
@@ -106,7 +151,11 @@ const StoryPanel = (): JSX.Element => {
<img <img
alt={activeChapter.title} alt={activeChapter.title}
className="story-chapter-banner" className="story-chapter-banner"
onClick={handleImageExpand}
onKeyDown={handleImageKeyDown}
role="button"
src={cdnImage("story-chapters", activeChapter.id)} src={cdnImage("story-chapters", activeChapter.id)}
tabIndex={0}
/> />
<h2 className="story-chapter-title"> <h2 className="story-chapter-title">
{"Chapter "} {"Chapter "}
@@ -178,6 +227,20 @@ const StoryPanel = (): JSX.Element => {
} }
</div> </div>
} }
{isImageExpanded && activeChapter !== undefined
? <div
className="modal-overlay"
onClick={handleImageCollapse}
>
<img
alt={activeChapter.title}
className="story-chapter-banner-full"
onClick={handleFullImageClick}
src={cdnImage("story-chapters", activeChapter.id)}
/>
</div>
: null}
</div> </div>
); );
}; };
@@ -50,7 +50,7 @@ const categoryOrder: Array<TranscendenceUpgradeCategory> = [
* @returns The JSX element. * @returns The JSX element.
*/ */
const TranscendencePanel = (): JSX.Element => { const TranscendencePanel = (): JSX.Element => {
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame(); const { state, formatInteger, transcend, buyEchoUpgrade } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
echoes: number; echoes: number;
@@ -152,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => {
type="button" type="button"
> >
{"✨ Echo Shop ("} {"✨ Echo Shop ("}
{formatNumber(currentEchoes)} {formatInteger(currentEchoes)}
{" echoes)"} {" echoes)"}
</button> </button>
</div> </div>
@@ -184,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => {
} }
<p> <p>
{"Current Echoes: "} {"Current Echoes: "}
<strong>{formatNumber(currentEchoes)}</strong> <strong>{formatInteger(currentEchoes)}</strong>
</p> </p>
<p> <p>
{"Current prestige count: "} {"Current prestige count: "}
@@ -195,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => {
{"Echoes on transcendence: "} {"Echoes on transcendence: "}
<strong> <strong>
{"+"} {"+"}
{formatNumber(echoPreview)} {formatInteger(echoPreview)}
</strong> </strong>
{echoMetaMultiplier > 1 {echoMetaMultiplier > 1
&& <span className="echo-meta-bonus"> && <span className="echo-meta-bonus">
@@ -238,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => {
> >
{isPending {isPending
? "Transcending..." ? "Transcending..."
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`} : `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`}
</button> </button>
{error === null {error === null
? null ? null
@@ -248,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => {
: <p className="success"> : <p className="success">
{"Transcended! Earned "} {"Transcended! Earned "}
<strong> <strong>
{formatNumber(result.echoes)} {formatInteger(result.echoes)}
{" Echoes"} {" Echoes"}
</strong> </strong>
{". This is Transcendence "} {". This is Transcendence "}
@@ -266,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {
<p className="shop-balance"> <p className="shop-balance">
{"Balance: "} {"Balance: "}
<strong> <strong>
{formatNumber(currentEchoes)} {formatInteger(currentEchoes)}
{" Echoes"} {" Echoes"}
</strong> </strong>
</p> </p>
@@ -314,7 +314,7 @@ const TranscendencePanel = (): JSX.Element => {
<p className="upgrade-cost"> <p className="upgrade-cost">
{purchased {purchased
? "✅ Purchased" ? "✅ Purchased"
: `${formatNumber(upgrade.cost)} Echoes`} : `${formatInteger(upgrade.cost)} Echoes`}
</p> </p>
</div> </div>
{purchased {purchased
+4 -4
View File
@@ -82,7 +82,7 @@ const ResourceBar = ({
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProperties): JSX.Element => { }: ResourceBarProperties): JSX.Element => {
const { formatNumber, syncError, state } = useGame(); const { formatInteger, formatNumber, syncError, state } = useGame();
const [ isProfileOpen, setIsProfileOpen ] = useState(false); const [ isProfileOpen, setIsProfileOpen ] = useState(false);
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false); const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
@@ -162,7 +162,7 @@ const ResourceBar = ({
title="Click to see all resources" title="Click to see all resources"
type="button" type="button"
> >
<span className="resource-icon">{"🪙"}</span> <span className="resource-icon">{"💰"}</span>
<span className="resource-value">{formatNumber(gold)}</span> <span className="resource-value">{formatNumber(gold)}</span>
<span className="resource-label">{"Gold"}</span> <span className="resource-label">{"Gold"}</span>
{goldFull {goldFull
@@ -233,14 +233,14 @@ const ResourceBar = ({
<div className="resource"> <div className="resource">
<span className="resource-icon">{"🔮"}</span> <span className="resource-icon">{"🔮"}</span>
<span className="resource-value"> <span className="resource-value">
{formatNumber(runestones)} {formatInteger(runestones)}
</span> </span>
<span className="resource-label">{"Runestones"}</span> <span className="resource-label">{"Runestones"}</span>
</div> </div>
<div className="resource"> <div className="resource">
<span className="resource-icon">{"⭐"}</span> <span className="resource-icon">{"⭐"}</span>
<span className="resource-value"> <span className="resource-value">
{`+${formatNumber(projectedRunestones)}`} {`+${formatInteger(projectedRunestones)}`}
</span> </span>
<span className="resource-label">{"On Prestige"}</span> <span className="resource-label">{"On Prestige"}</span>
</div> </div>
+193 -12
View File
@@ -22,6 +22,7 @@ import {
type NumberFormat, type NumberFormat,
type Quest, type Quest,
type TranscendenceResponse, type TranscendenceResponse,
computeUnlockedCompanionIds,
isStoryChapterUnlocked, isStoryChapterUnlocked,
} from "@elysium/types"; } from "@elysium/types";
import { import {
@@ -62,14 +63,35 @@ import {
computePartyCombatPower, computePartyCombatPower,
} from "../engine/tick.js"; } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js"; import {
formatInteger as formatIntegerUtil,
formatNumber as formatNumberUtil,
} from "../utils/format.js";
import { logError } from "../utils/logError.js"; import { logError } from "../utils/logError.js";
import { sendNotification } from "../utils/notification.js"; import { sendNotification } from "../utils/notification.js";
import { playSound } from "../utils/sound.js"; import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000; const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000; const autoPrestigeThresholdBase = 1_000_000;
const autoPrestigeThresholdScale = 5;
/**
* Returns whether a boss's own unlock quest (if any) has been completed.
* @param questId - The quest ID gating the boss, or null/undefined if none.
* @param quests - The player's current quest states.
* @returns True when there's no quest gate, or the gate quest is completed.
*/
const isBossQuestSatisfied = (
questId: string | null | undefined,
quests: GameState["quests"],
): boolean => {
return (
questId === null
|| questId === undefined
|| quests.some((q) => {
return q.id === questId && q.status === "completed";
})
);
};
/** /**
* Pure function — applies a boss challenge result to the game state. * Pure function — applies a boss challenge result to the game state.
@@ -143,12 +165,14 @@ const applyBossResult = (
if ( if (
b.id === nextZoneBossId b.id === nextZoneBossId
&& b.prestigeRequirement <= previous.prestige.count && b.prestigeRequirement <= previous.prestige.count
&& isBossQuestSatisfied(b.unlockQuestId, previous.quests)
) { ) {
return { ...b, status: "available" as const }; return { ...b, status: "available" as const };
} }
if ( if (
zoneFirstBossIds.has(b.id) zoneFirstBossIds.has(b.id)
&& b.prestigeRequirement <= previous.prestige.count && b.prestigeRequirement <= previous.prestige.count
&& isBossQuestSatisfied(b.unlockQuestId, previous.quests)
) { ) {
return { ...b, status: "available" as const }; return { ...b, status: "available" as const };
} }
@@ -178,6 +202,7 @@ const applyBossResult = (
return { return {
...equip, ...equip,
equipped: slotEmpty || equip.equipped, equipped: slotEmpty || equip.equipped,
everOwned: true,
owned: true, owned: true,
}; };
} }
@@ -401,6 +426,13 @@ interface GameContextValue {
*/ */
triggerPrestigeToast: ()=> void; triggerPrestigeToast: ()=> void;
/**
* Schedule a lightweight background save ~10s after a prestige completes,
* folding back into the normal auto-save cadence (called from prestigePanel
* on manual prestige, and internally on auto-prestige).
*/
notifyPrestiged: ()=> void;
/** /**
* Dismiss the prestige milestone toast. * Dismiss the prestige milestone toast.
*/ */
@@ -461,6 +493,11 @@ interface GameContextValue {
*/ */
formatNumber: (value: number)=> string; formatNumber: (value: number)=> string;
/**
* Format a whole-number value without decimal places.
*/
formatInteger: (value: number)=> string;
/** /**
* Buy a prestige upgrade from the runestone shop. * Buy a prestige upgrade from the runestone shop.
*/ */
@@ -471,6 +508,11 @@ interface GameContextValue {
*/ */
toggleAutoPrestige: ()=> void; toggleAutoPrestige: ()=> void;
/**
* Toggle whether auto-prestige waits for maximum runestone yield before firing.
*/
toggleAutoPrestigeMaxRunestones: ()=> void;
/** /**
* Toggle the auto-quest setting on/off. * Toggle the auto-quest setting on/off.
*/ */
@@ -703,13 +745,15 @@ export const GameProvider = ({
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>( const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
); );
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix"); const [ numberFormat, setNumberFormat ]
= useState<NumberFormat>("scientific");
const [ enableSounds, setEnableSounds ] = useState(false); const [ enableSounds, setEnableSounds ] = useState(false);
const [ enableNotifications, setEnableNotifications ] = useState(false); const [ enableNotifications, setEnableNotifications ] = useState(false);
const enableSoundsReference = useRef(false); const enableSoundsReference = useRef(false);
const enableNotificationsReference = useRef(false); const enableNotificationsReference = useRef(false);
const stateReference = useRef<GameState | null>(null); const stateReference = useRef<GameState | null>(null);
const lastSaveReference = useRef<number>(Date.now()); const lastSaveReference = useRef<number>(Date.now());
const postPrestigeSaveDeadlineReference = useRef<number | null>(null);
const isSyncingReference = useRef(false); const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null); const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]); const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
@@ -728,6 +772,9 @@ export const GameProvider = ({
/* No-op placeholder */ /* No-op placeholder */
}); });
const notifyPrestiged = useCallback(() => {
postPrestigeSaveDeadlineReference.current = Date.now() + 10_000;
}, []);
const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
@@ -1111,6 +1158,57 @@ export const GameProvider = ({
}); });
}, [ state ]); }, [ state ]);
// Detect newly unlocked companions whenever relevant state changes
useEffect(() => {
if (state === null) {
return;
}
const computedUnlocks = computeUnlockedCompanionIds({
apotheosisCount: state.apotheosis?.count ?? 0,
lifetimeBossesDefeated: state.player.lifetimeBossesDefeated,
lifetimeGoldEarned: state.player.lifetimeGoldEarned,
lifetimeQuestsCompleted: state.player.lifetimeQuestsCompleted,
prestigeCount: state.prestige.count,
transcendenceCount: state.transcendence?.count ?? 0,
});
const currentUnlocks = state.companions?.unlockedCompanionIds ?? [];
const toAdd = computedUnlocks.filter((id) => {
return !currentUnlocks.includes(id);
});
if (toAdd.length === 0) {
return;
}
setState((previous) => {
if (previous === null) {
return previous;
}
const existingUnlocks = previous.companions?.unlockedCompanionIds ?? [];
const addedIds = computedUnlocks.filter((id) => {
return !existingUnlocks.includes(id);
});
if (addedIds.length === 0) {
return previous;
}
const updatedUnlocks = [ ...existingUnlocks, ...addedIds ];
const activeId = previous.companions?.activeCompanionId ?? null;
const validatedActiveId
= activeId !== null && updatedUnlocks.includes(activeId)
? activeId
: null;
return {
...previous,
companions: {
activeCompanionId: validatedActiveId,
unlockedCompanionIds: updatedUnlocks,
},
};
});
}, [ state ]);
// Game loop via requestAnimationFrame // Game loop via requestAnimationFrame
useEffect(() => { useEffect(() => {
@@ -1292,10 +1390,21 @@ export const GameProvider = ({
newlyFailedQuestsReference.current = []; newlyFailedQuestsReference.current = [];
} }
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) /*
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) { * Auto-save every 30 seconds, or ~10s after a prestige (whichever comes
* first) so recent progress lands without waiting on a heavy force-sync
* (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
*/
const postPrestigeDeadline = postPrestigeSaveDeadlineReference.current;
const shouldAutoSave
= Date.now() - lastSaveReference.current >= autoSaveIntervalMs;
const shouldPostPrestigeSave
= postPrestigeDeadline !== null && Date.now() >= postPrestigeDeadline;
if (shouldAutoSave || shouldPostPrestigeSave) {
lastSaveReference.current = Date.now(); lastSaveReference.current = Date.now();
if (stateReference.current !== null && !isSyncingReference.current) { postPrestigeSaveDeadlineReference.current = null;
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
void saveGame({ void saveGame({
state: stateReference.current, state: stateReference.current,
...signatureReference.current === null ...signatureReference.current === null
@@ -1332,14 +1441,27 @@ export const GameProvider = ({
// Auto-prestige: fire when unlocked, enabled, and threshold is met // Auto-prestige: fire when unlocked, enabled, and threshold is met
const autoState = stateReference.current; const autoState = stateReference.current;
const autoPrestigeThreshold = autoPrestigeThresholdBase
* Math.pow((autoState?.prestige.count ?? 0) + 1, 2.5)
* (autoState?.transcendence?.echoPrestigeThresholdMultiplier ?? 1);
const autoBaseRunestones = Math.min(
Math.floor(
Math.cbrt(
(autoState?.player.totalGoldEarned ?? 0) / autoPrestigeThreshold,
),
) * 15,
400,
);
const autoMaxRunestonesMet
= autoState?.prestige.autoPrestigeMaxRunestonesOnly !== true
|| autoBaseRunestones >= 400;
if ( if (
!isAutoPrestigingReference.current !isAutoPrestigingReference.current
&& autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige") && autoState?.prestige.purchasedUpgradeIds.includes("auto_prestige")
=== true === true
&& autoState.prestige.autoPrestigeEnabled === true && autoState.prestige.autoPrestigeEnabled === true
&& autoState.player.totalGoldEarned && autoState.player.totalGoldEarned >= autoPrestigeThreshold
>= autoPrestigeThresholdBase && autoMaxRunestonesMet
* Math.pow(autoPrestigeThresholdScale, autoState.prestige.count)
) { ) {
isAutoPrestigingReference.current = true; isAutoPrestigingReference.current = true;
void prestigeApi({}). void prestigeApi({}).
@@ -1351,6 +1473,7 @@ export const GameProvider = ({
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!"); sendNotification("⭐ Prestige!", "You have ascended!");
} }
notifyPrestiged();
await reloadSilentReference.current(); await reloadSilentReference.current();
}). }).
catch(() => { catch(() => {
@@ -1419,11 +1542,20 @@ export const GameProvider = ({
}); });
/* /*
* Boss fight modifies server state; clear stale signature so * Boss fight modifies server state; update signature chain so
* the next pre-save or auto-save does not send a mismatched one. * the next pre-save or auto-save sends the correct token.
*/ */
if (result.signature === undefined) {
signatureReference.current = null; signatureReference.current = null;
localStorage.removeItem("elysium_save_signature"); localStorage.removeItem("elysium_save_signature");
} else {
signatureReference.current = result.signature;
localStorage.setItem(
"elysium_save_signature",
result.signature,
);
}
lastSaveReference.current = Date.now();
setAutoBossLastResult({ setAutoBossLastResult({
at: Date.now(), at: Date.now(),
bossName: bossName, bossName: bossName,
@@ -1736,7 +1868,12 @@ export const GameProvider = ({
...previous, ...previous,
equipment: previous.equipment.map((equip) => { equipment: previous.equipment.map((equip) => {
if (equip.id === equipmentId) { if (equip.id === equipmentId) {
return { ...equip, equipped: !slotAlreadyEquipped, owned: true }; return {
...equip,
equipped: !slotAlreadyEquipped,
everOwned: true,
owned: true,
};
} }
return equip; return equip;
}), }),
@@ -1770,6 +1907,13 @@ export const GameProvider = ({
}, },
}; };
}); });
/*
* Buying a prestige upgrade mutates DB state; clear the cached signature
* so the next auto-save doesn't collide with a stale one.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} catch (error_: unknown) { } catch (error_: unknown) {
logError("buy_prestige_upgrade", error_); logError("buy_prestige_upgrade", error_);
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
@@ -2005,6 +2149,22 @@ export const GameProvider = ({
}); });
}, []); }, []);
const toggleAutoPrestigeMaxRunestones = useCallback(() => {
setState((previous) => {
if (previous === null) {
return previous;
}
return {
...previous,
prestige: {
...previous.prestige,
autoPrestigeMaxRunestonesOnly:
previous.prestige.autoPrestigeMaxRunestonesOnly !== true,
},
};
});
}, []);
const toggleAutoQuest = useCallback(() => { const toggleAutoQuest = useCallback(() => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -2084,6 +2244,14 @@ export const GameProvider = ({
} }
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
if (result.signature === undefined) {
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} else {
signatureReference.current = result.signature;
localStorage.setItem("elysium_save_signature", result.signature);
}
lastSaveReference.current = Date.now();
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
} catch (error_: unknown) { } catch (error_: unknown) {
const bossErrorMessage const bossErrorMessage
@@ -2359,6 +2527,13 @@ export const GameProvider = ({
[ numberFormat ], [ numberFormat ],
); );
const formatInteger = useCallback(
(value: number) => {
return formatIntegerUtil(value);
},
[],
);
const contextValue = useMemo<GameContextValue>(() => { const contextValue = useMemo<GameContextValue>(() => {
return { return {
apotheosis, apotheosis,
@@ -2397,6 +2572,7 @@ export const GameProvider = ({
flushBossLoreToasts, flushBossLoreToasts,
forceSync, forceSync,
forceUnlocks, forceUnlocks,
formatInteger,
formatNumber, formatNumber,
handleClick, handleClick,
inGuild, inGuild,
@@ -2405,6 +2581,7 @@ export const GameProvider = ({
lastSavedAt, lastSavedAt,
loginBonus, loginBonus,
loginStreak, loginStreak,
notifyPrestiged,
numberFormat, numberFormat,
offlineEssence, offlineEssence,
offlineGold, offlineGold,
@@ -2428,6 +2605,7 @@ export const GameProvider = ({
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast, triggerPrestigeToast,
@@ -2443,6 +2621,7 @@ export const GameProvider = ({
bossError, bossError,
completedQuestToasts, completedQuestToasts,
failedQuestToasts, failedQuestToasts,
formatInteger,
formatNumber, formatNumber,
buyAdventurer, buyAdventurer,
buyEchoUpgrade, buyEchoUpgrade,
@@ -2480,6 +2659,7 @@ export const GameProvider = ({
lastSavedAt, lastSavedAt,
loginBonus, loginBonus,
loginStreak, loginStreak,
notifyPrestiged,
numberFormat, numberFormat,
offlineEssence, offlineEssence,
offlineGold, offlineGold,
@@ -2502,6 +2682,7 @@ export const GameProvider = ({
toggleAutoAdventurer, toggleAutoAdventurer,
toggleAutoBoss, toggleAutoBoss,
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast, triggerPrestigeToast,
+72 -72
View File
@@ -20,7 +20,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
durationSeconds: 3600, durationSeconds: 60,
id: "verdant_meadow", id: "verdant_meadow",
name: "The Verdant Meadow", name: "The Verdant Meadow",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -28,7 +28,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
durationSeconds: 7200, durationSeconds: 75,
id: "whispering_forest", id: "whispering_forest",
name: "The Whispering Forest", name: "The Whispering Forest",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -36,7 +36,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
durationSeconds: 10_800, durationSeconds: 90,
id: "ancient_grove", id: "ancient_grove",
name: "The Ancient Grove", name: "The Ancient Grove",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -44,7 +44,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
durationSeconds: 14_400, durationSeconds: 105,
id: "forbidden_glen", id: "forbidden_glen",
name: "The Forbidden Glen", name: "The Forbidden Glen",
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -54,7 +54,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
durationSeconds: 7200, durationSeconds: 75,
id: "collapsed_outpost", id: "collapsed_outpost",
name: "The Collapsed Outpost", name: "The Collapsed Outpost",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -62,7 +62,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
durationSeconds: 14_400, durationSeconds: 90,
id: "cursed_lake", id: "cursed_lake",
name: "The Cursed Lake", name: "The Cursed Lake",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -70,7 +70,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
durationSeconds: 21_600, durationSeconds: 120,
id: "runic_archive", id: "runic_archive",
name: "The Runic Archive", name: "The Runic Archive",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -78,7 +78,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
durationSeconds: 28_800, durationSeconds: 150,
id: "dragon_throne", id: "dragon_throne",
name: "The Dragon's Throne", name: "The Dragon's Throne",
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -88,7 +88,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
durationSeconds: 10_800, durationSeconds: 105,
id: "glacial_cave", id: "glacial_cave",
name: "The Glacial Cave", name: "The Glacial Cave",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -96,7 +96,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
durationSeconds: 21_600, durationSeconds: 135,
id: "frozen_tundra", id: "frozen_tundra",
name: "The Frozen Tundra", name: "The Frozen Tundra",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -104,7 +104,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
durationSeconds: 32_400, durationSeconds: 180,
id: "void_rift", id: "void_rift",
name: "The Void Rift", name: "The Void Rift",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -112,7 +112,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
durationSeconds: 43_200, durationSeconds: 240,
id: "summit_shrine", id: "summit_shrine",
name: "The Summit Shrine", name: "The Summit Shrine",
zoneId: "frozen_peaks", zoneId: "frozen_peaks",
@@ -122,7 +122,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
durationSeconds: 18_000, durationSeconds: 105,
id: "fog_hollow", id: "fog_hollow",
name: "The Fog Hollow", name: "The Fog Hollow",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -130,7 +130,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
durationSeconds: 36_000, durationSeconds: 150,
id: "dark_grotto", id: "dark_grotto",
name: "The Dark Grotto", name: "The Dark Grotto",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -138,7 +138,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
durationSeconds: 54_000, durationSeconds: 195,
id: "cursed_barrow", id: "cursed_barrow",
name: "The Cursed Barrow", name: "The Cursed Barrow",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -146,7 +146,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
durationSeconds: 72_000, durationSeconds: 255,
id: "marsh_depths", id: "marsh_depths",
name: "The Marsh Depths", name: "The Marsh Depths",
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
@@ -156,7 +156,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
durationSeconds: 25_200, durationSeconds: 120,
id: "magma_tunnel", id: "magma_tunnel",
name: "The Magma Tunnel", name: "The Magma Tunnel",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -164,7 +164,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
durationSeconds: 50_400, durationSeconds: 165,
id: "forge_chamber", id: "forge_chamber",
name: "The Forge Chamber", name: "The Forge Chamber",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -172,7 +172,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
durationSeconds: 75_600, durationSeconds: 225,
id: "fire_temple", id: "fire_temple",
name: "The Fire Temple", name: "The Fire Temple",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -180,7 +180,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
durationSeconds: 100_800, durationSeconds: 300,
id: "core_descent", id: "core_descent",
name: "The Core Descent", name: "The Core Descent",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
@@ -190,7 +190,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
durationSeconds: 36_000, durationSeconds: 150,
id: "star_field", id: "star_field",
name: "The Star Field", name: "The Star Field",
zoneId: "astral_void", zoneId: "astral_void",
@@ -198,7 +198,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
durationSeconds: 72_000, durationSeconds: 210,
id: "probability_sea", id: "probability_sea",
name: "The Probability Sea", name: "The Probability Sea",
zoneId: "astral_void", zoneId: "astral_void",
@@ -206,7 +206,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
durationSeconds: 108_000, durationSeconds: 270,
id: "void_current", id: "void_current",
name: "The Void Current", name: "The Void Current",
zoneId: "astral_void", zoneId: "astral_void",
@@ -214,7 +214,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
durationSeconds: 144_000, durationSeconds: 375,
id: "null_zenith", id: "null_zenith",
name: "The Null Zenith", name: "The Null Zenith",
zoneId: "astral_void", zoneId: "astral_void",
@@ -224,7 +224,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
durationSeconds: 43_200, durationSeconds: 300,
id: "light_spire", id: "light_spire",
name: "The Light Spire", name: "The Light Spire",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -232,7 +232,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
durationSeconds: 86_400, durationSeconds: 390,
id: "choir_hall", id: "choir_hall",
name: "The Choir Hall", name: "The Choir Hall",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -240,7 +240,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
durationSeconds: 129_600, durationSeconds: 540,
id: "divine_court", id: "divine_court",
name: "The Divine Court", name: "The Divine Court",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -248,7 +248,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
durationSeconds: 172_800, durationSeconds: 720,
id: "celestial_vault", id: "celestial_vault",
name: "The Celestial Vault", name: "The Celestial Vault",
zoneId: "celestial_reaches", zoneId: "celestial_reaches",
@@ -258,7 +258,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
durationSeconds: 50_400, durationSeconds: 420,
id: "trench_entrance", id: "trench_entrance",
name: "The Trench Entrance", name: "The Trench Entrance",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -266,7 +266,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
durationSeconds: 100_800, durationSeconds: 570,
id: "deep_current", id: "deep_current",
name: "The Deep Current", name: "The Deep Current",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -274,7 +274,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
durationSeconds: 151_200, durationSeconds: 750,
id: "sunless_chamber", id: "sunless_chamber",
name: "The Sunless Chamber", name: "The Sunless Chamber",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -282,7 +282,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
durationSeconds: 201_600, durationSeconds: 990,
id: "the_waiting_place", id: "the_waiting_place",
name: "The Waiting Place", name: "The Waiting Place",
zoneId: "abyssal_trench", zoneId: "abyssal_trench",
@@ -292,7 +292,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
durationSeconds: 57_600, durationSeconds: 540,
id: "demon_market", id: "demon_market",
name: "The Demon Market", name: "The Demon Market",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -300,7 +300,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
durationSeconds: 115_200, durationSeconds: 720,
id: "torment_hall", id: "torment_hall",
name: "The Torment Hall", name: "The Torment Hall",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -308,7 +308,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
durationSeconds: 172_800, durationSeconds: 960,
id: "soul_forge", id: "soul_forge",
name: "The Soul Forge", name: "The Soul Forge",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -316,7 +316,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
durationSeconds: 230_400, durationSeconds: 1320,
id: "lords_chamber", id: "lords_chamber",
name: "The Lords' Chamber", name: "The Lords' Chamber",
zoneId: "infernal_court", zoneId: "infernal_court",
@@ -326,7 +326,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
durationSeconds: 64_800, durationSeconds: 750,
id: "facet_approach", id: "facet_approach",
name: "The Facet Approach", name: "The Facet Approach",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -334,7 +334,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
durationSeconds: 129_600, durationSeconds: 1005,
id: "calculation_chamber", id: "calculation_chamber",
name: "The Calculation Chamber", name: "The Calculation Chamber",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -342,7 +342,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
durationSeconds: 194_400, durationSeconds: 1320,
id: "mirror_hall", id: "mirror_hall",
name: "The Mirror Hall", name: "The Mirror Hall",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -350,7 +350,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
durationSeconds: 259_200, durationSeconds: 1800,
id: "core_access", id: "core_access",
name: "The Core Access", name: "The Core Access",
zoneId: "crystalline_spire", zoneId: "crystalline_spire",
@@ -360,7 +360,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
durationSeconds: 72_000, durationSeconds: 990,
id: "threshold", id: "threshold",
name: "The Threshold", name: "The Threshold",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -368,7 +368,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
durationSeconds: 144_000, durationSeconds: 1320,
id: "inner_silence", id: "inner_silence",
name: "The Inner Silence", name: "The Inner Silence",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -376,7 +376,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
durationSeconds: 216_000, durationSeconds: 1800,
id: "resonance_chamber", id: "resonance_chamber",
name: "The Resonance Chamber", name: "The Resonance Chamber",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -384,7 +384,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
durationSeconds: 288_000, durationSeconds: 2400,
id: "sanctum_heart", id: "sanctum_heart",
name: "The Sanctum Heart", name: "The Sanctum Heart",
zoneId: "void_sanctum", zoneId: "void_sanctum",
@@ -394,7 +394,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
durationSeconds: 79_200, durationSeconds: 1320,
id: "throne_approach", id: "throne_approach",
name: "The Throne Approach", name: "The Throne Approach",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -402,7 +402,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
durationSeconds: 158_400, durationSeconds: 1800,
id: "dominion_hall", id: "dominion_hall",
name: "The Dominion Hall", name: "The Dominion Hall",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -410,7 +410,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
durationSeconds: 237_600, durationSeconds: 2400,
id: "eternity_vault", id: "eternity_vault",
name: "The Eternity Vault", name: "The Eternity Vault",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -418,7 +418,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
durationSeconds: 316_800, durationSeconds: 3600,
id: "the_seat", id: "the_seat",
name: "The Seat", name: "The Seat",
zoneId: "eternal_throne", zoneId: "eternal_throne",
@@ -428,7 +428,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
durationSeconds: 86_400, durationSeconds: 1800,
id: "creation_storm", id: "creation_storm",
name: "The Creation Storm", name: "The Creation Storm",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -436,7 +436,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
durationSeconds: 172_800, durationSeconds: 2700,
id: "unmaking_sea", id: "unmaking_sea",
name: "The Unmaking Sea", name: "The Unmaking Sea",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -444,7 +444,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
durationSeconds: 259_200, durationSeconds: 3600,
id: "probability_void", id: "probability_void",
name: "The Probability Void", name: "The Probability Void",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -452,7 +452,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
durationSeconds: 345_600, durationSeconds: 4500,
id: "chaos_core", id: "chaos_core",
name: "The Chaos Core", name: "The Chaos Core",
zoneId: "primordial_chaos", zoneId: "primordial_chaos",
@@ -462,7 +462,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
durationSeconds: 93_600, durationSeconds: 2400,
id: "first_horizon", id: "first_horizon",
name: "The First Horizon", name: "The First Horizon",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -470,7 +470,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
durationSeconds: 187_200, durationSeconds: 3600,
id: "middle_nowhere", id: "middle_nowhere",
name: "The Middle of Nowhere", name: "The Middle of Nowhere",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -478,7 +478,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
durationSeconds: 280_800, durationSeconds: 4500,
id: "edge_approach", id: "edge_approach",
name: "The Edge Approach", name: "The Edge Approach",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -486,7 +486,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
durationSeconds: 374_400, durationSeconds: 5700,
id: "the_furthest", id: "the_furthest",
name: "The Furthest", name: "The Furthest",
zoneId: "infinite_expanse", zoneId: "infinite_expanse",
@@ -496,7 +496,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
durationSeconds: 100_800, durationSeconds: 5400,
id: "workshop_entrance", id: "workshop_entrance",
name: "The Workshop Entrance", name: "The Workshop Entrance",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -504,7 +504,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
durationSeconds: 201_600, durationSeconds: 7200,
id: "creation_floor", id: "creation_floor",
name: "The Creation Floor", name: "The Creation Floor",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -512,7 +512,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
durationSeconds: 302_400, durationSeconds: 9000,
id: "master_forge", id: "master_forge",
name: "The Master Forge", name: "The Master Forge",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -520,7 +520,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
durationSeconds: 403_200, durationSeconds: 12_600,
id: "forge_core", id: "forge_core",
name: "The Forge Core", name: "The Forge Core",
zoneId: "reality_forge", zoneId: "reality_forge",
@@ -530,7 +530,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
durationSeconds: 108_000, durationSeconds: 7200,
id: "outer_current", id: "outer_current",
name: "The Outer Current", name: "The Outer Current",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -538,7 +538,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
durationSeconds: 216_000, durationSeconds: 10_800,
id: "debris_field", id: "debris_field",
name: "The Debris Field", name: "The Debris Field",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -546,7 +546,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
durationSeconds: 324_000, durationSeconds: 14_400,
id: "force_confluence", id: "force_confluence",
name: "The Force Confluence", name: "The Force Confluence",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -554,7 +554,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
durationSeconds: 432_000, durationSeconds: 18_000,
id: "eye_approach", id: "eye_approach",
name: "The Eye Approach", name: "The Eye Approach",
zoneId: "cosmic_maelstrom", zoneId: "cosmic_maelstrom",
@@ -564,7 +564,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
durationSeconds: 115_200, durationSeconds: 9000,
id: "first_steps", id: "first_steps",
name: "The First Steps", name: "The First Steps",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -572,7 +572,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
durationSeconds: 230_400, durationSeconds: 16_200,
id: "ancient_archive", id: "ancient_archive",
name: "The Ancient Archive", name: "The Ancient Archive",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -580,7 +580,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
durationSeconds: 345_600, durationSeconds: 22_500,
id: "memory_chamber", id: "memory_chamber",
name: "The Memory Chamber", name: "The Memory Chamber",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -588,7 +588,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
durationSeconds: 460_800, durationSeconds: 29_700,
id: "the_oldest_place", id: "the_oldest_place",
name: "The Oldest Place", name: "The Oldest Place",
zoneId: "primeval_sanctum", zoneId: "primeval_sanctum",
@@ -598,7 +598,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
durationSeconds: 129_600, durationSeconds: 10_800,
id: "edge_of_everything", id: "edge_of_everything",
name: "The Edge of Everything", name: "The Edge of Everything",
zoneId: "the_absolute", zoneId: "the_absolute",
@@ -606,7 +606,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
durationSeconds: 259_200, durationSeconds: 21_600,
id: "truth_approach", id: "truth_approach",
name: "The Truth Approach", name: "The Truth Approach",
zoneId: "the_absolute", zoneId: "the_absolute",
@@ -614,7 +614,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
durationSeconds: 388_800, durationSeconds: 32_400,
id: "final_antechamber", id: "final_antechamber",
name: "The Final Antechamber", name: "The Final Antechamber",
zoneId: "the_absolute", zoneId: "the_absolute",
@@ -622,7 +622,7 @@ export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
{ {
description: description:
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
durationSeconds: 518_400, durationSeconds: 43_200,
id: "the_absolute_heart", id: "the_absolute_heart",
name: "The Absolute Heart", name: "The Absolute Heart",
zoneId: "the_absolute", zoneId: "the_absolute",
+6 -6
View File
@@ -94,20 +94,20 @@ export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
{ {
category: "income", category: "income",
description: description:
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.", "The oldest runes, carved before memory began, yield their secrets at last. All production ×200.",
id: "income_10", id: "income_10",
multiplier: 500, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 30_000, runestonesCost: 15_000,
}, },
{ {
category: "income", category: "income",
description: description:
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.", "Eternal runes resonate with the heartbeat of creation itself. All production ×500.",
id: "income_11", id: "income_11",
multiplier: 1000, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 80_000, runestonesCost: 35_000,
}, },
// ── Click Power ─────────────────────────────────────────────────────────── // ── Click Power ───────────────────────────────────────────────────────────
{ {
+15 -15
View File
@@ -13,7 +13,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Income multipliers ────────────────────────────────────────────────────── // ── Income multipliers ──────────────────────────────────────────────────────
{ {
category: "income", category: "income",
cost: 5, cost: 2,
description: description:
"The echoes of past runs linger, amplifying your guild's income by 25%.", "The echoes of past runs linger, amplifying your guild's income by 25%.",
id: "echo_income_1", id: "echo_income_1",
@@ -22,7 +22,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 10, cost: 4,
description: description:
"Your transcendent experience resonates through your guild, boosting income by 50%.", "Your transcendent experience resonates through your guild, boosting income by 50%.",
id: "echo_income_2", id: "echo_income_2",
@@ -31,7 +31,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 20, cost: 8,
description: description:
"The harmony of multiple timelines surges through your guild, doubling its income.", "The harmony of multiple timelines surges through your guild, doubling its income.",
id: "echo_income_3", id: "echo_income_3",
@@ -40,7 +40,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 40, cost: 16,
description: description:
"Ethereal energy overflows from your transcendence, tripling your guild's income.", "Ethereal energy overflows from your transcendence, tripling your guild's income.",
id: "echo_income_4", id: "echo_income_4",
@@ -49,7 +49,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "income", category: "income",
cost: 80, cost: 32,
description: description:
"The infinite chorus of every run you've ever played amplifies your guild fivefold.", "The infinite chorus of every run you've ever played amplifies your guild fivefold.",
id: "echo_income_5", id: "echo_income_5",
@@ -60,7 +60,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Combat multipliers ────────────────────────────────────────────────────── // ── Combat multipliers ──────────────────────────────────────────────────────
{ {
category: "combat", category: "combat",
cost: 5, cost: 2,
description: description:
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.", "Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
id: "echo_combat_1", id: "echo_combat_1",
@@ -69,7 +69,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "combat", category: "combat",
cost: 15, cost: 6,
description: description:
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.", "Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
id: "echo_combat_2", id: "echo_combat_2",
@@ -78,7 +78,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "combat", category: "combat",
cost: 35, cost: 12,
description: description:
"Your warriors carry the strength of every fallen timeline, doubling party DPS.", "Your warriors carry the strength of every fallen timeline, doubling party DPS.",
id: "echo_combat_3", id: "echo_combat_3",
@@ -89,7 +89,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Prestige threshold reductions ────────────────────────────────────────── // ── Prestige threshold reductions ──────────────────────────────────────────
{ {
category: "prestige_threshold", category: "prestige_threshold",
cost: 8, cost: 3,
description: description:
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.", "Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
id: "echo_prestige_threshold_1", id: "echo_prestige_threshold_1",
@@ -98,7 +98,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "prestige_threshold", category: "prestige_threshold",
cost: 20, cost: 6,
description: description:
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.", "You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
id: "echo_prestige_threshold_2", id: "echo_prestige_threshold_2",
@@ -109,7 +109,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Prestige runestone multipliers ───────────────────────────────────────── // ── Prestige runestone multipliers ─────────────────────────────────────────
{ {
category: "prestige_runestones", category: "prestige_runestones",
cost: 8, cost: 3,
description: description:
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.", "Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
id: "echo_prestige_runestones_1", id: "echo_prestige_runestones_1",
@@ -118,7 +118,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "prestige_runestones", category: "prestige_runestones",
cost: 20, cost: 6,
description: description:
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.", "You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
id: "echo_prestige_runestones_2", id: "echo_prestige_runestones_2",
@@ -129,7 +129,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ─────────────────────────────────────────────────── // ── Echo meta multipliers ───────────────────────────────────────────────────
{ {
category: "echo_meta", category: "echo_meta",
cost: 10, cost: 15,
description: description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.", "Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1", id: "echo_meta_1",
@@ -138,7 +138,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 25, cost: 45,
description: description:
"Each loop of existence makes the next more powerful — future echo yields +50%.", "Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2", id: "echo_meta_2",
@@ -147,7 +147,7 @@ export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
}, },
{ {
category: "echo_meta", category: "echo_meta",
cost: 50, cost: 100,
description: description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.", "You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3", id: "echo_meta_3",
@@ -0,0 +1,57 @@
/**
* @file Shared achievement progress calculation for Elysium.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { AchievementCondition, GameState } from "@elysium/types";
/**
* Computes the player's current progress toward an achievement condition.
* Stat-based conditions combine the frozen lifetime total from the last
* prestige/transcendence/apotheosis reset with this run's live contribution,
* so progress reflects the true all-time total rather than resetting to zero
* every reset. `prestigeCount`, `equipmentOwned`, `uniqueQuestsCompleted`,
* and `uniqueBossesDefeated` already read persistent state directly (via
* `everOwned`/`everCompleted`/`everDefeated` flags) and need no such
* combination.
* @param condition - The achievement's unlock condition.
* @param state - The current game state to evaluate progress against.
* @returns The current numeric progress toward the condition's target amount.
*/
const getAchievementProgress = (
condition: AchievementCondition,
state: GameState,
): number => {
switch (condition.type) {
case "totalGoldEarned":
return state.player.lifetimeGoldEarned + state.player.totalGoldEarned;
case "totalClicks":
return state.player.lifetimeClicks + state.player.totalClicks;
case "uniqueBossesDefeated":
return state.bosses.filter((boss) => {
return boss.everDefeated === true || boss.status === "defeated";
}).length;
case "uniqueQuestsCompleted":
return state.quests.filter((quest) => {
return quest.everCompleted === true || quest.status === "completed";
}).length;
case "adventurerTotal":
return (
state.player.lifetimeAdventurersRecruited
+ 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.everOwned === true || item.owned;
}).length;
default:
/* V8 ignore next -- @preserve */ return 0;
}
};
export { getAchievementProgress };
+99 -65
View File
@@ -22,6 +22,7 @@ import {
import { EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { EXPLORATION_AREAS } from "../data/explorations.js"; import { EXPLORATION_AREAS } from "../data/explorations.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { getAchievementProgress } from "./achievementProgress.js";
/** /**
* Checks all achievements against the current game state and returns an updated * Checks all achievements against the current game state and returns an updated
@@ -37,45 +38,7 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
} }
const { condition } = achievement; const { condition } = achievement;
let met = false; const met = getAchievementProgress(condition, state) >= condition.amount;
switch (condition.type) {
case "totalGoldEarned":
met = state.player.totalGoldEarned >= condition.amount;
break;
case "totalClicks":
met = state.player.totalClicks >= condition.amount;
break;
case "bossesDefeated":
met
= state.bosses.filter((boss) => {
return boss.status === "defeated";
}).length >= condition.amount;
break;
case "questsCompleted":
met
= state.quests.filter((quest) => {
return quest.status === "completed";
}).length >= condition.amount;
break;
case "adventurerTotal":
met
= state.adventurers.reduce((sum, adventurer) => {
return sum + adventurer.count;
}, 0) >= condition.amount;
break;
case "prestigeCount":
met = state.prestige.count >= condition.amount;
break;
case "equipmentOwned":
met
= state.equipment.filter((item) => {
return item.owned;
}).length >= condition.amount;
break;
default:
/* V8 ignore next -- @preserve */ break;
}
return met return met
? { ...achievement, unlockedAt: now } ? { ...achievement, unlockedAt: now }
@@ -95,29 +58,29 @@ export const PRESTIGE_COMBAT_BASE = 4;
export const RESOURCE_CAP = 1e300; export const RESOURCE_CAP = 1e300;
/** /**
* Probability of quest failure per zone scales from 10% (early game) to 40% (end game). * Probability of quest failure per zone scales from 4% (early game) to 15% (end game).
* On failure the quest resets to "available" with no rewards; the player must wait the * On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt. * full duration again on their next attempt.
*/ */
export const zoneFailureChance: Record<string, number> = { export const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24, abyssal_trench: 0.09,
astral_void: 0.2, astral_void: 0.08,
celestial_reaches: 0.22, celestial_reaches: 0.08,
cosmic_maelstrom: 0.4, cosmic_maelstrom: 0.15,
crystalline_spire: 0.28, crystalline_spire: 0.11,
eternal_throne: 0.32, eternal_throne: 0.12,
frozen_peaks: 0.14, frozen_peaks: 0.05,
infernal_court: 0.26, infernal_court: 0.1,
infinite_expanse: 0.36, infinite_expanse: 0.14,
primeval_sanctum: 0.4, primeval_sanctum: 0.15,
primordial_chaos: 0.34, primordial_chaos: 0.13,
reality_forge: 0.38, reality_forge: 0.14,
shadow_marshes: 0.16, shadow_marshes: 0.06,
shattered_ruins: 0.12, shattered_ruins: 0.05,
the_absolute: 0.4, the_absolute: 0.15,
verdant_vale: 0.1, verdant_vale: 0.04,
void_sanctum: 0.3, void_sanctum: 0.11,
volcanic_depths: 0.18, volcanic_depths: 0.07,
}; };
/** /**
@@ -451,18 +414,42 @@ export const computePartyCombatPower = (state: GameState): number => {
}; };
const basePrestigeThreshold = 1_000_000; const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15; const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200; const maxBaseRunestones = 400;
const milestoneIntervalClient = 5;
const milestoneRunestonesPerIntervalClient = 25;
/** /**
* Computes the projected runestone reward if the player were to prestige right now. * Computes the milestone runestone bonus the player would receive if they
* Mirrors the server-side calculateRunestones formula exactly. * prestiged right now. Mirrors the server-side calculateMilestoneBonus formula:
* every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param state - The current game state.
* @returns The milestone runestone bonus, or 0 if the next prestige isn't a milestone.
*/
export const computeProjectedMilestoneBonus = (state: GameState): number => {
const nextPrestigeCount = state.prestige.count + 1;
if (nextPrestigeCount % milestoneIntervalClient !== 0) {
return 0;
}
const milestoneNumber = nextPrestigeCount / milestoneIntervalClient;
return (
milestoneNumber * milestoneNumber * milestoneRunestonesPerIntervalClient
);
};
/**
* Computes the projected runestone reward if the player were to prestige right now,
* including any milestone bonus, so callers always see the true total they'd receive.
* Mirrors the server-side calculateRunestones + calculateMilestoneBonus formulas exactly.
* @param state - The current game state. * @param state - The current game state.
* @returns The number of runestones the player would earn from a prestige now. * @returns The number of runestones the player would earn from a prestige now.
*/ */
export const computeProjectedRunestones = (state: GameState): number => { export const computeProjectedRunestones = (state: GameState): number => {
const { count, purchasedUpgradeIds } = state.prestige; const { count, purchasedUpgradeIds } = state.prestige;
const threshold = basePrestigeThreshold * Math.pow(count + 1, 2); const thresholdMult: number
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
const threshold
= basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
const base = Math.min( const base = Math.min(
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold)) Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
* runestonesPerPrestigeLevelClient, * runestonesPerPrestigeLevelClient,
@@ -477,7 +464,10 @@ export const computeProjectedRunestones = (state: GameState): number => {
const runestoneMult = gain1Mult * gain2Mult; const runestoneMult = gain1Mult * gain2Mult;
const echoMult: number const echoMult: number
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1; = state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult); return (
Math.floor(base * runestoneMult * echoMult)
+ computeProjectedMilestoneBonus(state)
);
}; };
/** /**
@@ -646,6 +636,7 @@ export const applyTick = (
return { return {
...item, ...item,
equipped: slotEmpty || item.equipped, equipped: slotEmpty || item.equipped,
everOwned: true,
owned: true, owned: true,
}; };
}); });
@@ -728,7 +719,11 @@ export const applyTick = (
return zoneBoss.zoneId === boss.zoneId; return zoneBoss.zoneId === boss.zoneId;
}); });
const [ firstBoss ] = zoneBosses; const [ firstBoss ] = zoneBosses;
if (firstBoss?.id === boss.id && boss.status === "locked") { const questOk
= boss.unlockQuestId === null
|| boss.unlockQuestId === undefined
|| completedIds.has(boss.unlockQuestId);
if (firstBoss?.id === boss.id && boss.status === "locked" && questOk) {
return { ...boss, status: "available" as const }; return { ...boss, status: "available" as const };
} }
} }
@@ -736,6 +731,45 @@ export const applyTick = (
}); });
} }
/*
* Activate any locked boss whose own unlock quest just became satisfied
* this tick, independent of a zone-unlock or boss-defeat event.
*/
updatedBosses = updatedBosses.map((boss) => {
if (boss.status !== "locked") {
return boss;
}
if (boss.unlockQuestId === null || boss.unlockQuestId === undefined) {
return boss;
}
if (!completedIds.has(boss.unlockQuestId)) {
return boss;
}
if (boss.prestigeRequirement > state.prestige.count) {
return boss;
}
const bossZone = updatedZones.find((zone) => {
return zone.id === boss.zoneId;
});
if (bossZone?.status !== "unlocked") {
return boss;
}
const zoneBosses = updatedBosses.filter((zoneBoss) => {
return zoneBoss.zoneId === boss.zoneId;
});
const bossIndex = zoneBosses.findIndex((zoneBoss) => {
return zoneBoss.id === boss.id;
});
const previousZoneBoss = zoneBosses[bossIndex - 1];
if (
previousZoneBoss !== undefined
&& previousZoneBoss.status !== "defeated"
) {
return boss;
}
return { ...boss, status: "available" as const };
});
// Count quests newly completed this tick and update daily challenge progress // Count quests newly completed this tick and update daily challenge progress
const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => { const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => {
const wasNotCompleted = state.quests[index]?.status !== "completed"; const wasNotCompleted = state.quests[index]?.status !== "completed";
+11 -2
View File
@@ -4455,6 +4455,7 @@ body::before {
color: var(--colour-text); color: var(--colour-text);
line-height: 1.6; line-height: 1.6;
font-style: italic; font-style: italic;
font-size: 0.9rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--colour-surface); background: var(--colour-surface);
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -4583,12 +4584,20 @@ body::before {
.story-chapter-banner { .story-chapter-banner {
border-radius: var(--radius); border-radius: var(--radius);
height: 220px; height: 260px;
margin-bottom: 1rem; margin-bottom: 1rem;
object-fit: cover; object-fit: contain;
cursor: pointer;
width: 100%; width: 100%;
} }
.story-chapter-banner-full {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius);
}
.codex-entry-image { .codex-entry-image {
border-radius: var(--radius); border-radius: var(--radius);
height: 80px; height: 80px;
+32 -1
View File
@@ -108,6 +108,35 @@ const formatEngineering = (value: number): string => {
return `${mantissa.toFixed(2)}E${String(engExp)}`; return `${mantissa.toFixed(2)}E${String(engExp)}`;
}; };
/**
* Formats a whole-number value for display without decimal places.
* Uses the same suffix/letter logic as formatNumber but rounds to integers.
* @param value - The integer value to format.
* @returns The formatted string with no decimal places.
*/
const formatInteger = (value: number): string => {
if (!Number.isFinite(value) || Number.isNaN(value)) {
return "0";
}
if (value < 0) {
return `-${formatInteger(-value)}`;
}
if (value >= Math.pow(10, letterBaseExp)) {
const exp = Math.floor(Math.log10(value));
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
const steps = stepsAboveBase * 3;
const divisorExp = letterBaseExp + steps;
const divisor = Math.pow(10, divisorExp);
return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`;
}
for (const { threshold, suffix } of namedSuffixes) {
if (value >= threshold) {
return `${String(Math.floor(value / threshold))}${suffix}`;
}
}
return String(Math.floor(value));
};
/** /**
* Formats a number for display using the player's chosen notation style. * Formats a number for display using the player's chosen notation style.
* Negative values are formatted with a leading minus sign. * Negative values are formatted with a leading minus sign.
@@ -115,7 +144,7 @@ const formatEngineering = (value: number): string => {
* @param format - The notation style to use. * @param format - The notation style to use.
* @returns The formatted number string. * @returns The formatted number string.
*/ */
export const formatNumber = ( const formatNumber = (
value: number, value: number,
format: NumberFormat = "suffix", format: NumberFormat = "suffix",
): string => { ): string => {
@@ -139,3 +168,5 @@ export const formatNumber = (
} }
} }
}; };
export { formatInteger, formatNumber };
+11 -3
View File
@@ -5,14 +5,22 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */ /* eslint-disable no-console -- Errors are forwarded to backend via the overridden console.error */
import { ValidationError } from "../api/client.js";
/** /**
* Logs an error to the backend telemetry service. * Logs an error to the backend telemetry service.
* Accepts the same arguments as console.error conventionally a context string * ValidationErrors (4xx API rejections) are downgraded to console.warn so they
* followed by the error value. * are not forwarded to the error-email pipeline they are expected server responses.
* @param logArguments - The values to log, forwarded directly to console.error. * @param logArguments - The values to log, forwarded directly to console.error or console.warn.
*/ */
const logError = (...logArguments: Array<unknown>): void => { const logError = (...logArguments: Array<unknown>): void => {
const isValidation = logArguments.some((argument) => {
return argument instanceof ValidationError;
});
if (isValidation) {
console.warn(...logArguments);
return;
}
console.error(...logArguments); console.error(...logArguments);
}; };
+12
View File
@@ -49,6 +49,18 @@ const initialiseFrontendLogger = (): void => {
? argument ? argument
: JSON.stringify(argument); : JSON.stringify(argument);
}).join(" "); }).join(" ");
/*
* Ignore errors originating entirely from third-party scripts (e.g. AdSense).
* Stack frames from our own code reference elysium.nhcarrigan.com or localhost;
* if none are present but external URLs are, the error is not actionable.
*/
const hasExternalUrl = (/https?:\/\//u).test(message);
const hasOurDomain = message.includes("elysium.nhcarrigan.com");
const hasOwnFrame = hasOurDomain || message.includes("localhost");
if (hasExternalUrl && !hasOwnFrame) {
return;
}
const context = "console.error"; const context = "console.error";
post("/api/fe/error", { context, message }); post("/api/fe/error", { context, message });
}; };
+57 -1
View File
@@ -8,7 +8,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatNumber } from "../src/utils/format.js"; import { formatInteger, formatNumber } from "../src/utils/format.js";
describe("formatNumber", () => { describe("formatNumber", () => {
describe("edge cases", () => { describe("edge cases", () => {
@@ -142,3 +142,59 @@ describe("formatNumber", () => {
}); });
}); });
}); });
describe("formatInteger", () => {
describe("edge cases", () => {
it("should return '0' for NaN", () => {
expect(formatInteger(Number.NaN)).toBe("0");
});
it("should return '0' for Infinity", () => {
expect(formatInteger(Infinity)).toBe("0");
});
it("should format negative integers with a leading minus sign", () => {
expect(formatInteger(-1500)).toBe("-1K");
});
it("should format zero as '0'", () => {
expect(formatInteger(0)).toBe("0");
});
it("should format small integers without decimals", () => {
expect(formatInteger(42)).toBe("42");
});
});
describe("named suffixes", () => {
it("should format thousands with K suffix and no decimals", () => {
expect(formatInteger(1500)).toBe("1K");
});
it("should format millions with M suffix and no decimals", () => {
expect(formatInteger(2_500_000)).toBe("2M");
});
it("should format billions with B suffix", () => {
expect(formatInteger(3_000_000_000)).toBe("3B");
});
it("should format trillions with T suffix", () => {
expect(formatInteger(1e12)).toBe("1T");
});
it("should format quintillions with Qi suffix", () => {
expect(formatInteger(1e18)).toBe("1Qi");
});
});
describe("letter suffixes", () => {
it("should format values >= 1e36 with letter suffix 'a'", () => {
expect(formatInteger(1e36)).toBe("1a");
});
it("should format values >= 1e39 with letter suffix 'b'", () => {
expect(formatInteger(1e39)).toBe("1b");
});
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* Dev Puppeteer script intercepts /api/game/load and injects a fresh
* game state built from the actual compiled data files, so we can browse
* the game UI without auth or a real DB record.
*/
import puppeteer from "puppeteer";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
// Load actual game data from compiled API output
const { defaultAchievements } = require("./apps/api/prod/src/data/achievements.js");
const { defaultEquipment } = require("./apps/api/prod/src/data/equipment.js");
const { defaultBosses } = require("./apps/api/prod/src/data/bosses.js");
const { defaultQuests } = require("./apps/api/prod/src/data/quests.js");
const { defaultAdventurers } = require("./apps/api/prod/src/data/adventurers.js");
const { defaultUpgrades } = require("./apps/api/prod/src/data/upgrades.js");
const { defaultZones } = require("./apps/api/prod/src/data/zones.js");
console.log("📦 Data loaded:");
console.log(` achievements : ${defaultAchievements.length}`);
console.log(` equipment : ${defaultEquipment.length}`);
console.log(` bosses : ${defaultBosses.length}`);
console.log(` quests : ${defaultQuests.length}`);
// Spot-check for our new items
const newEquipIds = [
"chaos_mantle", "titan_core", "expanse_blade", "void_armour_mk2",
"cosmos_blade", "reality_plate", "maelstrom_edge", "cosmic_plate",
"primeval_blade", "ancient_aegis", "absolute_blade", "eternity_plate",
"omniversal_core",
];
const foundNew = newEquipIds.filter(id => defaultEquipment.some(e => e.id === id));
const missingNew = newEquipIds.filter(id => !defaultEquipment.some(e => e.id === id));
console.log(`\n🗡️ New equipment found (${foundNew.length}/13): ${foundNew.join(", ")}`);
if (missingNew.length > 0) console.log(` ❌ Missing: ${missingNew.join(", ")}`);
const questEternal = defaultAchievements.find(a => a.id === "quest_eternal");
const fullyEquipped = defaultAchievements.find(a => a.id === "fully_equipped");
console.log(`\n🏆 quest_eternal condition amount : ${questEternal?.condition?.amount}`);
console.log(`🏆 fully_equipped condition amount: ${fullyEquipped?.condition?.amount}`);
// Build a minimal but valid mock game state
const mockState = {
achievements : defaultAchievements,
adventurers : defaultAdventurers,
baseClickPower: 1,
bosses : defaultBosses,
equipment : defaultEquipment,
lastTickAt : Date.now(),
player : {
avatar : null,
characterName : "Hikari Test",
createdAt : Date.now(),
discordId : "000000000000000001",
discriminator : "0",
lastSavedAt : Date.now(),
lifetimeAchievementsUnlocked: 0,
lifetimeAdventurersRecruited: 0,
lifetimeBossesDefeated : 0,
lifetimeClicks : 0,
lifetimeGoldEarned : 0,
lifetimeQuestsCompleted : 0,
totalClicks : 0,
totalGoldEarned : 0,
username : "HikariTest",
},
prestige : {
count : 0,
runestones : 0,
},
quests : defaultQuests,
resources : {
crystals : 0,
essence : 0,
gold : 0,
},
upgrades : defaultUpgrades,
zones : defaultZones,
};
const mockLoadResponse = {
currentSchemaVersion: 1,
inGuild : true,
loginBonus : null,
loginStreak : 0,
offlineEssence : 0,
offlineGold : 0,
offlineSeconds : 0,
schemaOutdated : false,
signature : undefined,
state : mockState,
};
console.log("\n🌐 Launching browser...");
const browser = await puppeteer.launch({
args : ["--no-sandbox", "--disable-setuid-sandbox"],
headless: false,
});
const page = await browser.newPage();
await page.setViewport({ height: 900, width: 1400 });
// Intercept the game load call and inject our mock state
await page.setRequestInterception(true);
page.on("request", (req) => {
if (req.url().includes("/api/game/load") && req.method() === "GET") {
console.log(" ↩️ Intercepted /api/game/load — injecting mock state");
req.respond({
body : JSON.stringify(mockLoadResponse),
contentType : "application/json",
headers : { "Content-Type": "application/json" },
status : 200,
});
} else {
req.continue();
}
});
// Set a fake token so the frontend thinks we're logged in
await page.evaluateOnNewDocument(() => {
localStorage.setItem("elysium_token", "dev.fake.token");
});
console.log(" 🔗 Navigating to http://localhost:5173 ...");
await page.goto("http://localhost:5173", { waitUntil: "networkidle2" });
// Give the game a moment to tick and render
await new Promise(r => setTimeout(r, 3000));
await page.screenshot({ path: "/tmp/elysium-01-game.png" });
console.log(" 📸 Screenshot: /tmp/elysium-01-game.png");
// Try to find the equipment panel
const equipmentTab = await page.$("button, a, [role='tab']");
console.log(`\n🔍 Checking page title: ${await page.title()}`);
// Log any visible text that mentions our new items
const pageText = await page.evaluate(() => document.body.innerText);
const newItemsVisible = newEquipIds.filter(id => pageText.toLowerCase().includes(id.replace(/_/g, " ").toLowerCase().slice(0, 8)));
console.log(`\n🗡️ New item names visible in UI: ${newItemsVisible.length > 0 ? newItemsVisible.join(", ") : "none yet (may need to navigate to equipment panel)"}`);
// Check achievement counts visible in page
const hasQuestEternal = pageText.includes("112");
const hasFullyEquipped = pageText.includes("78");
console.log(` quest_eternal (112) visible: ${hasQuestEternal}`);
console.log(` fully_equipped (78) visible: ${hasFullyEquipped}`);
console.log("\n✅ Browser open — take a look around! Close it when done.");
console.log(" (or Ctrl+C to exit)\n");
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.3.2", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,6 +11,6 @@
}, },
"devDependencies": { "devDependencies": {
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"typescript": "5.9.3" "typescript": "5.8.2"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.3.2", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+3 -3
View File
@@ -8,11 +8,11 @@
type AchievementConditionType = type AchievementConditionType =
| "totalGoldEarned" | "totalGoldEarned"
| "totalClicks" | "totalClicks"
| "bossesDefeated"
| "questsCompleted"
| "adventurerTotal" | "adventurerTotal"
| "prestigeCount" | "prestigeCount"
| "equipmentOwned"; | "equipmentOwned"
| "uniqueQuestsCompleted"
| "uniqueBossesDefeated";
interface AchievementCondition { interface AchievementCondition {
type: AchievementConditionType; type: AchievementConditionType;
+5
View File
@@ -170,6 +170,11 @@ interface BossChallengeResponse {
adventurerId: string; adventurerId: string;
killed: number; killed: number;
}>; }>;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
} }
type PrestigeRequest = Record<string, never>; type PrestigeRequest = Record<string, never>;
+14
View File
@@ -55,6 +55,12 @@ interface Boss {
*/ */
zoneId: string; zoneId: string;
/**
* ID of the quest that must be completed before this boss becomes
* available. `null`/`undefined` means no quest gate applies.
*/
unlockQuestId?: string | null;
/** /**
* One-time runestone bounty awarded on first-ever defeat. * One-time runestone bounty awarded on first-ever defeat.
*/ */
@@ -66,6 +72,14 @@ interface Boss {
* bounty is never re-awarded in subsequent runs. * bounty is never re-awarded in subsequent runs.
*/ */
bountyRunestonesClaimed?: boolean; bountyRunestonesClaimed?: boolean;
/**
* Whether this boss has ever been defeated, across all prestige/
* transcendence/apotheosis resets. Used by achievements that require
* defeating every distinct boss, since `status` alone only reflects
* the current run.
*/
everDefeated?: boolean;
} }
export type { Boss, BossStatus }; export type { Boss, BossStatus };
+3 -3
View File
@@ -87,7 +87,7 @@ const COMPANIONS: Array<Companion> = [
id: "wren", id: "wren",
name: "Wren", name: "Wren",
title: "Hedge Witch", title: "Hedge Witch",
unlock: { threshold: 500, type: "lifetimeQuests" }, unlock: { threshold: 250, type: "lifetimeQuests" },
}, },
{ {
bonus: { type: "bossDamage", value: 0.2 }, bonus: { type: "bossDamage", value: 0.2 },
@@ -117,7 +117,7 @@ const COMPANIONS: Array<Companion> = [
id: "kael", id: "kael",
name: "Kael", name: "Kael",
title: "Battle Mage", title: "Battle Mage",
unlock: { threshold: 720, type: "lifetimeBosses" }, unlock: { threshold: 500, type: "lifetimeBosses" },
}, },
{ {
bonus: { type: "questTime", value: 0.3 }, bonus: { type: "questTime", value: 0.3 },
@@ -127,7 +127,7 @@ const COMPANIONS: Array<Companion> = [
id: "zuri", id: "zuri",
name: "Zuri", name: "Zuri",
title: "Chrono Weaver", title: "Chrono Weaver",
unlock: { threshold: 950, type: "lifetimeQuests" }, unlock: { threshold: 500, type: "lifetimeQuests" },
}, },
{ {
bonus: { type: "passiveGold", value: 0.75 }, bonus: { type: "passiveGold", value: 0.75 },
@@ -8,6 +8,7 @@
type DailyChallengeType = type DailyChallengeType =
| "clicks" | "clicks"
| "bossesDefeated" | "bossesDefeated"
| "crafting"
| "questsCompleted" | "questsCompleted"
| "prestige"; | "prestige";
@@ -40,6 +40,13 @@ interface Equipment {
*/ */
owned: boolean; owned: boolean;
/**
* Whether the player has ever acquired this item, across all runs never reset.
* Optional for backwards compatibility with saves persisted before this field
* existed; readers should fall back to `owned` when it is absent.
*/
everOwned?: boolean;
/** /**
* Whether this item is currently equipped (only one per type can be equipped). * Whether this item is currently equipped (only one per type can be equipped).
*/ */
@@ -56,6 +56,12 @@ interface PrestigeData {
* Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade). * Whether the auto-prestige feature is currently enabled (requires auto_prestige upgrade).
*/ */
autoPrestigeEnabled?: boolean; autoPrestigeEnabled?: boolean;
/**
* When true, auto-prestige only fires when the runestone yield is at its maximum base cap.
* Requires auto_prestige upgrade and autoPrestigeEnabled to be true.
*/
autoPrestigeMaxRunestonesOnly?: boolean;
} }
export type { PrestigeData }; export type { PrestigeData };
@@ -60,7 +60,7 @@ const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
enableNotifications: false, enableNotifications: false,
enablePrestigeAnnouncements: true, enablePrestigeAnnouncements: true,
enableSounds: false, enableSounds: false,
numberFormat: "suffix", numberFormat: "scientific",
showAchievementsUnlocked: true, showAchievementsUnlocked: true,
showAdventurersRecruited: true, showAdventurersRecruited: true,
showApotheosis: true, showApotheosis: true,
+8
View File
@@ -61,6 +61,14 @@ interface Quest {
* Unix timestamp of the most recent failed attempt (if any). * Unix timestamp of the most recent failed attempt (if any).
*/ */
lastFailedAt?: number; lastFailedAt?: number;
/**
* Whether this quest has ever been completed, across all prestige/
* transcendence/apotheosis resets. Used by achievements that require
* completing every distinct quest, since `status` alone only reflects
* the current run.
*/
everCompleted?: boolean;
} }
export type { Quest, QuestReward, QuestRewardType, QuestStatus }; export type { Quest, QuestReward, QuestRewardType, QuestStatus };
+8 -8
View File
@@ -35,8 +35,8 @@ describe("computeUnlockedCompanionIds", () => {
expect(result).toContain("finn"); expect(result).toContain("finn");
}); });
it("unlocks wren at 500 lifetime quests", () => { it("unlocks wren at 250 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 500 }); const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 250 });
expect(result).toContain("finn"); expect(result).toContain("finn");
expect(result).toContain("wren"); expect(result).toContain("wren");
}); });
@@ -57,15 +57,15 @@ describe("computeUnlockedCompanionIds", () => {
expect(result).not.toContain("sera"); expect(result).not.toContain("sera");
}); });
it("unlocks kael at 720 lifetime bosses", () => { it("unlocks kael at 500 lifetime bosses", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 720 }); const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeBossesDefeated: 500 });
expect(result).toContain("kael"); expect(result).toContain("kael");
expect(result).toContain("lyra"); expect(result).toContain("lyra");
expect(result).toContain("aldric"); expect(result).toContain("aldric");
}); });
it("unlocks zuri at 950 lifetime quests", () => { it("unlocks zuri at 500 lifetime quests", () => {
const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 950 }); const result = computeUnlockedCompanionIds({ ...baseParams, lifetimeQuestsCompleted: 500 });
expect(result).toContain("zuri"); expect(result).toContain("zuri");
}); });
@@ -96,8 +96,8 @@ describe("computeUnlockedCompanionIds", () => {
it("unlocks multiple companions simultaneously", () => { it("unlocks multiple companions simultaneously", () => {
const result = computeUnlockedCompanionIds({ const result = computeUnlockedCompanionIds({
lifetimeBossesDefeated: 720, lifetimeBossesDefeated: 500,
lifetimeQuestsCompleted: 950, lifetimeQuestsCompleted: 500,
lifetimeGoldEarned: 1e18, lifetimeGoldEarned: 1e18,
prestigeCount: 10, prestigeCount: 10,
transcendenceCount: 5, transcendenceCount: 5,
+2 -2
View File
@@ -24,7 +24,7 @@ describe("DEFAULT_PROFILE_SETTINGS", () => {
expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false); expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false);
}); });
it("defaults numberFormat to suffix", () => { it("defaults numberFormat to scientific", () => {
expect(DEFAULT_PROFILE_SETTINGS.numberFormat).toBe("suffix"); expect(DEFAULT_PROFILE_SETTINGS.numberFormat).toBe("scientific");
}); });
}); });
+794 -642
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,3 +1,13 @@
packages: packages:
- "apps/*" - "apps/*"
- "packages/*" - "packages/*"
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true
onlyBuiltDependencies:
- "@prisma/engines"
- "prisma"