generated from nhcarrigan/template
fix: preserve all-time stats, achievements, and boss first-kill across prestige #47
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user