5 Commits

Author SHA1 Message Date
minori 58f411285c deps: update typescript to 6.0.2
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m48s
CI / Lint, Build & Test (pull_request) Successful in 1m48s
2026-04-03 07:01:42 -07:00
hikari de5570b5fc fix: filter third-party script errors from frontend telemetry
CI / Lint, Build & Test (push) Successful in 1m14s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m56s
2026-04-01 13:55:40 -07:00
hikari 133c81fefe chore: bump schema version to 2 for v0.4.0 balance pass
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m11s
2026-03-31 20:06:13 -07:00
naomi 1408e067b7 release: v0.4.0 2026-03-31 20:00:08 -07:00
hikari 666a5b2d6d fix: runestone formula, prestige/transcendence rebalance, exploration fixes, and comprehensive balance audit (#135)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m12s
CI / Lint, Build & Test (push) Successful in 1m13s
## What changed and why

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

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

---

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

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

 This PR was crafted with help from Hikari~ 🌸

Reviewed-on: #135
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-31 19:57:53 -07:00
26 changed files with 1163 additions and 432 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/api",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+95 -5
View File
@@ -149,7 +149,7 @@ export const defaultAchievements: Array<Achievement> = [
},
{
condition: { amount: 18, type: "bossesDefeated" },
description: "Defeat all 18 bosses across the first six zones.",
description: "Defeat the 18 bosses of the mortal realms.",
icon: "🌟",
id: "devourer_slayer",
name: "World Saver",
@@ -223,8 +223,8 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 65, type: "equipmentOwned" },
description: "Own all 65 pieces of equipment.",
condition: { amount: 78, type: "equipmentOwned" },
description: "Own all 78 pieces of equipment.",
icon: "🛡️",
id: "fully_equipped",
name: "Fully Equipped",
@@ -269,6 +269,33 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 50_000 },
unlockedAt: null,
},
{
condition: { amount: 1e30, type: "totalGoldEarned" },
description: "Earn 1 nonillion gold in total.",
icon: "🌌",
id: "cosmic_wealthy",
name: "Cosmic Wealthy",
reward: { crystals: 100_000 },
unlockedAt: null,
},
{
condition: { amount: 1e60, type: "totalGoldEarned" },
description: "Earn a vigintillion gold in total.",
icon: "♾️",
id: "infinite_hoarder",
name: "Infinite Hoarder",
reward: { crystals: 250_000 },
unlockedAt: null,
},
{
condition: { amount: 1e90, type: "totalGoldEarned" },
description: "Earn a trigintillion gold in total.",
icon: "🔮",
id: "omniversal_tycoon",
name: "Omniversal Tycoon",
reward: { crystals: 1_000_000 },
unlockedAt: null,
},
// Higher quest milestones
{
condition: { amount: 30, type: "questsCompleted" },
@@ -289,8 +316,26 @@ export const defaultAchievements: Array<Achievement> = [
unlockedAt: null,
},
{
condition: { amount: 95, type: "questsCompleted" },
description: "Complete all 95 quests across the known multiverse.",
condition: { amount: 75, type: "questsCompleted" },
description: "Complete 75 quests.",
icon: "🌠",
id: "quest_hero",
name: "Quest Hero",
reward: { crystals: 10_000 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "questsCompleted" },
description: "Complete 100 quests.",
icon: "💫",
id: "quest_legend",
name: "Quest Legend",
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
condition: { amount: 112, type: "questsCompleted" },
description: "Complete all 112 quests across the known multiverse.",
icon: "🌌",
id: "quest_eternal",
name: "Quest Eternal",
@@ -316,6 +361,15 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 5000 },
unlockedAt: null,
},
{
condition: { amount: 50, type: "bossesDefeated" },
description: "Defeat 50 bosses.",
icon: "⚡",
id: "boss_legend",
name: "Legendary Vanquisher",
reward: { crystals: 15_000 },
unlockedAt: null,
},
{
condition: { amount: 72, type: "bossesDefeated" },
description: "Defeat all 72 bosses across every plane of existence.",
@@ -363,4 +417,40 @@ export const defaultAchievements: Array<Achievement> = [
reward: { crystals: 25_000 },
unlockedAt: null,
},
{
condition: { amount: 50, type: "prestigeCount" },
description: "Prestige 50 times.",
icon: "✨",
id: "prestige_transcendent",
name: "Transcendent",
reward: { runestones: 100 },
unlockedAt: null,
},
{
condition: { amount: 100, type: "prestigeCount" },
description: "Prestige 100 times.",
icon: "💎",
id: "prestige_eternal",
name: "Eternal Looper",
reward: { runestones: 500 },
unlockedAt: null,
},
{
condition: { amount: 150, type: "prestigeCount" },
description: "Prestige 150 times.",
icon: "🌟",
id: "prestige_immortal",
name: "Immortal Cycler",
reward: { runestones: 2000 },
unlockedAt: null,
},
{
condition: { amount: 200, type: "prestigeCount" },
description: "Prestige 200 times.",
icon: "👑",
id: "prestige_absolute",
name: "Absolute Champion",
reward: { runestones: 10_000 },
unlockedAt: null,
},
];
+60 -60
View File
@@ -12,7 +12,7 @@ export const defaultBosses: Array<Boss> = [
// ── Verdant Vale ──────────────────────────────────────────────────────────
{
bountyRunestones: 1,
crystalReward: 0,
crystalReward: 5,
currentHp: 1000,
damagePerSecond: 5,
description:
@@ -360,7 +360,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 40,
crystalReward: 40_000,
crystalReward: 0,
currentHp: 2_000_000_000,
damagePerSecond: 120_000,
description:
@@ -378,7 +378,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 50,
crystalReward: 100_000,
crystalReward: 0,
currentHp: 8_000_000_000,
damagePerSecond: 350_000,
description:
@@ -396,7 +396,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 60,
crystalReward: 300_000,
crystalReward: 0,
currentHp: 30_000_000_000,
damagePerSecond: 1_000_000,
description:
@@ -414,7 +414,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 75,
crystalReward: 800_000,
crystalReward: 0,
currentHp: 100_000_000_000,
damagePerSecond: 3_000_000,
description:
@@ -433,7 +433,7 @@ export const defaultBosses: Array<Boss> = [
// ── Abyssal Trench ────────────────────────────────────────────────────────
{
bountyRunestones: 40,
crystalReward: 1_500_000,
crystalReward: 0,
currentHp: 250_000_000_000,
damagePerSecond: 5_000_000,
description:
@@ -451,7 +451,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 55,
crystalReward: 4_000_000,
crystalReward: 0,
currentHp: 1_000_000_000_000,
damagePerSecond: 15_000_000,
description:
@@ -469,7 +469,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 70,
crystalReward: 12_000_000,
crystalReward: 0,
currentHp: 4_000_000_000_000,
damagePerSecond: 50_000_000,
description:
@@ -487,7 +487,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 85,
crystalReward: 40_000_000,
crystalReward: 0,
currentHp: 15_000_000_000_000,
damagePerSecond: 150_000_000,
description:
@@ -505,7 +505,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 100,
crystalReward: 150_000_000,
crystalReward: 0,
currentHp: 50_000_000_000_000,
damagePerSecond: 500_000_000,
description:
@@ -524,7 +524,7 @@ export const defaultBosses: Array<Boss> = [
// ── Infernal Court ────────────────────────────────────────────────────────
{
bountyRunestones: 55,
crystalReward: 350_000_000,
crystalReward: 0,
currentHp: 120_000_000_000_000,
damagePerSecond: 800_000_000,
description:
@@ -542,7 +542,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 70,
crystalReward: 1_000_000_000,
crystalReward: 0,
currentHp: 500_000_000_000_000,
damagePerSecond: 2_500_000_000,
description:
@@ -560,7 +560,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 90,
crystalReward: 3_000_000_000,
crystalReward: 0,
currentHp: 2_000_000_000_000_000,
damagePerSecond: 8_000_000_000,
description:
@@ -578,7 +578,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 110,
crystalReward: 10_000_000_000,
crystalReward: 0,
currentHp: 6_000_000_000_000_000,
damagePerSecond: 25_000_000_000,
description:
@@ -596,7 +596,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 135,
crystalReward: 30_000_000_000,
crystalReward: 0,
currentHp: 8_000_000_000_000_000,
damagePerSecond: 80_000_000_000,
description:
@@ -615,7 +615,7 @@ export const defaultBosses: Array<Boss> = [
// ── Crystalline Spire ─────────────────────────────────────────────────────
{
bountyRunestones: 70,
crystalReward: 8e10,
crystalReward: 0,
currentHp: 2e16,
damagePerSecond: 120_000_000_000,
description:
@@ -633,7 +633,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 90,
crystalReward: 3e11,
crystalReward: 0,
currentHp: 8e16,
damagePerSecond: 4e11,
description:
@@ -651,7 +651,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 115,
crystalReward: 1e12,
crystalReward: 0,
currentHp: 3e17,
damagePerSecond: 1.2e12,
description:
@@ -669,7 +669,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 140,
crystalReward: 4e12,
crystalReward: 0,
currentHp: 1e18,
damagePerSecond: 4e12,
description:
@@ -687,7 +687,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 175,
crystalReward: 1.5e13,
crystalReward: 0,
currentHp: 4e18,
damagePerSecond: 1.5e13,
description:
@@ -706,7 +706,7 @@ export const defaultBosses: Array<Boss> = [
// ── Void Sanctum ──────────────────────────────────────────────────────────
{
bountyRunestones: 90,
crystalReward: 4e13,
crystalReward: 0,
currentHp: 1e19,
damagePerSecond: 4e13,
description:
@@ -724,7 +724,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 115,
crystalReward: 1.5e14,
crystalReward: 0,
currentHp: 5e19,
damagePerSecond: 1.5e14,
description:
@@ -742,7 +742,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 145,
crystalReward: 5e14,
crystalReward: 0,
currentHp: 2e20,
damagePerSecond: 5e14,
description:
@@ -760,7 +760,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 180,
crystalReward: 2e15,
crystalReward: 0,
currentHp: 8e20,
damagePerSecond: 2e15,
description:
@@ -778,7 +778,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 225,
crystalReward: 8e15,
crystalReward: 0,
currentHp: 3e21,
damagePerSecond: 8e15,
description:
@@ -797,7 +797,7 @@ export const defaultBosses: Array<Boss> = [
// ── Eternal Throne ────────────────────────────────────────────────────────
{
bountyRunestones: 115,
crystalReward: 2e16,
crystalReward: 0,
currentHp: 1e22,
damagePerSecond: 2e16,
description:
@@ -815,7 +815,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 150,
crystalReward: 8e16,
crystalReward: 0,
currentHp: 5e22,
damagePerSecond: 8e16,
description:
@@ -833,7 +833,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 190,
crystalReward: 3e17,
crystalReward: 0,
currentHp: 2e23,
damagePerSecond: 3e17,
description:
@@ -851,7 +851,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 235,
crystalReward: 1.2e18,
crystalReward: 0,
currentHp: 8e23,
damagePerSecond: 1.2e18,
description:
@@ -869,7 +869,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 295,
crystalReward: 5e18,
crystalReward: 0,
currentHp: 3e24,
damagePerSecond: 5e18,
description:
@@ -888,7 +888,7 @@ export const defaultBosses: Array<Boss> = [
// ── Primordial Chaos ──────────────────────────────────────────────────────
{
bountyRunestones: 150,
crystalReward: 2e20,
crystalReward: 0,
currentHp: 1e26,
damagePerSecond: 2e20,
description:
@@ -906,7 +906,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 200,
crystalReward: 8e21,
crystalReward: 0,
currentHp: 5e27,
damagePerSecond: 8e21,
description:
@@ -924,7 +924,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 265,
crystalReward: 4e23,
crystalReward: 0,
currentHp: 2e29,
damagePerSecond: 4e23,
description:
@@ -942,7 +942,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 350,
crystalReward: 2e25,
crystalReward: 0,
currentHp: 8e30,
damagePerSecond: 2e25,
description:
@@ -961,7 +961,7 @@ export const defaultBosses: Array<Boss> = [
// ── Infinite Expanse ──────────────────────────────────────────────────────
{
bountyRunestones: 200,
crystalReward: 8e27,
crystalReward: 0,
currentHp: 3e33,
damagePerSecond: 8e27,
description:
@@ -979,8 +979,8 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 265,
crystalReward: 3e31,
currentHp: 1e37,
crystalReward: 0,
currentHp: 2e35,
damagePerSecond: 3e31,
description:
"A creature as wide as the observable universe — which, in the Expanse, is not a helpful measurement. It is simply everywhere the horizon is, which in this place is everywhere.",
@@ -988,7 +988,7 @@ export const defaultBosses: Array<Boss> = [
essenceReward: 1e34,
goldReward: 1e38,
id: "horizon_beast",
maxHp: 1e37,
maxHp: 2e35,
name: "The Horizon Beast",
prestigeRequirement: 8,
status: "locked",
@@ -997,8 +997,8 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 350,
crystalReward: 1e35,
currentHp: 5e40,
crystalReward: 0,
currentHp: 5e37,
damagePerSecond: 1e35,
description:
"A self-replicating intelligence that has filled the Expanse with copies of itself. Every copy has the same purpose: to be the last thing in the Expanse. Your guild will need to convince all of them otherwise.",
@@ -1006,7 +1006,7 @@ export const defaultBosses: Array<Boss> = [
essenceReward: 5e37,
goldReward: 5e41,
id: "infinity_construct",
maxHp: 5e40,
maxHp: 5e37,
name: "The Infinity Construct",
prestigeRequirement: 8,
status: "locked",
@@ -1015,8 +1015,8 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 465,
crystalReward: 5e38,
currentHp: 2e44,
crystalReward: 0,
currentHp: 3e39,
damagePerSecond: 5e38,
description:
"The thing that claims the Infinite Expanse as its territory — which, given the name of the place, is an ambitious claim. It enforces this claim with power that has had infinite space to accumulate.",
@@ -1024,7 +1024,7 @@ export const defaultBosses: Array<Boss> = [
essenceReward: 2e41,
goldReward: 2e45,
id: "expanse_sovereign",
maxHp: 2e44,
maxHp: 3e39,
name: "The Expanse Sovereign",
prestigeRequirement: 9,
status: "locked",
@@ -1034,7 +1034,7 @@ export const defaultBosses: Array<Boss> = [
// ── Reality Forge ─────────────────────────────────────────────────────────
{
bountyRunestones: 265,
crystalReward: 2e42,
crystalReward: 0,
currentHp: 8e47,
damagePerSecond: 2e42,
description:
@@ -1052,7 +1052,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 350,
crystalReward: 1e47,
crystalReward: 0,
currentHp: 4e52,
damagePerSecond: 1e47,
description:
@@ -1070,7 +1070,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 465,
crystalReward: 6e51,
crystalReward: 0,
currentHp: 2e57,
damagePerSecond: 6e51,
description:
@@ -1088,7 +1088,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 615,
crystalReward: 2e56,
crystalReward: 0,
currentHp: 8e61,
damagePerSecond: 2e56,
description:
@@ -1107,7 +1107,7 @@ export const defaultBosses: Array<Boss> = [
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{
bountyRunestones: 350,
crystalReward: 1e60,
crystalReward: 0,
currentHp: 4e65,
damagePerSecond: 1e60,
description:
@@ -1125,7 +1125,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 465,
crystalReward: 6e65,
crystalReward: 0,
currentHp: 2e71,
damagePerSecond: 6e65,
description:
@@ -1143,7 +1143,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 615,
crystalReward: 3e71,
crystalReward: 0,
currentHp: 1e77,
damagePerSecond: 3e71,
description:
@@ -1161,7 +1161,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 815,
crystalReward: 1e77,
crystalReward: 0,
currentHp: 5e82,
damagePerSecond: 1e77,
description:
@@ -1180,7 +1180,7 @@ export const defaultBosses: Array<Boss> = [
// ── Primeval Sanctum ──────────────────────────────────────────────────────
{
bountyRunestones: 465,
crystalReward: 5e82,
crystalReward: 0,
currentHp: 2e88,
damagePerSecond: 5e82,
description:
@@ -1198,7 +1198,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 615,
crystalReward: 3e89,
crystalReward: 0,
currentHp: 1e95,
damagePerSecond: 3e89,
description:
@@ -1216,7 +1216,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 815,
crystalReward: 2e96,
crystalReward: 0,
currentHp: 8e101,
damagePerSecond: 2e96,
description:
@@ -1234,7 +1234,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 1080,
crystalReward: 1e103,
crystalReward: 0,
currentHp: 5e108,
damagePerSecond: 1e103,
description:
@@ -1253,7 +1253,7 @@ export const defaultBosses: Array<Boss> = [
// ── The Absolute ──────────────────────────────────────────────────────────
{
bountyRunestones: 615,
crystalReward: 5e110,
crystalReward: 0,
currentHp: 2e116,
damagePerSecond: 5e110,
description:
@@ -1271,7 +1271,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 815,
crystalReward: 3e119,
crystalReward: 0,
currentHp: 1e125,
damagePerSecond: 3e119,
description:
@@ -1289,7 +1289,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 1080,
crystalReward: 1e129,
crystalReward: 0,
currentHp: 5e134,
damagePerSecond: 1e129,
description:
@@ -1307,7 +1307,7 @@ export const defaultBosses: Array<Boss> = [
},
{
bountyRunestones: 1430,
crystalReward: 5e139,
crystalReward: 0,
currentHp: 2e145,
damagePerSecond: 5e139,
description:
+163 -1
View File
@@ -695,6 +695,168 @@ export const defaultEquipment: Array<Equipment> = [
setId: "eternal_throne",
type: "trinket",
},
// ── Primordial Chaos ──────────────────────────────────────────────────────
{
bonus: { goldMultiplier: 9 },
description:
"The Primordial Titan's carapace — formed before the concept of armour existed. It simply is what armour aspires to be.",
equipped: false,
id: "chaos_mantle",
name: "The Chaos Mantle",
owned: false,
rarity: "legendary",
setId: "primordial_chaos",
type: "armour",
},
{
bonus: { clickMultiplier: 5, combatMultiplier: 2, goldMultiplier: 2.5 },
description:
"The crystallised core of the Titan itself — the first stable thing to emerge from chaos. It radiates in every direction simultaneously.",
equipped: false,
id: "titan_core",
name: "The Titan Core",
owned: false,
rarity: "legendary",
setId: "primordial_chaos",
type: "trinket",
},
// ── Infinite Expanse ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 14 },
description:
"Forged from the Expanse Sovereign's own reach — a blade that has no beginning and no end, only edge.",
equipped: false,
id: "expanse_blade",
name: "The Expanse Blade",
owned: false,
rarity: "legendary",
setId: "infinite_expanse",
type: "weapon",
},
{
bonus: { goldMultiplier: 10 },
description:
"A second iteration of the void's armour — the first was not enough. This one has never been tested to its limit.",
equipped: false,
id: "void_armour_mk2",
name: "Void Armour Mk. II",
owned: false,
rarity: "legendary",
setId: "infinite_expanse",
type: "armour",
},
// ── Reality Forge ─────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 16 },
description:
"The Reality Architect's primary instrument — a sword that does not cut through things but rewrites what they are.",
equipped: false,
id: "cosmos_blade",
name: "The Cosmos Blade",
owned: false,
rarity: "legendary",
setId: "reality_forge",
type: "weapon",
},
{
bonus: { goldMultiplier: 12 },
description:
"Plated from the substance of reality itself — wearing it makes you feel slightly more real than everything around you.",
equipped: false,
id: "reality_plate",
name: "The Reality Plate",
owned: false,
rarity: "legendary",
setId: "reality_forge",
type: "armour",
},
// ── Cosmic Maelstrom ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 18 },
description:
"Torn from the eye of the Cosmic Annihilator — a weapon that carries the force of an ending universe in every swing.",
equipped: false,
id: "maelstrom_edge",
name: "The Maelstrom Edge",
owned: false,
rarity: "legendary",
setId: "cosmic_maelstrom",
type: "weapon",
},
{
bonus: { goldMultiplier: 14 },
description:
"Armour that has weathered the destruction of countless realities. It has learned not to flinch.",
equipped: false,
id: "cosmic_plate",
name: "The Cosmic Plate",
owned: false,
rarity: "legendary",
setId: "cosmic_maelstrom",
type: "armour",
},
// ── Primeval Sanctum ──────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 22 },
description:
"The first weapon — older than the concept of war, older than the concept of a weapon. It remembers what it was made for.",
equipped: false,
id: "primeval_blade",
name: "The Primeval Blade",
owned: false,
rarity: "legendary",
setId: "primeval_sanctum",
type: "weapon",
},
{
bonus: { goldMultiplier: 17 },
description:
"The shield-form of the Primeval God — absolute protection from before the concept of harm existed.",
equipped: false,
id: "ancient_aegis",
name: "The Ancient Aegis",
owned: false,
rarity: "legendary",
setId: "primeval_sanctum",
type: "armour",
},
// ── The Absolute ──────────────────────────────────────────────────────────
{
bonus: { combatMultiplier: 28 },
description:
"There is no name for what this was before it became a sword. There is no name for what it is now. It ends things.",
equipped: false,
id: "absolute_blade",
name: "The Absolute Blade",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "weapon",
},
{
bonus: { goldMultiplier: 20 },
description:
"Eternity given the shape of armour — it has always existed, it will always exist, and it has always protected its wearer.",
equipped: false,
id: "eternity_plate",
name: "The Eternity Plate",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "armour",
},
{
bonus: { clickMultiplier: 6, combatMultiplier: 3, goldMultiplier: 3 },
description:
"The heart of everything — a thing so fundamental that its removal from the Absolute One ended all things, briefly. Briefly.",
equipped: false,
id: "omniversal_core",
name: "The Omniversal Core",
owned: false,
rarity: "legendary",
setId: "the_absolute",
type: "trinket",
},
// ── Purchasable endgame sinks ─────────────────────────────────────────────
{
bonus: { clickMultiplier: 4.25 },
@@ -757,7 +919,7 @@ export const defaultEquipment: Array<Equipment> = [
type: "armour",
},
{
bonus: { clickMultiplier: 5, combatMultiplier: 1.75, goldMultiplier: 2 },
bonus: { clickMultiplier: 5, combatMultiplier: 3, goldMultiplier: 2.5 },
cost: { crystals: 100_000_000, essence: 0, gold: 0 },
description:
"An artifact from beyond all known planes — it refracts power through all dimensions simultaneously.",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10",
multiplier: 200,
name: "Eternal Rune I",
runestonesCost: 30_000,
runestonesCost: 22_500,
},
{
category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11",
multiplier: 500,
name: "Eternal Rune II",
runestonesCost: 80_000,
runestonesCost: 60_000,
},
// ── Click Power ───────────────────────────────────────────────────────────
{
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -23,7 +23,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "verdant_vale",
},
{
bonus: { type: "combat_power", value: 1.12 },
bonus: { type: "combat_power", value: 1.2 },
description:
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
id: "elder_bark_shield",
@@ -101,7 +101,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "shadow_marshes",
},
{
bonus: { type: "combat_power", value: 1.1 },
bonus: { type: "combat_power", value: 1.15 },
description:
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
id: "cursed_focus",
@@ -127,7 +127,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "volcanic_depths",
},
{
bonus: { type: "combat_power", value: 1.12 },
bonus: { type: "combat_power", value: 1.2 },
description:
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
id: "elemental_ore_ingot",
@@ -193,7 +193,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 8: abyssal_trench
{
bonus: { type: "combat_power", value: 1.15 },
bonus: { type: "combat_power", value: 1.25 },
description:
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
id: "pressure_forged_core",
@@ -271,7 +271,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 11: void_sanctum
{
bonus: { type: "combat_power", value: 1.18 },
bonus: { type: "combat_power", value: 1.28 },
description:
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
id: "null_field_generator",
@@ -309,7 +309,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "eternal_throne",
},
{
bonus: { type: "combat_power", value: 1.2 },
bonus: { type: "combat_power", value: 1.3 },
description:
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
id: "eternity_bound_ring",
@@ -375,7 +375,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 15: reality_forge
{
bonus: { type: "combat_power", value: 1.22 },
bonus: { type: "combat_power", value: 1.35 },
description:
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
id: "reality_ingot",
@@ -427,7 +427,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
// Zone 17: primeval_sanctum
{
bonus: { type: "combat_power", value: 1.25 },
bonus: { type: "combat_power", value: 1.4 },
description:
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
id: "ancient_memory_array",
@@ -506,7 +506,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "the_absolute",
},
{
bonus: { type: "combat_power", value: 1.4 },
bonus: { type: "combat_power", value: 1.65 },
description:
"An eternity splinter from the eternal throne, set at the boundary between everything and nothing with an omega crystal and bound by boundary shards. Where eternity meets the absolute, something is forged that has never existed and will never exist again. Your party fights as if they know this.",
id: "eternal_omega",
@@ -546,7 +546,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
zoneId: "the_absolute",
},
{
bonus: { type: "combat_power", value: 1.3 },
bonus: { type: "combat_power", value: 1.55 },
description:
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
id: "omega_convergence",
+1 -1
View File
@@ -8,4 +8,4 @@
/**
* The current game state schema version. Bump this whenever a breaking change is made to GameState.
*/
export const currentSchemaVersion = 1;
export const currentSchemaVersion = 2;
+3 -3
View File
@@ -127,7 +127,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
// ── Echo meta multipliers ───────────────────────────────────────────────────
{
category: "echo_meta",
cost: 25,
cost: 15,
description:
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
id: "echo_meta_1",
@@ -136,7 +136,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 75,
cost: 45,
description:
"Each loop of existence makes the next more powerful — future echo yields +50%.",
id: "echo_meta_2",
@@ -145,7 +145,7 @@ export const defaultTranscendenceUpgrades: Array<TranscendenceUpgrade> = [
},
{
category: "echo_meta",
cost: 200,
cost: 100,
description:
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
id: "echo_meta_3",
+3 -3
View File
@@ -48,7 +48,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
{
costCrystals: 100,
costCrystals: 50,
costEssence: 0,
costGold: 0,
description:
@@ -104,7 +104,7 @@ export const defaultUpgrades: Array<Upgrade> = [
description:
"Forge partnerships with mage guilds across the realm. All income +50%.",
id: "essence_guild",
multiplier: 1.5,
multiplier: 2,
name: "Essence Guild",
purchased: false,
target: "global",
@@ -459,7 +459,7 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false,
},
{
costCrystals: 10_000_000,
costCrystals: 50_000_000,
costEssence: 0,
costGold: 0,
description: "Transcend mortal limits through void energy. All income x3.",
+8 -2
View File
@@ -24,6 +24,13 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
/**
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
* Must be kept in sync with prestigeCombatBase in apps/web/src/engine/tick.ts.
*/
const prestigeCombatBase = 4;
const bossRouter = new Hono<HonoEnvironment>();
bossRouter.use("*", authMiddleware);
@@ -38,8 +45,7 @@ const calculatePartyStats = (
}
}
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const prestigeMultiplier = Math.pow(prestigeCombatBase, state.prestige.count);
// Apply equipped weapon's combat bonus
// eslint-disable-next-line capitalized-comments -- v8 ignore
+4 -4
View File
@@ -146,7 +146,7 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
/**
* Calculates the new prestige production multiplier.
* Formula: 1.25^prestigeCount — exponential scaling per prestige that eventually
* Formula: 1.3^prestigeCount — exponential scaling per prestige that eventually
* overtakes the polynomial threshold growth, making late prestiges progressively easier.
* @param prestigeCount - The new prestige count.
* @returns The production multiplier for the new prestige level.
@@ -154,12 +154,12 @@ const calculateRunestones = (parameters: RunestoneParameters): number => {
const calculateProductionMultiplier = (
prestigeCount: number,
): number => {
return Math.pow(1.25, prestigeCount);
return Math.pow(1.3, prestigeCount);
};
/**
* Returns the milestone runestone bonus for the given prestige count.
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* Every MILESTONE_INTERVAL prestiges awards milestone_number² * MILESTONE_RUNESTONES_PER_INTERVAL stones.
* @param prestigeCount - The prestige count after the current prestige.
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
*/
@@ -168,7 +168,7 @@ const calculateMilestoneBonus = (prestigeCount: number): number => {
return 0;
}
const milestoneNumber = prestigeCount / milestoneInterval;
return milestoneNumber * milestoneRunestonesPerInterval;
return milestoneNumber * milestoneNumber * milestoneRunestonesPerInterval;
};
/**
+24
View File
@@ -881,6 +881,30 @@ describe("debug route", () => {
expect(body.bossesPatched).toBe(1);
});
it("patches boss when only equipmentRewards differ (covers savedRewards branch)", 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: [], 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." }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number };
expect(body.bossesPatched).toBe(1);
});
it("patches boss when only bountyRunestones differs with all other fields matching", 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: 99, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number };
expect(body.bossesPatched).toBe(1);
});
it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
+1 -1
View File
@@ -24,7 +24,7 @@ vi.mock("../../src/services/discord.js", () => ({
}));
const DISCORD_ID = "test_discord_id";
const CURRENT_SCHEMA_VERSION = 1;
const CURRENT_SCHEMA_VERSION = 2;
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
+7 -7
View File
@@ -131,12 +131,12 @@ describe("calculateProductionMultiplier", () => {
expect(calculateProductionMultiplier(0)).toBe(1);
});
it("returns 1.25 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.25);
it("returns 1.3 at count 1", () => {
expect(calculateProductionMultiplier(1)).toBeCloseTo(1.3);
});
it("scales exponentially", () => {
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.25, 10));
expect(calculateProductionMultiplier(10)).toBeCloseTo(Math.pow(1.3, 10));
});
});
@@ -151,12 +151,12 @@ describe("calculateMilestoneBonus", () => {
expect(calculateMilestoneBonus(5)).toBe(25);
});
it("returns 50 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(50);
it("returns 100 at prestige 10", () => {
expect(calculateMilestoneBonus(10)).toBe(100);
});
it("returns 75 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(75);
it("returns 225 at prestige 15", () => {
expect(calculateMilestoneBonus(15)).toBe(225);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/web",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -49,6 +49,40 @@ const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
zone: "zones",
};
/**
* Converts a snake_case ID to a Title Case display name.
* @param id - The snake_case identifier to format.
* @returns The formatted display name.
*/
const formatId = (id: string): string => {
return id.split("_").
map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
}).
join(" ");
};
/**
* Generates a human-readable unlock hint for a locked codex entry.
* @param entry - The locked codex entry.
* @returns A string describing how to unlock the entry.
*/
const buildUnlockHint = (entry: CodexEntry): string => {
const name = formatId(entry.sourceId);
switch (entry.sourceType) {
case "boss": return `Defeat ${name}`;
case "quest": return `Complete: ${name}`;
case "equipment": return `Obtain: ${name}`;
case "adventurer": return `Recruit a ${name}`;
case "upgrade": return `Purchase: ${name}`;
case "prestige": return `Purchase runestone upgrade: ${name}`;
case "zone": return `Explore: ${name}`;
case "exploration": return `Discover: ${name}`;
case "recipe": return `Craft: ${name}`;
default: return "Keep playing to unlock";
}
};
/**
* Renders the codex panel with lore entries grouped by zone.
* @returns The JSX element.
@@ -136,6 +170,9 @@ const CodexPanel = (): JSX.Element => {
<span className="codex-lock">{"🔒"}</span>
<span className="codex-entry-title">{"???"}</span>
</div>
<p className="codex-unlock-hint">
{buildUnlockHint(entry)}
</p>
</div>
);
}
+9 -9
View File
@@ -24,7 +24,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "verdant_vale",
},
{
bonus: { type: "combat_power", value: 1.08 },
bonus: { type: "combat_power", value: 1.2 },
description:
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
id: "elder_bark_shield",
@@ -102,7 +102,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "shadow_marshes",
},
{
bonus: { type: "combat_power", value: 1.1 },
bonus: { type: "combat_power", value: 1.15 },
description:
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
id: "cursed_focus",
@@ -128,7 +128,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "volcanic_depths",
},
{
bonus: { type: "combat_power", value: 1.12 },
bonus: { type: "combat_power", value: 1.2 },
description:
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
id: "elemental_ore_ingot",
@@ -194,7 +194,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 8: abyssal_trench
{
bonus: { type: "combat_power", value: 1.15 },
bonus: { type: "combat_power", value: 1.25 },
description:
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
id: "pressure_forged_core",
@@ -272,7 +272,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 11: void_sanctum
{
bonus: { type: "combat_power", value: 1.18 },
bonus: { type: "combat_power", value: 1.28 },
description:
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
id: "null_field_generator",
@@ -310,7 +310,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "eternal_throne",
},
{
bonus: { type: "combat_power", value: 1.2 },
bonus: { type: "combat_power", value: 1.3 },
description:
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
id: "eternity_bound_ring",
@@ -376,7 +376,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 15: reality_forge
{
bonus: { type: "combat_power", value: 1.22 },
bonus: { type: "combat_power", value: 1.35 },
description:
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
id: "reality_ingot",
@@ -428,7 +428,7 @@ export const RECIPES: Array<CraftingRecipe> = [
// Zone 17: primeval_sanctum
{
bonus: { type: "combat_power", value: 1.25 },
bonus: { type: "combat_power", value: 1.4 },
description:
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
id: "ancient_memory_array",
@@ -466,7 +466,7 @@ export const RECIPES: Array<CraftingRecipe> = [
zoneId: "the_absolute",
},
{
bonus: { type: "combat_power", value: 1.3 },
bonus: { type: "combat_power", value: 1.55 },
description:
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
id: "omega_convergence",
+29 -20
View File
@@ -11,7 +11,6 @@
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
import {
type Achievement,
@@ -84,6 +83,12 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
});
};
/**
* 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.
*/
export const PRESTIGE_COMBAT_BASE = 4;
/**
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
*/
@@ -296,8 +301,7 @@ export const computeEffectiveAdventurerStats = (
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeCombatMultiplier = 1 + state.prestige.count * 0.1;
const prestigeCombatMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
const craftedGoldMultiplier
@@ -378,8 +382,7 @@ export const computePartyCombatPower = (state: GameState): number => {
}
}
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const prestigeMultiplier = PRESTIGE_COMBAT_BASE ** state.prestige.count;
const equipmentCombatMultiplier = state.equipment.
filter((item) => {
@@ -472,8 +475,8 @@ export const computeProjectedRunestones = (state: GameState): number => {
? 1.5
: 1;
const runestoneMult = gain1Mult * gain2Mult;
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- optional chained game state field */
const echoMult: number = state.transcendence?.echoRunestoneMultiplier ?? 1;
const echoMult: number
= state.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult);
};
@@ -814,24 +817,30 @@ export const applyTick = (
zones: updatedZones,
};
// Check achievements and apply crystal rewards for newly unlocked ones
// Check achievements and apply crystal and runestone rewards for newly unlocked ones
const updatedAchievements = checkAchievements(partialState);
const crystalsFromAchievements = updatedAchievements.reduce(
(sum, achievement, index) => {
const wasLocked = state.achievements[index]?.unlockedAt === null;
const isNowUnlocked = achievement.unlockedAt !== null;
if (wasLocked && isNowUnlocked) {
return sum + (achievement.reward?.crystals ?? 0);
}
return sum;
},
0,
);
let crystalsFromAchievements = 0;
let runestonesFromAchievements = 0;
for (const [ index, achievement ] of updatedAchievements.entries()) {
const wasLocked = state.achievements[index]?.unlockedAt === null;
const isNowUnlocked = achievement.unlockedAt !== null;
if (wasLocked && isNowUnlocked) {
crystalsFromAchievements
= crystalsFromAchievements + (achievement.reward?.crystals ?? 0);
runestonesFromAchievements
= runestonesFromAchievements + (achievement.reward?.runestones ?? 0);
}
}
return {
...partialState,
achievements: updatedAchievements,
resources: {
prestige: {
...partialState.prestige,
runestones:
partialState.prestige.runestones + runestonesFromAchievements,
},
resources: {
...partialState.resources,
crystals: capResource(
partialState.resources.crystals + crystalsFromAchievements,
+12
View File
@@ -49,6 +49,18 @@ const initialiseFrontendLogger = (): void => {
? argument
: JSON.stringify(argument);
}).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";
post("/api/fe/error", { context, message });
};
+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",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -11,6 +11,6 @@
},
"devDependencies": {
"@nhcarrigan/typescript-config": "4.0.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@elysium/types",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
+2 -1
View File
@@ -20,7 +20,8 @@ interface AchievementCondition {
}
interface AchievementReward {
crystals?: number;
crystals?: number;
runestones?: number;
}
interface Achievement {
+8 -8
View File
@@ -10,10 +10,10 @@ importers:
devDependencies:
'@nhcarrigan/typescript-config':
specifier: 4.0.0
version: 4.0.0(typescript@5.9.3)
version: 4.0.0(typescript@6.0.2)
typescript:
specifier: 5.9.3
version: 5.9.3
specifier: 6.0.2
version: 6.0.2
apps/api:
dependencies:
@@ -2833,8 +2833,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
typescript@6.0.2:
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
engines: {node: '>=14.17'}
hasBin: true
@@ -3507,9 +3507,9 @@ snapshots:
dependencies:
typescript: 5.8.2
'@nhcarrigan/typescript-config@4.0.0(typescript@5.9.3)':
'@nhcarrigan/typescript-config@4.0.0(typescript@6.0.2)':
dependencies:
typescript: 5.9.3
typescript: 6.0.2
'@nodelib/fs.scandir@2.1.5':
dependencies:
@@ -6147,7 +6147,7 @@ snapshots:
typescript@5.8.2: {}
typescript@5.9.3: {}
typescript@6.0.2: {}
unbox-primitive@1.1.0:
dependencies: