fix: apply crystal multiplier to boss rewards, steepen prestige threshold, fix stale upgrade race condition, and fix companion format display
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m6s
CI / Lint, Build & Test (pull_request) Failing after 1m13s

Closes #221
Closes #222
Closes #201
Closes #213
This commit is contained in:
2026-04-06 13:22:15 -07:00
committed by Naomi Carrigan
parent 1c10df88fb
commit 69579e166a
10 changed files with 131 additions and 55 deletions
+4 -2
View File
@@ -205,9 +205,11 @@ bossRouter.post("/challenge", async(context) => {
boss.status = "defeated"; boss.status = "defeated";
boss.currentHp = 0; boss.currentHp = 0;
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
state.resources.gold = state.resources.gold + boss.goldReward; state.resources.gold = state.resources.gold + boss.goldReward;
state.resources.essence = state.resources.essence + boss.essenceReward; state.resources.essence = state.resources.essence + boss.essenceReward;
state.resources.crystals = state.resources.crystals + boss.crystalReward; const crystalAward = boss.crystalReward * crystalMult;
state.resources.crystals = state.resources.crystals + crystalAward;
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward; state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
for (const upgradeId of boss.upgradeRewards) { for (const upgradeId of boss.upgradeRewards) {
@@ -323,7 +325,7 @@ bossRouter.post("/challenge", async(context) => {
rewards = { rewards = {
bountyRunestones: bountyRunestones, bountyRunestones: bountyRunestones,
crystals: boss.crystalReward, crystals: crystalAward,
equipmentIds: boss.equipmentRewards, equipmentIds: boss.equipmentRewards,
essence: boss.essenceReward, essence: boss.essenceReward,
gold: boss.goldReward, gold: boss.goldReward,
+12
View File
@@ -546,6 +546,17 @@ const validateAndSanitize = (
? previous.prestige ? previous.prestige
: incoming.prestige; : incoming.prestige;
/*
* If the DB prestige count is higher than the client's, the client is sending a
* stale pre-prestige save. Discard its upgrades (which have purchased: true) in
* favour of the DB's post-prestige upgrades (purchased: false) so that upgrade
* multipliers cannot persist across prestige via a race-condition auto-save.
*/
const upgrades
= incoming.prestige.count < previous.prestige.count
? previous.upgrades
: incoming.upgrades;
/* /*
* Echoes are only granted server-side via transcendence and can only decrease between * Echoes are only granted server-side via transcendence and can only decrease between
* saves (spent on echo upgrades). Cap at the previous value to block inflation. * saves (spent on echo upgrades). Cap at the previous value to block inflation.
@@ -671,6 +682,7 @@ const validateAndSanitize = (
prestige, prestige,
quests, quests,
resources, resources,
upgrades,
...transcendenceSpread, ...transcendenceSpread,
...apotheosisSpread, ...apotheosisSpread,
...explorationSpread, ...explorationSpread,
+3 -3
View File
@@ -28,8 +28,8 @@ const maxBaseRunestones = 200;
/** /**
* Calculates the gold threshold required for the next prestige. * Calculates the gold threshold required for the next prestige.
* Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 810 * Formula: BASE * (count + 1)^2.5steeper growth to keep late prestiges
* then gets easier as the production multiplier overtakes it. * meaningful even as the production multiplier scales.
* @param prestigeCount - The current number of prestiges completed. * @param prestigeCount - The current number of prestiges completed.
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold. * @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
* @returns The gold amount required to prestige. * @returns The gold amount required to prestige.
@@ -40,7 +40,7 @@ const calculatePrestigeThreshold = (
): number => { ): number => {
return ( return (
basePrestigeGoldThreshold basePrestigeGoldThreshold
* Math.pow(prestigeCount + 1, 2) * Math.pow(prestigeCount + 1, 2.5)
* thresholdMultiplier * thresholdMultiplier
); );
}; };
+22
View File
@@ -477,6 +477,28 @@ describe("game route", () => {
expect(body.savedAt).toBeGreaterThan(0); expect(body.savedAt).toBeGreaterThan(0);
}); });
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
const prevUpgrades = [
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
] as GameState["upgrades"];
const prevState = makeState({
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
upgrades: prevUpgrades,
});
const incomingState = makeState({
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
upgrades: [
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
] as GameState["upgrades"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
});
it("validates companion when active companion is legitimately unlocked", async () => { it("validates companion when active companion is legitimately unlocked", async () => {
const prevState = makeState(); const prevState = makeState();
const stateWithCompanion = makeState({ const stateWithCompanion = makeState({
+10
View File
@@ -81,6 +81,16 @@ describe("prestige route", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns runestones on successful prestige", async () => { it("returns runestones on successful prestige", async () => {
const state = makeState(); const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never); vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
+7 -7
View File
@@ -55,18 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
describe("calculatePrestigeThreshold", () => { describe("calculatePrestigeThreshold", () => {
it("returns base threshold at count 0", () => { it("returns base threshold at count 0", () => {
// base × (0+1)^2 = 1_000_000 × 1 = 1_000_000 // base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
expect(calculatePrestigeThreshold(0)).toBe(1_000_000); expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
}); });
it("returns 4× base at count 1", () => { it("returns base × 2^2.5 at count 1", () => {
// base × (1+1)^2 = 1_000_000 × 4 = 4_000_000 // base × (1+1)^2.5 = 1_000_000 × 2^2.5
expect(calculatePrestigeThreshold(1)).toBe(4_000_000); expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
}); });
it("returns 9× base at count 2", () => { it("returns base × 3^2.5 at count 2", () => {
// base × (2+1)^2 = 1_000_000 × 9 = 9_000_000 // base × (2+1)^2.5 = 1_000_000 × 3^2.5
expect(calculatePrestigeThreshold(2)).toBe(9_000_000); expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
}); });
it("applies threshold multiplier correctly", () => { it("applies threshold multiplier correctly", () => {
@@ -6,6 +6,7 @@
*/ */
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */ /* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
/* eslint-disable complexity -- Companion card has many conditional render paths */
import { COMPANIONS, type Companion } from "@elysium/types"; import { COMPANIONS, type Companion } from "@elysium/types";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -28,41 +29,12 @@ const unlockLabels: Record<string, string> = {
transcendence: "transcendence(s)", transcendence: "transcendence(s)",
}; };
/**
* Formats a companion unlock threshold for display.
* @param type - The unlock condition type.
* @param threshold - The threshold value.
* @returns The formatted threshold string.
*/
const formatThreshold = (type: string, threshold: number): string => {
if (type === "lifetimeGold") {
if (threshold >= 1e18) {
return `${(threshold / 1e18).toFixed(0)}Qt`;
}
if (threshold >= 1e15) {
return `${(threshold / 1e15).toFixed(0)}Q`;
}
if (threshold >= 1e12) {
return `${(threshold / 1e12).toFixed(0)}T`;
}
if (threshold >= 1e9) {
return `${(threshold / 1e9).toFixed(0)}B`;
}
if (threshold >= 1e6) {
return `${(threshold / 1e6).toFixed(0)}M`;
}
if (threshold >= 1e3) {
return `${(threshold / 1e3).toFixed(0)}K`;
}
}
return threshold.toString();
};
interface CompanionCardProperties { interface CompanionCardProperties {
readonly companion: Companion; readonly companion: Companion;
readonly isUnlocked: boolean; readonly isUnlocked: boolean;
readonly isActive: boolean; readonly isActive: boolean;
readonly onSelect: ()=> void; readonly onSelect: ()=> void;
readonly formatNumber: (n: number)=> string;
} }
/** /**
@@ -72,6 +44,7 @@ interface CompanionCardProperties {
* @param props.isUnlocked - Whether this companion is unlocked. * @param props.isUnlocked - Whether this companion is unlocked.
* @param props.isActive - Whether this companion is currently active. * @param props.isActive - Whether this companion is currently active.
* @param props.onSelect - Callback when the companion is selected/deselected. * @param props.onSelect - Callback when the companion is selected/deselected.
* @param props.formatNumber - The number formatting utility function.
* @returns The JSX element. * @returns The JSX element.
*/ */
const CompanionCard = ({ const CompanionCard = ({
@@ -79,6 +52,7 @@ const CompanionCard = ({
isUnlocked, isUnlocked,
isActive, isActive,
onSelect, onSelect,
formatNumber,
}: CompanionCardProperties): JSX.Element => { }: CompanionCardProperties): JSX.Element => {
const bonusSign = companion.bonus.type === "questTime" const bonusSign = companion.bonus.type === "questTime"
? "-" ? "-"
@@ -138,10 +112,9 @@ const CompanionCard = ({
</button> </button>
: <div className="companion-unlock-requirement"> : <div className="companion-unlock-requirement">
{"🔒 Unlock: "} {"🔒 Unlock: "}
{formatThreshold( {companion.unlock.type === "lifetimeGold"
companion.unlock.type, ? formatNumber(companion.unlock.threshold)
companion.unlock.threshold, : String(companion.unlock.threshold)}{" "}
)}{" "}
{unlockLabels[companion.unlock.type] ?? companion.unlock.type} {unlockLabels[companion.unlock.type] ?? companion.unlock.type}
</div> </div>
} }
@@ -154,7 +127,7 @@ const CompanionCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const CompanionPanel = (): JSX.Element => { const CompanionPanel = (): JSX.Element => {
const { state, setActiveCompanion } = useGame(); const { formatNumber, setActiveCompanion, state } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -204,6 +177,7 @@ const CompanionPanel = (): JSX.Element => {
return ( return (
<CompanionCard <CompanionCard
companion={companion} companion={companion}
formatNumber={formatNumber}
isActive={activeId === companion.id} isActive={activeId === companion.id}
isUnlocked={unlockedIds.includes(companion.id)} isUnlocked={unlockedIds.includes(companion.id)}
key={companion.id} key={companion.id}
@@ -27,12 +27,12 @@ const baseThreshold = 1_000_000;
/** /**
* Calculates the prestige threshold for a given prestige count. * Calculates the prestige threshold for a given prestige count.
* Mirrors the server formula: BASE * (count + 1)^2. * Mirrors the server formula: BASE * (count + 1)^2.5.
* @param prestigeCount - The current prestige count. * @param prestigeCount - The current prestige count.
* @returns The required gold to prestige. * @returns The required gold to prestige.
*/ */
const calculateThreshold = (prestigeCount: number): number => { const calculateThreshold = (prestigeCount: number): number => {
return baseThreshold * Math.pow(prestigeCount + 1, 2); return baseThreshold * Math.pow(prestigeCount + 1, 2.5);
}; };
/** /**
+1 -1
View File
@@ -465,7 +465,7 @@ export const computeProjectedRunestones = (state: GameState): number => {
const thresholdMult: number const thresholdMult: number
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1; = state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
const threshold const threshold
= basePrestigeThreshold * Math.pow(count + 1, 2) * thresholdMult; = basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
const base = Math.min( const base = Math.min(
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold)) Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
* runestonesPerPrestigeLevelClient, * runestonesPerPrestigeLevelClient,
+57 -1
View File
@@ -8,7 +8,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatNumber } from "../src/utils/format.js"; import { formatInteger, formatNumber } from "../src/utils/format.js";
describe("formatNumber", () => { describe("formatNumber", () => {
describe("edge cases", () => { describe("edge cases", () => {
@@ -142,3 +142,59 @@ describe("formatNumber", () => {
}); });
}); });
}); });
describe("formatInteger", () => {
describe("edge cases", () => {
it("should return '0' for NaN", () => {
expect(formatInteger(Number.NaN)).toBe("0");
});
it("should return '0' for Infinity", () => {
expect(formatInteger(Infinity)).toBe("0");
});
it("should format negative integers with a leading minus sign", () => {
expect(formatInteger(-1500)).toBe("-1K");
});
it("should format zero as '0'", () => {
expect(formatInteger(0)).toBe("0");
});
it("should format small integers without decimals", () => {
expect(formatInteger(42)).toBe("42");
});
});
describe("named suffixes", () => {
it("should format thousands with K suffix and no decimals", () => {
expect(formatInteger(1500)).toBe("1K");
});
it("should format millions with M suffix and no decimals", () => {
expect(formatInteger(2_500_000)).toBe("2M");
});
it("should format billions with B suffix", () => {
expect(formatInteger(3_000_000_000)).toBe("3B");
});
it("should format trillions with T suffix", () => {
expect(formatInteger(1e12)).toBe("1T");
});
it("should format quintillions with Qi suffix", () => {
expect(formatInteger(1e18)).toBe("1Qi");
});
});
describe("letter suffixes", () => {
it("should format values >= 1e36 with letter suffix 'a'", () => {
expect(formatInteger(1e36)).toBe("1a");
});
it("should format values >= 1e39 with letter suffix 'b'", () => {
expect(formatInteger(1e39)).toBe("1b");
});
});
});