2 Commits

Author SHA1 Message Date
nerdychara 70e031eed7 update formatNumber
CI / Lint, Build & Test (pull_request) Failing after 54s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m13s
2026-07-09 16:45:30 -04:00
nerdychara f09fcca0ef feat: respect integer format pref 2026-07-09 15:48:39 -04:00
10 changed files with 116 additions and 53 deletions
+2 -2
View File
@@ -626,7 +626,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
durationSeconds: 75 * 60, durationSeconds: 90 * 60,
events: [ events: [
{ {
effect: { amount: 70_000, type: "gold_gain" }, effect: { amount: 70_000, type: "gold_gain" },
@@ -671,7 +671,7 @@ export const defaultExplorations: Array<ExplorationArea> = [
{ {
description: description:
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
durationSeconds: 100 * 60, durationSeconds: 90 * 60,
events: [ events: [
{ {
effect: { amount: 120_000, type: "gold_gain" }, effect: { amount: 120_000, type: "gold_gain" },
+3 -2
View File
@@ -64,6 +64,7 @@ const BattleModal = ({
flushBossLoreToasts, flushBossLoreToasts,
formatInteger, formatInteger,
formatNumber, formatNumber,
numberFormat,
} = useGame(); } = useGame();
const [ phase, setPhase ] = useState<"animating" | "result">("animating"); const [ phase, setPhase ] = useState<"animating" | "result">("animating");
@@ -242,14 +243,14 @@ const BattleModal = ({
{result.rewards.crystals > 0 {result.rewards.crystals > 0
&& <span> && <span>
{"💎 "} {"💎 "}
{formatInteger(result.rewards.crystals)} {formatInteger(result.rewards.crystals, numberFormat)}
{" crystals"} {" crystals"}
</span> </span>
} }
{result.rewards.bountyRunestones > 0 {result.rewards.bountyRunestones > 0
&& <span className="battle-bounty"> && <span className="battle-bounty">
{"🔮 "} {"🔮 "}
{formatInteger(result.rewards.bountyRunestones)} {formatInteger(result.rewards.bountyRunestones, numberFormat)}
{" runestones (first kill!)"} {" runestones (first kill!)"}
</span> </span>
} }
+5 -3
View File
@@ -15,7 +15,7 @@ import { computePartyCombatPower } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Boss } from "@elysium/types"; import type { Boss, NumberFormat } from "@elysium/types";
interface BossCardProperties { interface BossCardProperties {
readonly boss: Boss; readonly boss: Boss;
@@ -23,7 +23,7 @@ interface BossCardProperties {
readonly onChallenge: (bossId: string)=> void; readonly onChallenge: (bossId: string)=> void;
readonly isChallenging: boolean; readonly isChallenging: boolean;
readonly unlockHint: string | undefined; readonly unlockHint: string | undefined;
readonly formatInteger: (n: number)=> string; readonly formatInteger: (n: number, format: NumberFormat)=> string;
readonly formatNumber: (n: number)=> string; readonly formatNumber: (n: number)=> string;
} }
@@ -120,7 +120,7 @@ const BossCard = ({
{boss.crystalReward > 0 {boss.crystalReward > 0
&& <span> && <span>
{"💎 "} {"💎 "}
{formatInteger(boss.crystalReward)} {formatInteger(boss.crystalReward, numberFormat)}
</span> </span>
} }
{boss.equipmentRewards.length > 0 {boss.equipmentRewards.length > 0
@@ -175,6 +175,7 @@ const BossPanel = (): JSX.Element => {
autoBossLastResult, autoBossLastResult,
autoBossError, autoBossError,
bossError, bossError,
numberFormat,
} = useGame(); } = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>( const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null, null,
@@ -403,6 +404,7 @@ const BossPanel = (): JSX.Element => {
formatNumber={formatNumber} formatNumber={formatNumber}
isChallenging={challengingBossId === bossId} isChallenging={challengingBossId === bossId}
key={bossId} key={bossId}
numberFormat={numberFormat}
onChallenge={handleChallengeClick} onChallenge={handleChallengeClick}
prestigeCount={prestigeCount} prestigeCount={prestigeCount}
unlockHint={bossUnlockHints.get(bossId)} unlockHint={bossUnlockHints.get(bossId)}
@@ -70,6 +70,7 @@ const PrestigePanel = (): JSX.Element => {
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoPrestigeMaxRunestones, toggleAutoPrestigeMaxRunestones,
triggerPrestigeToast, triggerPrestigeToast,
numberFormat,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
@@ -198,7 +199,7 @@ const PrestigePanel = (): JSX.Element => {
type="button" type="button"
> >
{"🔮 Runestone Shop ("} {"🔮 Runestone Shop ("}
{formatInteger(prestigeData.runestones)} {formatInteger(prestigeData.runestones, numberFormat)}
{" stones)"} {" stones)"}
</button> </button>
</div> </div>
@@ -242,14 +243,14 @@ const PrestigePanel = (): JSX.Element => {
</p> </p>
<p> <p>
{"Runestones: "} {"Runestones: "}
<strong>{formatInteger(prestigeData.runestones)}</strong> <strong>{formatInteger(prestigeData.runestones, numberFormat)}</strong>
</p> </p>
{isEligible {isEligible
? <p className="runestone-preview"> ? <p className="runestone-preview">
{"Runestones on prestige: "} {"Runestones on prestige: "}
<strong> <strong>
{"+"} {"+"}
{formatInteger(runestonePreview)} {formatInteger(runestonePreview, numberFormat)}
</strong> </strong>
{isAtMaxRunestones {isAtMaxRunestones
? <span className="runestone-max-badge">{" ⚡ MAX"}</span> ? <span className="runestone-max-badge">{" ⚡ MAX"}</span>
@@ -289,7 +290,7 @@ const PrestigePanel = (): JSX.Element => {
> >
{isPending {isPending
? "Ascending..." ? "Ascending..."
: `✨ Ascend (+${formatInteger(runestonePreview)} Runestones)`} : `✨ Ascend (+${formatInteger(runestonePreview, numberFormat)} Runestones)`}
</button> </button>
{prestigeError === null {prestigeError === null
? null ? null
@@ -301,12 +302,12 @@ const PrestigePanel = (): JSX.Element => {
{"Ascended to Prestige "} {"Ascended to Prestige "}
{result.count} {result.count}
{"! Earned "} {"! Earned "}
{formatInteger(result.runestones)} {formatInteger(result.runestones, numberFormat)}
{" Runestones."} {" Runestones."}
{result.milestoneRunestones > 0 {result.milestoneRunestones > 0
&& <> && <>
{" 🎉 Milestone bonus: +"} {" 🎉 Milestone bonus: +"}
{formatInteger(result.milestoneRunestones)} {formatInteger(result.milestoneRunestones, numberFormat)}
{" Runestones!"} {" Runestones!"}
</> </>
} }
@@ -327,7 +328,7 @@ const PrestigePanel = (): JSX.Element => {
<p className="shop-balance"> <p className="shop-balance">
{"Balance: "} {"Balance: "}
<strong> <strong>
{formatInteger(prestigeData.runestones)} {formatInteger(prestigeData.runestones, numberFormat)}
{" Runestones"} {" Runestones"}
</strong> </strong>
</p> </p>
@@ -381,7 +382,7 @@ const PrestigePanel = (): JSX.Element => {
<p className="upgrade-cost"> <p className="upgrade-cost">
{purchased {purchased
? "✅ Purchased" ? "✅ Purchased"
: `🔮 ${formatInteger(upgrade.runestonesCost)} Runestones`} : `🔮 ${formatInteger(upgrade.runestonesCost, numberFormat)} Runestones`}
</p> </p>
</div> </div>
{isAutoAdventurerToggle {isAutoAdventurerToggle
@@ -59,7 +59,7 @@ const StatCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const StatisticsPanel = (): JSX.Element => { const StatisticsPanel = (): JSX.Element => {
const { state, formatInteger, formatNumber } = useGame(); const { state, formatInteger, formatNumber, numberFormat } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -152,13 +152,13 @@ const StatisticsPanel = (): JSX.Element => {
<StatCard <StatCard
icon="💎" icon="💎"
label="Crystals" label="Crystals"
value={formatInteger(resources.crystals)} value={formatInteger(resources.crystals, numberFormat)}
/> />
<StatCard <StatCard
icon="🔮" icon="🔮"
label="Runestones" label="Runestones"
sub="permanent currency" sub="permanent currency"
value={formatInteger(prestige.runestones)} value={formatInteger(prestige.runestones, numberFormat)}
/> />
</div> </div>
@@ -50,7 +50,7 @@ const categoryOrder: Array<TranscendenceUpgradeCategory> = [
* @returns The JSX element. * @returns The JSX element.
*/ */
const TranscendencePanel = (): 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 [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
echoes: number; echoes: number;
@@ -152,7 +152,7 @@ const TranscendencePanel = (): JSX.Element => {
type="button" type="button"
> >
{"✨ Echo Shop ("} {"✨ Echo Shop ("}
{formatInteger(currentEchoes)} {formatInteger(currentEchoes, numberFormat)}
{" echoes)"} {" echoes)"}
</button> </button>
</div> </div>
@@ -184,7 +184,7 @@ const TranscendencePanel = (): JSX.Element => {
} }
<p> <p>
{"Current Echoes: "} {"Current Echoes: "}
<strong>{formatInteger(currentEchoes)}</strong> <strong>{formatInteger(currentEchoes, numberFormat)}</strong>
</p> </p>
<p> <p>
{"Current prestige count: "} {"Current prestige count: "}
@@ -195,7 +195,7 @@ const TranscendencePanel = (): JSX.Element => {
{"Echoes on transcendence: "} {"Echoes on transcendence: "}
<strong> <strong>
{"+"} {"+"}
{formatInteger(echoPreview)} {formatInteger(echoPreview, numberFormat)}
</strong> </strong>
{echoMetaMultiplier > 1 {echoMetaMultiplier > 1
&& <span className="echo-meta-bonus"> && <span className="echo-meta-bonus">
@@ -238,7 +238,7 @@ const TranscendencePanel = (): JSX.Element => {
> >
{isPending {isPending
? "Transcending..." ? "Transcending..."
: `🌌 Transcend (+${formatInteger(echoPreview)} Echoes)`} : `🌌 Transcend (+${formatInteger(echoPreview, numberFormat)} Echoes)`}
</button> </button>
{error === null {error === null
? null ? null
@@ -248,7 +248,7 @@ const TranscendencePanel = (): JSX.Element => {
: <p className="success"> : <p className="success">
{"Transcended! Earned "} {"Transcended! Earned "}
<strong> <strong>
{formatInteger(result.echoes)} {formatInteger(result.echoes, numberFormat)}
{" Echoes"} {" Echoes"}
</strong> </strong>
{". This is Transcendence "} {". This is Transcendence "}
@@ -266,7 +266,7 @@ const TranscendencePanel = (): JSX.Element => {
<p className="shop-balance"> <p className="shop-balance">
{"Balance: "} {"Balance: "}
<strong> <strong>
{formatInteger(currentEchoes)} {formatInteger(currentEchoes, numberFormat)}
{" Echoes"} {" Echoes"}
</strong> </strong>
</p> </p>
@@ -314,7 +314,7 @@ const TranscendencePanel = (): JSX.Element => {
<p className="upgrade-cost"> <p className="upgrade-cost">
{purchased {purchased
? "✅ Purchased" ? "✅ Purchased"
: `${formatInteger(upgrade.cost)} Echoes`} : `${formatInteger(upgrade.cost, numberFormat)} Echoes`}
</p> </p>
</div> </div>
{purchased {purchased
+3 -3
View File
@@ -82,7 +82,7 @@ const ResourceBar = ({
isSyncing, isSyncing,
onForceSync, onForceSync,
}: ResourceBarProperties): JSX.Element => { }: ResourceBarProperties): JSX.Element => {
const { formatInteger, formatNumber, syncError, state } = useGame(); const { formatInteger, formatNumber, syncError, state, numberFormat } = useGame();
const [ isProfileOpen, setIsProfileOpen ] = useState(false); const [ isProfileOpen, setIsProfileOpen ] = useState(false);
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false); const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
@@ -233,14 +233,14 @@ const ResourceBar = ({
<div className="resource"> <div className="resource">
<span className="resource-icon">{"🔮"}</span> <span className="resource-icon">{"🔮"}</span>
<span className="resource-value"> <span className="resource-value">
{formatInteger(runestones)} {formatInteger(runestones, numberFormat)}
</span> </span>
<span className="resource-label">{"Runestones"}</span> <span className="resource-label">{"Runestones"}</span>
</div> </div>
<div className="resource"> <div className="resource">
<span className="resource-icon">{"⭐"}</span> <span className="resource-icon">{"⭐"}</span>
<span className="resource-value"> <span className="resource-value">
{`+${formatInteger(projectedRunestones)}`} {`+${formatInteger(projectedRunestones, numberFormat)}`}
</span> </span>
<span className="resource-label">{"On Prestige"}</span> <span className="resource-label">{"On Prestige"}</span>
</div> </div>
+2 -2
View File
@@ -462,12 +462,12 @@ interface GameContextValue {
/** /**
* Format a number using the player's chosen notation style. * Format a number using the player's chosen notation style.
*/ */
formatNumber: (value: number)=> string; formatNumber: (value: number, format?: NumberFormat)=> string;
/** /**
* Format a whole-number value without decimal places. * 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. * Buy a prestige upgrade from the runestone shop.
+42 -21
View File
@@ -57,22 +57,32 @@ const getLetterSuffix = (index: number): string => {
/** /**
* Formats a number with a named or letter-based suffix. * Formats a number with a named or letter-based suffix.
* @param value - The number to format. * @param value - The number to format.
* @param round - Display zero numbers after a decimal point.
* @returns The formatted string with suffix. * @returns The formatted string with suffix.
*/ */
const formatSuffix = (value: number): string => { const formatSuffix = (value: number, round = false): string => {
if (value >= Math.pow(10, letterBaseExp)) { if (value >= Math.pow(10, letterBaseExp)) {
const exp = Math.floor(Math.log10(value)); const exp = Math.floor(Math.log10(value));
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3); const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
const steps = stepsAboveBase * 3; const steps = stepsAboveBase * 3;
const divisorExp = letterBaseExp + steps; const divisorExp = letterBaseExp + steps;
const divisor = Math.pow(10, divisorExp); const divisor = Math.pow(10, divisorExp);
return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`; return `${round
? String(Math.round(value / divisor))
: (value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`;
} }
for (const { threshold, suffix } of namedSuffixes) { for (const { threshold, suffix } of namedSuffixes) {
if (value >= threshold) { if (value >= threshold) {
return `${(value / threshold).toFixed(2)}${suffix}`; return `${round
? String(Math.floor(value / threshold))
: (value / threshold).toFixed(2)}${suffix}`;
} }
} }
if (round) {
return String(Math.floor(value));
}
return value < 1 return value < 1
? value.toFixed(2) ? value.toFixed(2)
: value.toFixed(1); : value.toFixed(1);
@@ -82,12 +92,18 @@ const formatSuffix = (value: number): string => {
* Formats a number in scientific notation: e.g. 1.23e15. * Formats a number in scientific notation: e.g. 1.23e15.
* Falls back to K/M/B/T style below 1 million. * Falls back to K/M/B/T style below 1 million.
* @param value - The number to format. * @param value - The number to format.
* @param round - Display zero numbers after a decimal point.
* @returns The formatted string in scientific notation. * @returns The formatted string in scientific notation.
*/ */
const formatScientific = (value: number): string => { const formatScientific = (value: number, round = false): string => {
if (value < 1e6) { if (value < 1e6) {
return formatSuffix(value); return formatSuffix(value, round);
} }
if (round) {
return value.toExponential(0).replace("e+", "e");
}
// ToExponential handles all magnitudes JS can represent (up to ~1.8e308) // ToExponential handles all magnitudes JS can represent (up to ~1.8e308)
return value.toExponential(2).replace("e+", "e"); return value.toExponential(2).replace("e+", "e");
}; };
@@ -96,45 +112,50 @@ const formatScientific = (value: number): string => {
* Formats a number in engineering notation (exponent always a multiple of 3): * Formats a number in engineering notation (exponent always a multiple of 3):
* e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million. * e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million.
* @param value - The number to format. * @param value - The number to format.
* @param round - Display zero numbers after a decimal point.
* @returns The formatted string in engineering notation. * @returns The formatted string in engineering notation.
*/ */
const formatEngineering = (value: number): string => { const formatEngineering = (value: number, round = false): string => {
if (value < 1e6) { if (value < 1e6) {
return formatSuffix(value); return formatSuffix(value, round);
} }
const exp = Math.floor(Math.log10(value)); const exp = Math.floor(Math.log10(value));
const engExp = Math.floor(exp / 3) * 3; const engExp = Math.floor(exp / 3) * 3;
const mantissa = value / Math.pow(10, engExp); const mantissa = value / Math.pow(10, engExp);
return `${mantissa.toFixed(2)}E${String(engExp)}`; return `${round
? String(Math.round(mantissa))
: mantissa.toFixed(2)}E${String(engExp)}`;
}; };
/** /**
* Formats a whole-number value for display without decimal places. * Formats a whole-number value for display without decimal places.
* Uses the same suffix/letter logic as formatNumber but rounds to integers. * Uses the same suffix/letter logic as formatNumber but rounds to integers.
* @param value - The integer value to format. * @param value - The integer value to format.
* @param format
* @returns The formatted string with no decimal places. * @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)) { if (!Number.isFinite(value) || Number.isNaN(value)) {
return "0"; return "0";
} }
if (value < 0) { if (value < 0) {
return `-${formatInteger(-value)}`; return `-${formatInteger(-value)}`;
} }
if (value >= Math.pow(10, letterBaseExp)) {
const exp = Math.floor(Math.log10(value)); const roundedValue = Math.floor(value);
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3); switch (format) {
const steps = stepsAboveBase * 3; case "scientific":
const divisorExp = letterBaseExp + steps; return formatScientific(roundedValue, true);
const divisor = Math.pow(10, divisorExp); case "engineering":
return `${String(Math.round(value / divisor))}${getLetterSuffix(stepsAboveBase)}`; return formatEngineering(value, true);
} case "suffix":
for (const { threshold, suffix } of namedSuffixes) { return formatSuffix(value, true);
if (value >= threshold) { default: {
return `${String(Math.floor(value / threshold))}${suffix}`; /* V8 ignore next -- @preserve */
return formatSuffix(value, true);
} }
} }
return String(Math.floor(value));
}; };
/** /**
+39 -1
View File
@@ -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-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */ /* eslint-disable max-nested-callbacks -- Vitest structure requires nesting */
/** /**
@@ -197,4 +196,43 @@ describe("formatInteger", () => {
expect(formatInteger(1e39)).toBe("1b"); expect(formatInteger(1e39)).toBe("1b");
}); });
}); });
describe("scientific format", () => {
it("should fall back to suffix format below 1e6", () => {
expect(formatInteger(500, "scientific")).toBe("500");
});
it("should format values >= 1e6 in scientific notation", () => {
expect(formatInteger(1_230_000, "scientific")).toBe("1e6");
});
it("should format large values in scientific notation", () => {
expect(formatInteger(1e18, "scientific")).toBe("1e18");
});
});
describe("engineering format", () => {
it("should fall back to suffix format below 1e6", () => {
expect(formatInteger(500, "engineering")).toBe("500");
});
it("should format values >= 1e6 with exponent multiple of 3", () => {
expect(formatInteger(1_230_000, "engineering")).toBe("1E6");
});
it("should format 1e9 correctly in engineering notation", () => {
expect(formatInteger(1e9, "engineering")).toBe("1E9");
});
it("should format 12350000 correctly in engineering notation", () => {
expect(formatInteger(12_350_000, "engineering")).toBe("12E6");
});
});
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("1K");
});
});
}); });