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.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 8–10
|
* Formula: BASE * (count + 1)^2.5 — steeper 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
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user