From d0790890ee5dae19076eae3b344e979cd8d0ca44 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 21:53:58 -0700 Subject: [PATCH] fix: preserve all-time stats, achievements, and boss first-kill across prestige (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #37, resolves #38, and resolves #39 — three related bugs where prestige incorrectly reset data that should survive all prestige resets. ## Changes ### fix: preserve lifetime player stats across prestige (#37) After prestige, `GameState.player.lifetime*` fields were stale — they reflected values from *before* the current run. The Prisma Player record was incremented correctly, but the GameState JSON saved to the DB had old values, so the UI showed wrong all-time totals on reload. `buildPostPrestigeState` now computes the run-stat contributions (bosses defeated, quests completed, adventurers recruited, achievements unlocked, gold earned, clicks) and folds them into the fresh player object before writing the prestige state. ### fix: preserve achievements across prestige (#38) `buildPostPrestigeState` was reconstructing achievements from `defaultAchievements` (via `initialGameState`), resetting all unlocked achievements on every prestige. Achievements are now carried forward from `currentState.achievements` instead. ### fix: preserve boss first-kill state across prestige (#39) Added `bountyRunestonesClaimed?: boolean` to the `Boss` type. The boss challenge route now: - Only awards the first-kill bounty runestones if `bountyRunestonesClaimed !== true` - Sets `bountyRunestonesClaimed = true` on first defeat `buildPostPrestigeState` maps the fresh boss list and carries the `bountyRunestonesClaimed` flag forward from the current state, so the bounty is never re-awarded in subsequent prestige runs. The boss panel badge is also hidden for bosses whose bounty is already claimed. ## Test Coverage All three fixes include new tests covering the new behaviours. API coverage remains at 100%. ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/elysium/pulls/47 Co-authored-by: Hikari Co-committed-by: Hikari --- apps/api/src/routes/boss.ts | 13 +- apps/api/src/services/prestige.ts | 63 ++++++++- apps/api/test/routes/boss.spec.ts | 21 +++ apps/api/test/services/prestige.spec.ts | 148 +++++++++++++++++++-- apps/web/src/components/game/bossPanel.tsx | 4 +- packages/types/src/interfaces/boss.ts | 7 + 6 files changed, 242 insertions(+), 14 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 db2e0be..8f40a35 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -205,10 +205,69 @@ 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"; + }).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, + // 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(), + + /* + * 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/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 724a021..cbff3d0 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,126 @@ 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("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", + name: "A Quest", + description: "Do the thing", + status: "completed" as const, + zoneId: "zone_1", + }; + const state = makeMinimalState({ quests: [ quest ] as GameState["quests"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeQuestsCompleted).toBe(1); + }); + + it("accumulates recruited adventurers into lifetime total", () => { + const adventurer = { + combatPower: 10, + count: 5, + essencePerSecond: 0, + goldPerSecond: 1, + id: "adv_1", + level: 1, + unlocked: true, + }; + const state = makeMinimalState({ adventurers: [ adventurer ] as GameState["adventurers"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeAdventurersRecruited).toBe(5); + }); + + it("preserves achievements from current state across prestige", () => { + const achievement = { + description: "Did a thing", + id: "ach_persisted", + name: "Achiever", + requirement: 1, + type: "totalClicks" as const, + unlockedAt: Date.now(), + }; + const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.achievements).toEqual([ achievement ]); + }); + + it("accumulates unlocked achievements into lifetime total", () => { + const achievement = { + description: "Did a thing", + id: "ach_1", + name: "Achiever", + requirement: 1, + type: "totalClicks" as const, + unlockedAt: Date.now(), + }; + const state = makeMinimalState({ achievements: [ achievement ] as GameState["achievements"] }); + const { prestigeState } = buildPostPrestigeState(state, "Tester"); + expect(prestigeState.player.lifetimeAchievementsUnlocked).toBe(1); + }); }); 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 };