generated from nhcarrigan/template
fix: preserve all-time stats, achievements, and boss first-kill across prestige (#47)
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: #47 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #47.
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
/* 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 {
|
import {
|
||||||
computeSetBonuses,
|
computeSetBonuses,
|
||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
@@ -298,14 +299,20 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
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) => {
|
const staticBoss = defaultBosses.find((b) => {
|
||||||
return b.id === body.bossId;
|
return b.id === body.bossId;
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
/* v8 ignore next -- @preserve */
|
/* v8 ignore next 7 -- @preserve */
|
||||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
const bountyRunestones
|
||||||
|
= boss.bountyRunestonesClaimed === true
|
||||||
|
? 0
|
||||||
|
: staticBoss?.bountyRunestones ?? 0;
|
||||||
|
if (bountyRunestones > 0) {
|
||||||
|
boss.bountyRunestonesClaimed = true;
|
||||||
|
}
|
||||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||||
|
|
||||||
rewards = {
|
rewards = {
|
||||||
|
|||||||
@@ -205,9 +205,68 @@ const buildPostPrestigeState = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const freshState = initialGameState(currentState.player, characterName);
|
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 = {
|
const prestigeState: GameState = {
|
||||||
...freshState,
|
...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(),
|
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,
|
prestige: prestigeData,
|
||||||
// Codex lore persists across prestiges — players keep their discovered entries
|
// Codex lore persists across prestiges — players keep their discovered entries
|
||||||
...currentState.codex === undefined
|
...currentState.codex === undefined
|
||||||
|
|||||||
@@ -305,4 +305,25 @@ describe("boss route", () => {
|
|||||||
const res = await challenge({ bossId: "test_boss" });
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
expect(res.status).toBe(500);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ import {
|
|||||||
} from "../../src/services/prestige.js";
|
} from "../../src/services/prestige.js";
|
||||||
import type { GameState } from "@elysium/types";
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
const makePlayer = (totalGoldEarned: number) => ({
|
const makePlayer = (
|
||||||
discordId: "test_id",
|
totalGoldEarned: number,
|
||||||
username: "testuser",
|
lifetimeGoldEarned = 0,
|
||||||
discriminator: "0",
|
totalClicks = 0,
|
||||||
|
) => ({
|
||||||
avatar: null,
|
avatar: null,
|
||||||
totalGoldEarned,
|
|
||||||
totalClicks: 0,
|
|
||||||
characterName: "Tester",
|
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> = {}): GameState =>
|
const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||||
@@ -242,4 +252,126 @@ describe("buildPostPrestigeState", () => {
|
|||||||
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
const { prestigeState } = buildPostPrestigeState(state, "Tester");
|
||||||
expect(prestigeState.apotheosis).toEqual(apotheosis);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ const BossCard = ({
|
|||||||
{" Equipment"}
|
{" Equipment"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
{boss.status !== "defeated"
|
||||||
|
&& boss.bountyRunestones > 0
|
||||||
|
&& boss.bountyRunestonesClaimed !== true
|
||||||
&& <span className="boss-bounty">
|
&& <span className="boss-bounty">
|
||||||
{"🔮 "}
|
{"🔮 "}
|
||||||
{boss.bountyRunestones}
|
{boss.bountyRunestones}
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ interface Boss {
|
|||||||
* One-time runestone bounty awarded on first-ever defeat.
|
* One-time runestone bounty awarded on first-ever defeat.
|
||||||
*/
|
*/
|
||||||
bountyRunestones: number;
|
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 };
|
export type { Boss, BossStatus };
|
||||||
|
|||||||
Reference in New Issue
Block a user