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 };