fix: preserve all-time stats, achievements, and boss first-kill across prestige #47

Merged
naomi merged 3 commits from fix/prestige-resets into main 2026-03-09 21:53:59 -07:00
6 changed files with 84 additions and 4 deletions
Showing only changes of commit 35e4d71d98 - Show all commits
+10 -3
View File
@@ -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 = {
+16
View File
@@ -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(),
/*
+21
View File
@@ -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);
});
});
+27
View File
@@ -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",
+3 -1
View File
@@ -126,7 +126,9 @@ const BossCard = ({
{" Equipment"}
</span>
}
{boss.status !== "defeated" && boss.bountyRunestones > 0
{boss.status !== "defeated"
&& boss.bountyRunestones > 0
&& boss.bountyRunestonesClaimed !== true
&& <span className="boss-bounty">
{"🔮 "}
{boss.bountyRunestones}
+7
View File
@@ -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 };