From 35e4d71d981ee90590bd952df3db1bbfff2483b9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 21:35:03 -0700 Subject: [PATCH] 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 };