From f09fcca0ef40f5d9e7b062265b42a40d78e5aa09 Mon Sep 17 00:00:00 2001 From: Anna Cottrill Date: Thu, 9 Jul 2026 15:48:39 -0400 Subject: [PATCH] feat: respect integer format pref --- apps/web/src/components/game/battleModal.tsx | 5 +- apps/web/src/components/game/bossPanel.tsx | 8 +- .../web/src/components/game/prestigePanel.tsx | 17 +-- .../src/components/game/statisticsPanel.tsx | 6 +- .../components/game/transcendencePanel.tsx | 16 +-- apps/web/src/components/ui/resourceBar.tsx | 6 +- apps/web/src/context/gameContext.tsx | 2 +- apps/web/src/utils/format.ts | 46 +++++--- apps/web/test/format.spec.ts | 106 +++++++++++++++++- 9 files changed, 170 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/game/battleModal.tsx b/apps/web/src/components/game/battleModal.tsx index 03b5983..b3076e3 100644 --- a/apps/web/src/components/game/battleModal.tsx +++ b/apps/web/src/components/game/battleModal.tsx @@ -64,6 +64,7 @@ const BattleModal = ({ flushBossLoreToasts, formatInteger, formatNumber, + numberFormat, } = useGame(); const [ phase, setPhase ] = useState<"animating" | "result">("animating"); @@ -242,14 +243,14 @@ const BattleModal = ({ {result.rewards.crystals > 0 && {"💎 "} - {formatInteger(result.rewards.crystals)} + {formatInteger(result.rewards.crystals, numberFormat)} {" crystals"} } {result.rewards.bountyRunestones > 0 && {"🔮 "} - {formatInteger(result.rewards.bountyRunestones)} + {formatInteger(result.rewards.bountyRunestones, numberFormat)} {" runestones (first kill!)"} } diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx index dd0dd0e..32ceab6 100644 --- a/apps/web/src/components/game/bossPanel.tsx +++ b/apps/web/src/components/game/bossPanel.tsx @@ -15,7 +15,7 @@ import { computePartyCombatPower } from "../../engine/tick.js"; import { cdnImage } from "../../utils/cdn.js"; import { LockToggle } from "../ui/lockToggle.js"; import { ZoneSelector } from "./zoneSelector.js"; -import type { Boss } from "@elysium/types"; +import type { Boss, NumberFormat } from "@elysium/types"; interface BossCardProperties { readonly boss: Boss; @@ -23,7 +23,7 @@ interface BossCardProperties { readonly onChallenge: (bossId: string)=> void; readonly isChallenging: boolean; readonly unlockHint: string | undefined; - readonly formatInteger: (n: number)=> string; + readonly formatInteger: (n: number, format: NumberFormat)=> string; readonly formatNumber: (n: number)=> string; } @@ -120,7 +120,7 @@ const BossCard = ({ {boss.crystalReward > 0 && {"💎 "} - {formatInteger(boss.crystalReward)} + {formatInteger(boss.crystalReward, numberFormat)} } {boss.equipmentRewards.length > 0 @@ -175,6 +175,7 @@ const BossPanel = (): JSX.Element => { autoBossLastResult, autoBossError, bossError, + numberFormat, } = useGame(); const [ challengingBossId, setChallengingBossId ] = useState( null, @@ -403,6 +404,7 @@ const BossPanel = (): JSX.Element => { formatNumber={formatNumber} isChallenging={challengingBossId === bossId} key={bossId} + numberFormat={numberFormat} onChallenge={handleChallengeClick} prestigeCount={prestigeCount} unlockHint={bossUnlockHints.get(bossId)} diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index 9339b1f..072576e 100644 --- a/apps/web/src/components/game/prestigePanel.tsx +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -70,6 +70,7 @@ const PrestigePanel = (): JSX.Element => { toggleAutoPrestige, toggleAutoPrestigeMaxRunestones, triggerPrestigeToast, + numberFormat, } = useGame(); const [ isPending, setIsPending ] = useState(false); const [ result, setResult ] = useState<{ @@ -198,7 +199,7 @@ const PrestigePanel = (): JSX.Element => { type="button" > {"🔮 Runestone Shop ("} - {formatInteger(prestigeData.runestones)} + {formatInteger(prestigeData.runestones, numberFormat)} {" stones)"} @@ -242,14 +243,14 @@ const PrestigePanel = (): JSX.Element => {

{"Runestones: "} - {formatInteger(prestigeData.runestones)} + {formatInteger(prestigeData.runestones, numberFormat)}

{isEligible ?

{"Runestones on prestige: "} {"+"} - {formatInteger(runestonePreview)} + {formatInteger(runestonePreview, numberFormat)} {isAtMaxRunestones ? {" ⚡ MAX"} @@ -289,7 +290,7 @@ const PrestigePanel = (): JSX.Element => { > {isPending ? "Ascending..." - : `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`} + : `✨ Ascend (+${formatInteger(runestonePreview, numberFormat)} Runestones)`} {prestigeError === null ? null @@ -301,12 +302,12 @@ const PrestigePanel = (): JSX.Element => { {"Ascended to Prestige "} {result.count} {"! Earned "} - {formatInteger(result.runestones)} + {formatInteger(result.runestones, numberFormat)} {" Runestones."} {result.milestoneRunestones > 0 && <> {" 🎉 Milestone bonus: +"} - {formatInteger(result.milestoneRunestones)} + {formatInteger(result.milestoneRunestones, numberFormat)} {" Runestones!"} } @@ -327,7 +328,7 @@ const PrestigePanel = (): JSX.Element => {

{"Balance: "} - {formatInteger(prestigeData.runestones)} + {formatInteger(prestigeData.runestones, numberFormat)} {" Runestones"}

@@ -381,7 +382,7 @@ const PrestigePanel = (): JSX.Element => {

{purchased ? "✅ Purchased" - : `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`} + : `🔮 ${formatInteger(upgrade.runestonesCost, numberFormat)} Runestones`}

{isAutoAdventurerToggle diff --git a/apps/web/src/components/game/statisticsPanel.tsx b/apps/web/src/components/game/statisticsPanel.tsx index d8ec44b..f7d7914 100644 --- a/apps/web/src/components/game/statisticsPanel.tsx +++ b/apps/web/src/components/game/statisticsPanel.tsx @@ -59,7 +59,7 @@ const StatCard = ({ * @returns The JSX element. */ const StatisticsPanel = (): JSX.Element => { - const { state, formatInteger, formatNumber } = useGame(); + const { state, formatInteger, formatNumber, numberFormat } = useGame(); if (state === null) { return ( @@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => { diff --git a/apps/web/src/components/game/transcendencePanel.tsx b/apps/web/src/components/game/transcendencePanel.tsx index ec80c7f..fef7793 100644 --- a/apps/web/src/components/game/transcendencePanel.tsx +++ b/apps/web/src/components/game/transcendencePanel.tsx @@ -50,7 +50,7 @@ const categoryOrder: Array = [ * @returns The JSX element. */ const TranscendencePanel = (): JSX.Element => { - const { state, formatInteger, transcend, buyEchoUpgrade } = useGame(); + const { state, formatInteger, transcend, buyEchoUpgrade, numberFormat } = useGame(); const [ isPending, setIsPending ] = useState(false); const [ result, setResult ] = useState<{ echoes: number; @@ -152,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => { type="button" > {"✨ Echo Shop ("} - {formatInteger(currentEchoes)} + {formatInteger(currentEchoes, numberFormat)} {" echoes)"} @@ -184,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => { }

{"Current Echoes: "} - {formatInteger(currentEchoes)} + {formatInteger(currentEchoes, numberFormat)}

{"Current prestige count: "} @@ -195,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => { {"Echoes on transcendence: "} {"+"} - {formatInteger(echoPreview)} + {formatInteger(echoPreview, numberFormat)} {echoMetaMultiplier > 1 && @@ -238,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => { > {isPending ? "Transcending..." - : `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`} + : `🌌 Transcend (+${formatInteger(echoPreview, numberFormat)} Echoes)`} {error === null ? null @@ -248,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => { :

{"Transcended! Earned "} - {formatInteger(result.echoes)} + {formatInteger(result.echoes, numberFormat)} {" Echoes"} {". This is Transcendence "} @@ -266,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {

{"Balance: "} - {formatInteger(currentEchoes)} + {formatInteger(currentEchoes, numberFormat)} {" Echoes"}

@@ -314,7 +314,7 @@ const TranscendencePanel = (): JSX.Element => {

{purchased ? "✅ Purchased" - : `✨ ${formatInteger(upgrade.cost)} Echoes`} + : `✨ ${formatInteger(upgrade.cost, numberFormat)} Echoes`}

{purchased diff --git a/apps/web/src/components/ui/resourceBar.tsx b/apps/web/src/components/ui/resourceBar.tsx index f992b29..9e25b9e 100644 --- a/apps/web/src/components/ui/resourceBar.tsx +++ b/apps/web/src/components/ui/resourceBar.tsx @@ -82,7 +82,7 @@ const ResourceBar = ({ isSyncing, onForceSync, }: ResourceBarProperties): JSX.Element => { - const { formatInteger, formatNumber, syncError, state } = useGame(); + const { formatInteger, formatNumber, syncError, state, numberFormat } = useGame(); const [ isProfileOpen, setIsProfileOpen ] = useState(false); const [ isResourcesOpen, setIsResourcesOpen ] = useState(false); @@ -233,14 +233,14 @@ const ResourceBar = ({
{"🔮"} - {formatInteger(runestones)} + {formatInteger(runestones, numberFormat)} {"Runestones"}
{"⭐"} - {`+${formatInteger(projectedRunestones)}`} + {`+${formatInteger(projectedRunestones, numberFormat)}`} {"On Prestige"}
diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 7da5708..1823fa0 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -467,7 +467,7 @@ interface GameContextValue { /** * Format a whole-number value without decimal places. */ - formatInteger: (value: number)=> string; + formatInteger: (value: number, format: NumberFormat)=> string; /** * Buy a prestige upgrade from the runestone shop. diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts index 101f22a..f43d12d 100644 --- a/apps/web/src/utils/format.ts +++ b/apps/web/src/utils/format.ts @@ -112,29 +112,49 @@ const formatEngineering = (value: number): string => { * Formats a whole-number value for display without decimal places. * Uses the same suffix/letter logic as formatNumber but rounds to integers. * @param value - The integer value to format. + * @param format * @returns The formatted string with no decimal places. */ -const formatInteger = (value: number): string => { +const formatInteger = (value: number, + format: NumberFormat = "suffix"): string => { if (!Number.isFinite(value) || Number.isNaN(value)) { return "0"; } if (value < 0) { return `-${formatInteger(-value)}`; } - if (value >= Math.pow(10, letterBaseExp)) { - const exp = Math.floor(Math.log10(value)); - const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3); - const steps = stepsAboveBase * 3; - const divisorExp = letterBaseExp + steps; - const divisor = Math.pow(10, divisorExp); - return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`; - } - for (const { threshold, suffix } of namedSuffixes) { - if (value >= threshold) { - return `${String(Math.floor(value / threshold))}${suffix}`; + + const flooredValue = Math.floor(value); + switch (format) { + case "scientific": + return formatScientific(flooredValue); + case "engineering": + return formatEngineering(flooredValue); + case "suffix": + return formatSuffix(flooredValue); + default: { + /* V8 ignore next -- @preserve */ + return formatSuffix(flooredValue); } } - return String(Math.floor(value)); + *If (value >= Math.pow(10, letterBaseExp)) { + * const exp = Math.floor(Math.log10(value)); + * const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3); + * const steps = stepsAboveBase * 3; + * const divisorExp = letterBaseExp + steps; + * const divisor = Math.pow(10, divisorExp); + * return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`; + *} + * + * + * + *for (const { threshold, suffix } of namedSuffixes) { + * if (value >= threshold) { + * return `${String(Math.floor(value / threshold))}${suffix}`; + * } + *} + *return String(Math.floor(value)); + */ }; /** diff --git a/apps/web/test/format.spec.ts b/apps/web/test/format.spec.ts index a4d7462..313a2bf 100644 --- a/apps/web/test/format.spec.ts +++ b/apps/web/test/format.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Test suites naturally have many cases */ /* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ /* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */ /** @@ -197,4 +196,109 @@ describe("formatInteger", () => { expect(formatInteger(1e39)).toBe("1b"); }); }); + + describe("suffix format (default)", () => { + it("should format small numbers with one decimal place", () => { + expect(formatInteger(999)).toBe("999.0"); + }); + + it("should format thousands with K suffix", () => { + expect(formatInteger(1000)).toBe("1.00K"); + }); + + it("should format millions with M suffix", () => { + expect(formatInteger(1_000_000)).toBe("1.00M"); + }); + + it("should format billions with B suffix", () => { + expect(formatInteger(1_000_000_000)).toBe("1.00B"); + }); + + it("should format trillions with T suffix", () => { + expect(formatInteger(1e12)).toBe("1.00T"); + }); + + it("should format quadrillions with Qa suffix", () => { + expect(formatInteger(1e15)).toBe("1.00Qa"); + }); + + it("should format quintillions with Qi suffix", () => { + expect(formatInteger(1e18)).toBe("1.00Qi"); + }); + + it("should format sextillions with Sx suffix", () => { + expect(formatInteger(1e21)).toBe("1.00Sx"); + }); + + it("should format septillions with Sp suffix", () => { + expect(formatInteger(1e24)).toBe("1.00Sp"); + }); + + it("should format octillions with Oc suffix", () => { + expect(formatInteger(1e27)).toBe("1.00Oc"); + }); + + it("should format nonillions with No suffix", () => { + expect(formatInteger(1e30)).toBe("1.00No"); + }); + + it("should format decillions with Dc suffix", () => { + expect(formatInteger(1e33)).toBe("1.00Dc"); + }); + + it("should format values >= 1e36 with letter suffix 'a'", () => { + expect(formatInteger(1e36)).toBe("1.00a"); + }); + + it("should format values >= 1e39 with letter suffix 'b'", () => { + expect(formatInteger(1e39)).toBe("1.00b"); + }); + + it("should format values at 26th letter step with 'z'", () => { + expect(formatInteger(1e36 * Math.pow(10, 25 * 3))).toBe("1.00z"); + }); + + it("should format values at 27th letter step with 'aa'", () => { + expect(formatInteger(1e36 * Math.pow(10, 26 * 3))).toBe("1.00aa"); + }); + }); + + describe("scientific format", () => { + it("should fall back to suffix format below 1e6", () => { + expect(formatInteger(500, "scientific")).toBe("500.0"); + }); + + it("should format values >= 1e6 in scientific notation", () => { + expect(formatInteger(1_230_000, "scientific")).toBe("1.23e6"); + }); + + it("should format large values in scientific notation", () => { + expect(formatInteger(1e18, "scientific")).toBe("1.00e18"); + }); + }); + + describe("engineering format", () => { + it("should fall back to suffix format below 1e6", () => { + expect(formatInteger(500, "engineering")).toBe("500.0"); + }); + + it("should format values >= 1e6 with exponent multiple of 3", () => { + expect(formatInteger(1_230_000, "engineering")).toBe("1.23E6"); + }); + + it("should format 1e9 correctly in engineering notation", () => { + expect(formatInteger(1e9, "engineering")).toBe("1.00E9"); + }); + + it("should format 12350000 correctly in engineering notation", () => { + expect(formatInteger(12_350_000, "engineering")).toBe("12.35E6"); + }); + }); + + describe("unknown format (default branch)", () => { + it("should fall back to suffix format for an unrecognised format string", () => { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Testing unreachable default branch */ + expect(formatInteger(1000, "unknown" as never)).toBe("1.00K"); + }); + }); });