diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index 47a4754..efdaccb 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -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, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index f74e31e..7155011 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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, diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index ac08e89..372c4ee 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -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 ); }; diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index b846381..39c8ba2 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -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({ diff --git a/apps/api/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index 5c6546a..d3ef374 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -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); diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 2cedc49..8e7479e 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -55,18 +55,18 @@ const makeMinimalState = (overrides: Partial = {}): 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", () => { diff --git a/apps/web/src/components/game/companionPanel.tsx b/apps/web/src/components/game/companionPanel.tsx index f4dd476..d664f80 100644 --- a/apps/web/src/components/game/companionPanel.tsx +++ b/apps/web/src/components/game/companionPanel.tsx @@ -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 = { 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 = ({ :
{"🔒 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}
} @@ -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 ( { - return baseThreshold * Math.pow(prestigeCount + 1, 2); + return baseThreshold * Math.pow(prestigeCount + 1, 2.5); }; /** diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 9be3b2e..a53cf0c 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -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, diff --git a/apps/web/test/format.spec.ts b/apps/web/test/format.spec.ts index 6e1a51d..a4d7462 100644 --- a/apps/web/test/format.spec.ts +++ b/apps/web/test/format.spec.ts @@ -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"); + }); + }); +});