generated from nhcarrigan/template
fix: apply crystal multiplier to boss rewards, steepen prestige threshold, fix stale upgrade race condition, and fix companion format display
Closes #221 Closes #222 Closes #201 Closes #213
This commit is contained in:
@@ -205,9 +205,11 @@ bossRouter.post("/challenge", async(context) => {
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||
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;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
@@ -323,7 +325,7 @@ bossRouter.post("/challenge", async(context) => {
|
||||
|
||||
rewards = {
|
||||
bountyRunestones: bountyRunestones,
|
||||
crystals: boss.crystalReward,
|
||||
crystals: crystalAward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
essence: boss.essenceReward,
|
||||
gold: boss.goldReward,
|
||||
|
||||
@@ -546,6 +546,17 @@ const validateAndSanitize = (
|
||||
? previous.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
|
||||
* saves (spent on echo upgrades). Cap at the previous value to block inflation.
|
||||
@@ -671,6 +682,7 @@ const validateAndSanitize = (
|
||||
prestige,
|
||||
quests,
|
||||
resources,
|
||||
upgrades,
|
||||
...transcendenceSpread,
|
||||
...apotheosisSpread,
|
||||
...explorationSpread,
|
||||
|
||||
@@ -28,8 +28,8 @@ const maxBaseRunestones = 200;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 8–10
|
||||
* then gets easier as the production multiplier overtakes it.
|
||||
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
|
||||
* meaningful even as the production multiplier scales.
|
||||
* @param prestigeCount - The current number of prestiges completed.
|
||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||
* @returns The gold amount required to prestige.
|
||||
@@ -40,7 +40,7 @@ const calculatePrestigeThreshold = (
|
||||
): number => {
|
||||
return (
|
||||
basePrestigeGoldThreshold
|
||||
* Math.pow(prestigeCount + 1, 2)
|
||||
* Math.pow(prestigeCount + 1, 2.5)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
@@ -477,6 +477,28 @@ describe("game route", () => {
|
||||
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 () => {
|
||||
const prevState = makeState();
|
||||
const stateWithCompanion = makeState({
|
||||
|
||||
@@ -81,6 +81,16 @@ describe("prestige route", () => {
|
||||
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 () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
|
||||
@@ -55,18 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
|
||||
describe("calculatePrestigeThreshold", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("returns 4× base at count 1", () => {
|
||||
// base × (1+1)^2 = 1_000_000 × 4 = 4_000_000
|
||||
expect(calculatePrestigeThreshold(1)).toBe(4_000_000);
|
||||
it("returns base × 2^2.5 at count 1", () => {
|
||||
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
|
||||
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
|
||||
});
|
||||
|
||||
it("returns 9× base at count 2", () => {
|
||||
// base × (2+1)^2 = 1_000_000 × 9 = 9_000_000
|
||||
expect(calculatePrestigeThreshold(2)).toBe(9_000_000);
|
||||
it("returns base × 3^2.5 at count 2", () => {
|
||||
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
|
||||
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
|
||||
});
|
||||
|
||||
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 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 { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
@@ -28,41 +29,12 @@ const unlockLabels: Record<string, string> = {
|
||||
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 {
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +44,7 @@ interface CompanionCardProperties {
|
||||
* @param props.isUnlocked - Whether this companion is unlocked.
|
||||
* @param props.isActive - Whether this companion is currently active.
|
||||
* @param props.onSelect - Callback when the companion is selected/deselected.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionCard = ({
|
||||
@@ -79,6 +52,7 @@ const CompanionCard = ({
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
formatNumber,
|
||||
}: CompanionCardProperties): JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime"
|
||||
? "-"
|
||||
@@ -138,10 +112,9 @@ const CompanionCard = ({
|
||||
</button>
|
||||
: <div className="companion-unlock-requirement">
|
||||
{"🔒 Unlock: "}
|
||||
{formatThreshold(
|
||||
companion.unlock.type,
|
||||
companion.unlock.threshold,
|
||||
)}{" "}
|
||||
{companion.unlock.type === "lifetimeGold"
|
||||
? formatNumber(companion.unlock.threshold)
|
||||
: String(companion.unlock.threshold)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
}
|
||||
@@ -154,7 +127,7 @@ const CompanionCard = ({
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionPanel = (): JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
const { formatNumber, setActiveCompanion, state } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
@@ -204,6 +177,7 @@ const CompanionPanel = (): JSX.Element => {
|
||||
return (
|
||||
<CompanionCard
|
||||
companion={companion}
|
||||
formatNumber={formatNumber}
|
||||
isActive={activeId === companion.id}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
key={companion.id}
|
||||
|
||||
@@ -27,12 +27,12 @@ const baseThreshold = 1_000_000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(prestigeCount + 1, 2);
|
||||
return baseThreshold * Math.pow(prestigeCount + 1, 2.5);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -465,7 +465,7 @@ export const computeProjectedRunestones = (state: GameState): number => {
|
||||
const thresholdMult: number
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
const threshold
|
||||
= basePrestigeThreshold * Math.pow(count + 1, 2) * thresholdMult;
|
||||
= basePrestigeThreshold * Math.pow(count + 1, 2.5) * thresholdMult;
|
||||
const base = Math.min(
|
||||
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevelClient,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatNumber } from "../src/utils/format.js";
|
||||
import { formatInteger, formatNumber } from "../src/utils/format.js";
|
||||
|
||||
describe("formatNumber", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user