WIP: feat: respect integer format pref #261

Draft
nerdychara wants to merge 2 commits from feat/respect-format-preference into main
9 changed files with 114 additions and 51 deletions
+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");
});
});
}); });