From 062e5b59a6b4ccea2557571c1db8b6fd2597f771 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 21:28:16 -0700 Subject: [PATCH 1/3] fix: preserve lifetime player stats across prestige MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #37. After prestige, the GameState's player.lifetime* fields were stale — they did not include the current run's contributions. The Prisma Player record was updated correctly, but the saved GameState had old values, so the UI showed stale all-time totals on reload. buildPostPrestigeState now computes run-stat contributions and folds them into the fresh player object before writing the prestige state, ensuring the GameState is always consistent with the DB Player record. --- apps/api/src/services/prestige.ts | 43 +++++++++- apps/api/test/services/prestige.spec.ts | 107 ++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index db2e0be..0d9140b 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -205,10 +205,51 @@ const buildPostPrestigeState = ( }; const freshState = initialGameState(currentState.player, characterName); + + // Compute current-run contributions to accumulate into lifetime totals + const runBossesDefeated = currentState.bosses.filter((boss) => { + return boss.status === "defeated"; + }).length; + const runQuestsCompleted = currentState.quests.filter((quest) => { + return quest.status === "completed"; + }).length; + let runAdventurersRecruited = 0; + for (const adventurer of currentState.adventurers) { + runAdventurersRecruited = runAdventurersRecruited + adventurer.count; + } + const runAchievementsUnlocked = currentState.achievements.filter( + (achievement) => { + return achievement.unlockedAt !== null; + }, + ).length; + const prestigeState: GameState = { ...freshState, lastTickAt: Date.now(), - prestige: prestigeData, + + /* + * Fold current-run totals into lifetime stats so the GameState reflects + * the true all-time values immediately after prestige. + */ + player: { + ...freshState.player, + lifetimeAchievementsUnlocked: + freshState.player.lifetimeAchievementsUnlocked + + runAchievementsUnlocked, + lifetimeAdventurersRecruited: + freshState.player.lifetimeAdventurersRecruited + + runAdventurersRecruited, + lifetimeBossesDefeated: + freshState.player.lifetimeBossesDefeated + runBossesDefeated, + lifetimeClicks: + freshState.player.lifetimeClicks + currentState.player.totalClicks, + lifetimeGoldEarned: + freshState.player.lifetimeGoldEarned + + currentState.player.totalGoldEarned, + lifetimeQuestsCompleted: + freshState.player.lifetimeQuestsCompleted + runQuestsCompleted, + }, + prestige: prestigeData, // Codex lore persists across prestiges — players keep their discovered entries ...currentState.codex === undefined ? {} diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 724a021..6b09d2f 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -13,14 +13,24 @@ import { } from "../../src/services/prestige.js"; import type { GameState } from "@elysium/types"; -const makePlayer = (totalGoldEarned: number) => ({ - discordId: "test_id", - username: "testuser", - discriminator: "0", - avatar: null, - totalGoldEarned, - totalClicks: 0, - characterName: "Tester", +const makePlayer = ( + totalGoldEarned: number, + lifetimeGoldEarned = 0, + totalClicks = 0, +) => ({ + avatar: null, + characterName: "Tester", + discordId: "test_id", + discriminator: "0", + lifetimeAchievementsUnlocked: 0, + lifetimeAdventurersRecruited: 0, + lifetimeBossesDefeated: 0, + lifetimeClicks: 0, + lifetimeGoldEarned: lifetimeGoldEarned, + lifetimeQuestsCompleted: 0, + totalClicks: totalClicks, + totalGoldEarned: totalGoldEarned, + username: "testuser", }); const makeMinimalState = (overrides: Partial = {}): GameState => @@ -242,4 +252,85 @@ describe("buildPostPrestigeState", () => { const { prestigeState } = buildPostPrestigeState(state, "Tester"); expect(prestigeState.apotheosis).toEqual(apotheosis); }); + + it("accumulates current-run gold into lifetime total", () => { + const state = makeMinimalState({ + player: makePlayer(4_000_000, 1_000_000), + }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeGoldEarned).toBe(5_000_000); + }); + + it("accumulates current-run clicks into lifetime total", () => { + const state = makeMinimalState({ + player: makePlayer(4_000_000, 0, 500), + }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeClicks).toBe(500); + }); + + it("accumulates defeated bosses into lifetime total", () => { + const defeatedBoss = { + bountyRunestones: 0, + crystalReward: 0, + currentHp: 0, + damagePerSecond: 10, + description: "A boss", + equipmentRewards: [] as string[], + essenceReward: 0, + goldReward: 100, + id: "boss_1", + maxHp: 100, + name: "Boss One", + prestigeRequirement: 0, + status: "defeated" as const, + upgradeRewards: [] as string[], + zoneId: "zone_1", + }; + const state = makeMinimalState({ bosses: [ defeatedBoss ] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeBossesDefeated).toBe(1); + }); + + it("accumulates completed quests into lifetime total", () => { + const quest = { + id: "q_1", + name: "A Quest", + description: "Do the thing", + status: "completed" as const, + zoneId: "zone_1", + }; + const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1); + }); + + it("accumulates recruited adventurers into lifetime total", () => { + const adventurer = { + combatPower: 10, + count: 5, + essencePerSecond: 0, + goldPerSecond: 1, + id: "adv_1", + level: 1, + unlocked: true, + }; + const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5); + }); + + it("accumulates unlocked achievements into lifetime total", () => { + const achievement = { + description: "Did a thing", + id: "ach_1", + name: "Achiever", + requirement: 1, + type: "totalClicks" as const, + unlockedAt: Date.now(), + }; + const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1); + }); }); -- 2.52.0 From f2d82d58fc68078be2ad5f4b2ab9d5efcf65c2c7 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 21:31:06 -0700 Subject: [PATCH 2/3] fix: preserve achievements across prestige Fixes #38. buildPostPrestigeState was using structuredClone(defaultAchievements) via the freshState, which reset all achievements on every prestige. Achievements are now carried forward from currentState.achievements instead, ensuring unlocked achievements are never lost across prestige resets. --- apps/api/src/services/prestige.ts | 4 +++- apps/api/test/services/prestige.spec.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 0d9140b..d05de0b 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -225,7 +225,9 @@ const buildPostPrestigeState = ( const prestigeState: GameState = { ...freshState, - lastTickAt: Date.now(), + // Achievements are permanent — earned achievements survive all prestiges + achievements: currentState.achievements, + lastTickAt: Date.now(), /* * Fold current-run totals into lifetime stats so the GameState reflects diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 6b09d2f..c913e74 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -320,6 +320,20 @@ describe("buildPostPrestigeState", () => { expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5); }); + it("preserves achievements from current state across prestige", () => { + const achievement = { + description: "Did a thing", + id: "ach_persisted", + name: "Achiever", + requirement: 1, + type: "totalClicks" as const, + unlockedAt: Date.now(), + }; + const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.achievements).toEqual([ achievement ]); + }); + it("accumulates unlocked achievements into lifetime total", () => { const achievement = { description: "Did a thing", -- 2.52.0 From 35e4d71d981ee90590bd952df3db1bbfff2483b9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 21:35:03 -0700 Subject: [PATCH 3/3] fix: preserve boss first-kill state across prestige MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #39. Added bountyRunestonesClaimed?: boolean to the Boss type. The first-kill bounty runestones are now only awarded once across all prestige resets — the boss route checks the flag before awarding, sets it on first defeat, and buildPostPrestigeState carries the flag forward through fresh boss state on prestige. The boss panel badge no longer shows for bosses whose bounty has already been claimed. --- apps/api/src/routes/boss.ts | 13 ++++++++--- apps/api/src/services/prestige.ts | 16 +++++++++++++ apps/api/test/routes/boss.spec.ts | 21 +++++++++++++++++ apps/api/test/services/prestige.spec.ts | 27 ++++++++++++++++++++++ apps/web/src/components/game/bossPanel.tsx | 4 +++- packages/types/src/interfaces/boss.ts | 7 ++++++ 6 files changed, 84 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index 03379cf..fe41a29 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -8,6 +8,7 @@ /* eslint-disable max-statements -- Boss handler requires many statements */ /* eslint-disable complexity -- Boss handler has inherent complexity */ /* eslint-disable stylistic/max-len -- Long lines in combat logic */ +/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */ import { computeSetBonuses, getActiveCompanionBonus, @@ -298,14 +299,20 @@ bossRouter.post("/challenge", async(context) => { state.resources.crystals = state.resources.crystals + crystalsAwarded; } - // First-kill bounty — look up authoritative bounty from static data + // First-kill bounty — only awarded once across all prestiges const staticBoss = defaultBosses.find((b) => { return b.id === body.bossId; }); // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - const bountyRunestones = staticBoss?.bountyRunestones ?? 0; + /* v8 ignore next 7 -- @preserve */ + const bountyRunestones + = boss.bountyRunestonesClaimed === true + ? 0 + : staticBoss?.bountyRunestones ?? 0; + if (bountyRunestones > 0) { + boss.bountyRunestonesClaimed = true; + } state.prestige.runestones = state.prestige.runestones + bountyRunestones; rewards = { diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index d05de0b..8f40a35 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -206,6 +206,20 @@ const buildPostPrestigeState = ( const freshState = initialGameState(currentState.player, characterName); + /* + * Preserve first-kill (bounty claimed) status across the prestige reset so + * the one-time bounty is never re-awarded in subsequent runs. + */ + const bossesWithBountyClaimed = freshState.bosses.map((freshBoss) => { + const currentBoss = currentState.bosses.find((candidate) => { + return candidate.id === freshBoss.id; + }); + if (currentBoss?.bountyRunestonesClaimed === true) { + return { ...freshBoss, bountyRunestonesClaimed: true }; + } + return freshBoss; + }); + // Compute current-run contributions to accumulate into lifetime totals const runBossesDefeated = currentState.bosses.filter((boss) => { return boss.status === "defeated"; @@ -227,6 +241,8 @@ const buildPostPrestigeState = ( ...freshState, // Achievements are permanent — earned achievements survive all prestiges achievements: currentState.achievements, + // Boss statuses reset for gameplay, but first-kill claimed flag is preserved + bosses: bossesWithBountyClaimed, lastTickAt: Date.now(), /* diff --git a/apps/api/test/routes/boss.spec.ts b/apps/api/test/routes/boss.spec.ts index 37b2aca..98fa037 100644 --- a/apps/api/test/routes/boss.spec.ts +++ b/apps/api/test/routes/boss.spec.ts @@ -305,4 +305,25 @@ describe("boss route", () => { const res = await challenge({ bossId: "test_boss" }); expect(res.status).toBe(500); }); + + it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => { + const state = makeState({ + bosses: [makeBoss({ + bountyRunestonesClaimed: true, + currentHp: 100, + damagePerSecond: 1, + maxHp: 100, + })] as GameState["bosses"], + adventurers: [makeAdventurer()] as GameState["adventurers"], + prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 }, + zones: [], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } }; + expect(body.won).toBe(true); + expect(body.rewards.bountyRunestones).toBe(0); + }); }); diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index c913e74..cbff3d0 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -292,6 +292,33 @@ describe("buildPostPrestigeState", () => { expect(prestigeState.player.lifetimeBossesDefeated).toBe(1); }); + it("preserves bountyRunestonesClaimed flag on bosses across prestige", () => { + const claimedBoss = { + bountyRunestones: 5, + bountyRunestonesClaimed: true, + crystalReward: 0, + currentHp: 0, + damagePerSecond: 10, + description: "A boss", + equipmentRewards: [] as string[], + essenceReward: 0, + goldReward: 100, + id: "troll_king", + maxHp: 100, + name: "Troll King", + prestigeRequirement: 0, + status: "defeated" as const, + upgradeRewards: [] as string[], + zoneId: "verdant_vale", + }; + const state = makeMinimalState({ bosses: [ claimedBoss ] as GameState["bosses"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + const matchingBoss = prestigeState.bosses.find((boss) => { + return boss.id === "troll_king"; + }); + expect(matchingBoss?.bountyRunestonesClaimed).toBe(true); + }); + it("accumulates completed quests into lifetime total", () => { const quest = { id: "q_1", diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx index c324a61..df47f6e 100644 --- a/apps/web/src/components/game/bossPanel.tsx +++ b/apps/web/src/components/game/bossPanel.tsx @@ -126,7 +126,9 @@ const BossCard = ({ {" Equipment"} } - {boss.status !== "defeated" && boss.bountyRunestones > 0 + {boss.status !== "defeated" + && boss.bountyRunestones > 0 + && boss.bountyRunestonesClaimed !== true && {"🔮 "} {boss.bountyRunestones} diff --git a/packages/types/src/interfaces/boss.ts b/packages/types/src/interfaces/boss.ts index eabf646..9a2a91e 100644 --- a/packages/types/src/interfaces/boss.ts +++ b/packages/types/src/interfaces/boss.ts @@ -59,6 +59,13 @@ interface Boss { * One-time runestone bounty awarded on first-ever defeat. */ bountyRunestones: number; + + /** + * Whether the first-kill runestone bounty has already been claimed. + * Set to true on first defeat and preserved across all prestiges so the + * bounty is never re-awarded in subsequent runs. + */ + bountyRunestonesClaimed?: boolean; } export type { Boss, BossStatus }; -- 2.52.0