diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index ac4d361..df91c30 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -10,9 +10,14 @@ import { authMiddleware } from "../middleware/auth.js"; export const profileRouter = new Hono(); +const VALID_NUMBER_FORMATS = new Set(["suffix", "scientific", "engineering"]); + const parseProfileSettings = (raw: unknown): ProfileSettings => { if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { const obj = raw as Record; + const numberFormat = VALID_NUMBER_FORMATS.has(obj.numberFormat as string) + ? (obj.numberFormat as ProfileSettings["numberFormat"]) + : "suffix"; return { showTotalGold: obj.showTotalGold !== false, showTotalClicks: obj.showTotalClicks !== false, @@ -22,6 +27,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => { showQuestsCompleted: obj.showQuestsCompleted !== false, showAdventurersRecruited: obj.showAdventurersRecruited !== false, showAchievementsUnlocked: obj.showAchievementsUnlocked !== false, + numberFormat, }; } return { ...DEFAULT_PROFILE_SETTINGS }; @@ -73,6 +79,9 @@ profileRouter.put("/", authMiddleware, async (context) => { const characterName = (body.characterName ?? "").trim().slice(0, 32); const bio = (body.bio ?? "").trim().slice(0, 200); + const numberFormat = VALID_NUMBER_FORMATS.has(body.profileSettings?.numberFormat as string) + ? (body.profileSettings?.numberFormat as ProfileSettings["numberFormat"]) + : "suffix"; const profileSettings: ProfileSettings = { showTotalGold: body.profileSettings?.showTotalGold !== false, showTotalClicks: body.profileSettings?.showTotalClicks !== false, @@ -82,6 +91,7 @@ profileRouter.put("/", authMiddleware, async (context) => { showQuestsCompleted: body.profileSettings?.showQuestsCompleted !== false, showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false, showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false, + numberFormat, }; if (!characterName) { diff --git a/apps/web/src/components/game/AchievementPanel.tsx b/apps/web/src/components/game/AchievementPanel.tsx index 4b21fcf..7654ae5 100644 --- a/apps/web/src/components/game/AchievementPanel.tsx +++ b/apps/web/src/components/game/AchievementPanel.tsx @@ -1,10 +1,9 @@ import type { Achievement } from "@elysium/types"; import { useState } from "react"; import { useGame } from "../../context/GameContext.js"; -import { formatNumber } from "../../utils/format.js"; import { LockToggle } from "../ui/LockToggle.js"; -const conditionDescription = (achievement: Achievement): string => { +const conditionDescription = (achievement: Achievement, formatNumber: (n: number) => string): string => { const { condition } = achievement; switch (condition.type) { case "totalGoldEarned": @@ -26,9 +25,10 @@ const conditionDescription = (achievement: Achievement): string => { interface AchievementCardProps { achievement: Achievement; + formatNumber: (n: number) => string; } -const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Element => { +const AchievementCard = ({ achievement, formatNumber }: AchievementCardProps): React.JSX.Element => { const isUnlocked = achievement.unlockedAt !== null; return ( @@ -37,7 +37,7 @@ const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Eleme

{achievement.name}

{achievement.description}

-

{conditionDescription(achievement)}

+

{conditionDescription(achievement, formatNumber)}

{achievement.reward?.crystals != null && (

💎 +{achievement.reward.crystals} Crystals

)} @@ -54,7 +54,7 @@ const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Eleme }; export const AchievementPanel = (): React.JSX.Element => { - const { state } = useGame(); + const { state, formatNumber } = useGame(); const [showLocked, setShowLocked] = useState(true); if (!state) return

Loading...

; @@ -79,7 +79,7 @@ export const AchievementPanel = (): React.JSX.Element => {

{visible.map((achievement) => ( - + ))}
diff --git a/apps/web/src/components/game/BattleModal.tsx b/apps/web/src/components/game/BattleModal.tsx index 1a1cacc..b56243a 100644 --- a/apps/web/src/components/game/BattleModal.tsx +++ b/apps/web/src/components/game/BattleModal.tsx @@ -1,4 +1,5 @@ import type { BattleResult } from "../../context/GameContext.js"; +import { useGame } from "../../context/GameContext.js"; import { useEffect, useState } from "react"; interface BattleModalProps { @@ -6,17 +7,12 @@ interface BattleModalProps { onDismiss: () => void; } -const formatNumber = (n: number): string => { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return Math.floor(n).toLocaleString(); -}; - export const BattleModal = ({ battle, onDismiss, }: BattleModalProps): React.JSX.Element => { const { result, bossName } = battle; + const { formatNumber } = useGame(); const [phase, setPhase] = useState<"animating" | "result">("animating"); diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx index d423318..53b3857 100644 --- a/apps/web/src/components/game/BossPanel.tsx +++ b/apps/web/src/components/game/BossPanel.tsx @@ -1,7 +1,6 @@ import type { Boss } from "@elysium/types"; import { useState } from "react"; import { useGame } from "../../context/GameContext.js"; -import { formatNumber } from "../../utils/format.js"; import { LockToggle } from "../ui/LockToggle.js"; import { ZoneSelector } from "./ZoneSelector.js"; @@ -11,6 +10,7 @@ interface BossCardProps { onChallenge: (bossId: string) => void; isChallenging: boolean; unlockHint?: string | undefined; + formatNumber: (n: number) => string; } const BossCard = ({ @@ -19,6 +19,7 @@ const BossCard = ({ onChallenge, isChallenging, unlockHint, + formatNumber, }: BossCardProps): React.JSX.Element => { const hpPercent = (boss.currentHp / boss.maxHp) * 100; const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; @@ -92,7 +93,7 @@ const BossCard = ({ }; export const BossPanel = (): React.JSX.Element => { - const { state, challengeBoss } = useGame(); + const { state, challengeBoss, formatNumber } = useGame(); const [challengingBossId, setChallengingBossId] = useState(null); const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); const [showLocked, setShowLocked] = useState(true); @@ -212,6 +213,7 @@ export const BossPanel = (): React.JSX.Element => { { - const { state, handleClick } = useGame(); + const { state, handleClick, formatNumber } = useGame(); const [floats, setFloats] = useState([]); const nextIdRef = useRef(0); diff --git a/apps/web/src/components/game/EditProfileModal.tsx b/apps/web/src/components/game/EditProfileModal.tsx index 62318f4..51d0514 100644 --- a/apps/web/src/components/game/EditProfileModal.tsx +++ b/apps/web/src/components/game/EditProfileModal.tsx @@ -1,4 +1,4 @@ -import type { ProfileSettings } from "@elysium/types"; +import type { NumberFormat, ProfileSettings } from "@elysium/types"; import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types"; import { useEffect, useState } from "react"; import { updateProfile } from "../../api/client.js"; @@ -20,12 +20,15 @@ const STAT_TOGGLES: { key: keyof ProfileSettings; label: string; icon: string }[ ]; export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.Element => { - const { state } = useGame(); + const { state, numberFormat: currentNumberFormat, setNumberFormat } = useGame(); const player = state?.player; const [characterName, setCharacterName] = useState(player?.characterName ?? ""); const [bio, setBio] = useState(""); - const [settings, setSettings] = useState({ ...DEFAULT_PROFILE_SETTINGS }); + const [settings, setSettings] = useState({ + ...DEFAULT_PROFILE_SETTINGS, + numberFormat: currentNumberFormat, + }); const [loadingProfile, setLoadingProfile] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -59,6 +62,7 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX. setError(null); try { await updateProfile({ characterName, bio, profileSettings: settings }); + setNumberFormat(settings.numberFormat); setSaved(true); setTimeout(onClose, 900); } catch (err: unknown) { @@ -139,6 +143,30 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
+
+

Number Format

+

How large numbers appear across the game.

+
+ {( + [ + { value: "suffix", label: "Suffix", example: "1.23Qa" }, + { value: "scientific", label: "Scientific", example: "1.23e15" }, + { value: "engineering", label: "Engineering", example: "1.23E15" }, + ] as { value: NumberFormat; label: string; example: string }[] + ).map(({ value, label, example }) => ( + + ))} +
+
+ {error &&

{error}

}
diff --git a/apps/web/src/components/game/ProfilePage.tsx b/apps/web/src/components/game/ProfilePage.tsx index 805a061..88555e4 100644 --- a/apps/web/src/components/game/ProfilePage.tsx +++ b/apps/web/src/components/game/ProfilePage.tsx @@ -1,12 +1,13 @@ import type { PublicProfileResponse } from "@elysium/types"; import { useEffect, useState } from "react"; -import { formatNumber } from "../../utils/format.js"; +import { useGame } from "../../context/GameContext.js"; interface ProfilePageProps { discordId: string; } export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => { + const { formatNumber } = useGame(); const [profile, setProfile] = useState(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); diff --git a/apps/web/src/components/game/QuestPanel.tsx b/apps/web/src/components/game/QuestPanel.tsx index 247e602..0ac14e3 100644 --- a/apps/web/src/components/game/QuestPanel.tsx +++ b/apps/web/src/components/game/QuestPanel.tsx @@ -1,7 +1,6 @@ import type { Quest } from "@elysium/types"; import { useState } from "react"; import { useGame } from "../../context/GameContext.js"; -import { formatNumber } from "../../utils/format.js"; import { LockToggle } from "../ui/LockToggle.js"; import { ZoneSelector } from "./ZoneSelector.js"; @@ -24,7 +23,7 @@ interface QuestCardProps { } const QuestCard = ({ quest, unlockHint, zoneHint }: QuestCardProps): React.JSX.Element => { - const { startQuest } = useGame(); + const { startQuest, formatNumber } = useGame(); return (
diff --git a/apps/web/src/components/ui/ResourceBar.tsx b/apps/web/src/components/ui/ResourceBar.tsx index 1885c76..bb0f329 100644 --- a/apps/web/src/components/ui/ResourceBar.tsx +++ b/apps/web/src/components/ui/ResourceBar.tsx @@ -1,5 +1,6 @@ import type { Resource } from "@elysium/types"; -import { formatNumber } from "../../utils/format.js"; +import { useGame } from "../../context/GameContext.js"; +import { RESOURCE_CAP } from "../../engine/tick.js"; interface ResourceBarProps { resources: Resource; @@ -21,6 +22,8 @@ const formatRelativeTime = (timestamp: number): string => { return `${hours}h ago`; }; +const RESOURCE_FULL_TOOLTIP = "This resource is full! Consider spending some or prestiging to keep earning."; + export const ResourceBar = ({ resources, prestigeCount, @@ -29,27 +32,35 @@ export const ResourceBar = ({ lastSavedAt, isSyncing, onForceSync, -}: ResourceBarProps): React.JSX.Element => ( +}: ResourceBarProps): React.JSX.Element => { + const { formatNumber } = useGame(); + const anyFull = Object.values(resources).some((v) => v >= RESOURCE_CAP); + return ( + <>
-
+
= RESOURCE_CAP ? " resource-full" : ""}`}> 🪙 {formatNumber(resources.gold)} Gold + {resources.gold >= RESOURCE_CAP && FULL}
-
+
= RESOURCE_CAP ? " resource-full" : ""}`}> {formatNumber(resources.essence)} Essence + {resources.essence >= RESOURCE_CAP && FULL}
-
+
= RESOURCE_CAP ? " resource-full" : ""}`}> 💎 {formatNumber(resources.crystals)} Crystals + {resources.crystals >= RESOURCE_CAP && FULL}
-
+
= RESOURCE_CAP ? " resource-full" : ""}`}> 🔮 {formatNumber(resources.runestones)} Runestones + {resources.runestones >= RESOURCE_CAP && FULL}
{prestigeCount > 0 && (
@@ -90,4 +101,11 @@ export const ResourceBar = ({
-); + {anyFull && ( +
+ ⚠️ One or more resources are full! Consider spending some or prestiging to keep earning. +
+ )} + + ); +}; diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index 24da686..16c2a32 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -1,4 +1,4 @@ -import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types"; +import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types"; import { createContext, useCallback, @@ -8,7 +8,8 @@ import { useState, } from "react"; import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js"; -import { applyTick, calculateClickPower } from "../engine/tick.js"; +import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js"; +import { formatNumber as formatNumberUtil } from "../utils/format.js"; export interface BattleResult { @@ -54,6 +55,12 @@ interface GameContextValue { newAchievements: Achievement[]; /** Remove an achievement from the toast queue */ dismissAchievement: (id: string) => void; + /** The player's chosen number display format */ + numberFormat: NumberFormat; + /** Update the number format preference (persisted to server via profile save) */ + setNumberFormat: (format: NumberFormat) => void; + /** Format a number using the player's chosen notation style */ + formatNumber: (value: number) => string; } const GameContext = createContext(null); @@ -69,6 +76,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const [newAchievements, setNewAchievements] = useState([]); const [lastSavedAt, setLastSavedAt] = useState(null); const [isSyncing, setIsSyncing] = useState(false); + const [numberFormat, setNumberFormat] = useState("suffix"); const stateRef = useRef(null); const lastSaveRef = useRef(Date.now()); const isSyncingRef = useRef(false); @@ -87,6 +95,17 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React if (data.offlineGold > 0) { setOfflineGold(data.offlineGold); } + // Fetch number format preference from profile (fire-and-forget, non-blocking) + void fetch(`/api/profile/${data.state.player.discordId}`) + .then(async (res) => { + if (!res.ok) return; + const profile = await res.json() as { profileSettings?: { numberFormat?: NumberFormat } }; + const fmt = profile.profileSettings?.numberFormat; + if (fmt === "suffix" || fmt === "scientific" || fmt === "engineering") { + setNumberFormat(fmt); + } + }) + .catch(() => { /* fall back to default "suffix" */ }); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load game"); } finally { @@ -166,9 +185,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React setState((prev) => { if (!prev) return prev; const clickPower = calculateClickPower(prev); + const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP); return { ...prev, - resources: { ...prev.resources, gold: prev.resources.gold + clickPower }, + resources: { ...prev.resources, gold: newGold }, player: { ...prev.player, totalGoldEarned: prev.player.totalGoldEarned + clickPower, @@ -408,6 +428,11 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React setNewAchievements((prev) => prev.filter((a) => a.id !== id)); }, []); + const boundFormatNumber = useCallback( + (value: number) => formatNumberUtil(value, numberFormat), + [numberFormat], + ); + return ( {children} diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 0a6ecbb..e339c44 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -40,6 +40,11 @@ const checkAchievements = (state: GameState): Achievement[] => { }); }; +/** Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision. */ +export const RESOURCE_CAP = 1e300; + +const capResource = (value: number): number => Math.min(value, RESOURCE_CAP); + /** * Pure function — applies one game tick to the state. * deltaSeconds: time elapsed since last tick. @@ -187,8 +192,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => }); } - const newGold = state.resources.gold + goldGained + questGold; - const newEssence = state.resources.essence + essenceGained + questEssence; + const newGold = capResource(state.resources.gold + goldGained + questGold); + const newEssence = capResource(state.resources.essence + essenceGained + questEssence); const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; const partialState: GameState = { @@ -197,7 +202,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => ...state.resources, gold: newGold, essence: newEssence, - crystals: state.resources.crystals + questCrystals, + crystals: capResource(state.resources.crystals + questCrystals), }, player: { ...state.player, @@ -228,7 +233,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => achievements: updatedAchievements, resources: { ...partialState.resources, - crystals: partialState.resources.crystals + crystalsFromAchievements, + crystals: capResource(partialState.resources.crystals + crystalsFromAchievements), }, }; }; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 37c74aa..2fd9ca2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -71,6 +71,30 @@ body { font-weight: 600; } +.resource-full .resource-value { + color: var(--colour-warning, #f59e0b); +} + +.resource-cap-badge { + background: var(--colour-warning, #f59e0b); + border-radius: 0.25rem; + color: #000; + cursor: help; + font-size: 0.6rem; + font-weight: 800; + letter-spacing: 0.05em; + padding: 0.1rem 0.3rem; +} + +.resource-cap-notice { + background: rgba(245, 158, 11, 0.12); + border-bottom: 1px solid rgba(245, 158, 11, 0.35); + color: #f59e0b; + font-size: 0.82rem; + padding: 0.4rem 1.5rem; + text-align: center; +} + /* ===================== GAME LAYOUT ===================== */ .game-layout { display: flex; @@ -1366,7 +1390,11 @@ body { /* ── Edit Profile Modal ─────────────────────────────────────────────────── */ .edit-profile-modal { + display: flex; + flex-direction: column; + max-height: calc(100dvh - 4rem); max-width: 480px; + padding: 0; text-align: left; width: 100%; } @@ -1374,14 +1402,19 @@ body { .modal-header { align-items: center; display: flex; + flex-shrink: 0; justify-content: space-between; - margin-bottom: 1rem; + padding: 1.5rem 2rem 1rem; } .modal-header h2 { margin: 0; } +.edit-profile-modal .edit-profile-loading { + padding: 0 2rem 1.5rem; +} + .modal-close { background: transparent; border: none; @@ -1401,6 +1434,8 @@ body { display: flex; flex-direction: column; gap: 0.5rem; + overflow-y: auto; + padding: 0 2rem 1.5rem; } .edit-profile-label { @@ -1425,10 +1460,14 @@ body { font-family: inherit; font-size: 0.9rem; padding: 0.5rem 0.75rem; - resize: vertical; width: 100%; } +.edit-profile-textarea { + min-height: 5rem; + resize: vertical; +} + .edit-profile-input:focus, .edit-profile-textarea:focus { border-color: var(--colour-primary); @@ -1484,6 +1523,44 @@ body { color: var(--colour-success); } +.number-format-picker { + display: flex; + gap: 0.5rem; +} + +.number-format-btn { + align-items: center; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(147, 51, 234, 0.25); + border-radius: 0.4rem; + color: var(--colour-text-muted); + cursor: pointer; + display: flex; + flex: 1; + flex-direction: column; + font-family: inherit; + gap: 0.2rem; + padding: 0.5rem 0.75rem; + transition: all 0.15s; +} + +.number-format-active { + background: rgba(147, 51, 234, 0.12); + border-color: rgba(147, 51, 234, 0.5); + color: var(--colour-text); +} + +.number-format-label { + font-size: 0.85rem; + font-weight: 600; +} + +.number-format-example { + color: var(--colour-accent); + font-family: monospace; + font-size: 0.8rem; +} + .edit-profile-error { color: var(--colour-error); font-size: 0.82rem; diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts index dfcbf36..3b9b445 100644 --- a/apps/web/src/utils/format.ts +++ b/apps/web/src/utils/format.ts @@ -1,32 +1,90 @@ +import type { NumberFormat } from "@elysium/types"; + +// Named suffixes up to 1e33 (Decillion). Letter-based suffixes take over from 1e36 onwards. +const NAMED_SUFFIXES: { threshold: number; suffix: string }[] = [ + { threshold: 1e33, suffix: "Dc" }, // Decillion + { threshold: 1e30, suffix: "No" }, // Nonillion + { threshold: 1e27, suffix: "Oc" }, // Octillion + { threshold: 1e24, suffix: "Sp" }, // Septillion + { threshold: 1e21, suffix: "Sx" }, // Sextillion + { threshold: 1e18, suffix: "Qi" }, // Quintillion + { threshold: 1e15, suffix: "Qa" }, // Quadrillion + { threshold: 1e12, suffix: "T" }, // Trillion + { threshold: 1e9, suffix: "B" }, // Billion + { threshold: 1e6, suffix: "M" }, // Million + { threshold: 1e3, suffix: "K" }, // Thousand +]; + +// Letter suffixes start at 1e36 ("a"), stepping by 1000 each time (i.e. +3 exponent per letter). +const LETTER_BASE_EXP = 36; + /** - * Formats a number with K/M/B/T/Q/Qt/S/Sp suffixes for display. - * Numbers below 1000 show one decimal place. + * Generates an alphabetic suffix for a given index: + * 0 → "a", 1 → "b", ..., 25 → "z", + * 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ... */ -export const formatNumber = (value: number): string => { - if (!isFinite(value) || isNaN(value)) return "0"; - if (value >= 1e24) { - return `${(value / 1e24).toFixed(2)}Sp`; +const getLetterSuffix = (index: number): string => { + let result = ""; + let n = index; + do { + result = String.fromCharCode(97 + (n % 26)) + result; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return result; +}; + +const formatSuffix = (value: number): string => { + if (value >= Math.pow(10, LETTER_BASE_EXP)) { + const exp = Math.floor(Math.log10(value)); + const stepsAboveBase = Math.floor((exp - LETTER_BASE_EXP) / 3); + const divisorExp = LETTER_BASE_EXP + stepsAboveBase * 3; + const divisor = Math.pow(10, divisorExp); + return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`; } - if (value >= 1e21) { - return `${(value / 1e21).toFixed(2)}S`; - } - if (value >= 1e18) { - return `${(value / 1e18).toFixed(2)}Qt`; - } - if (value >= 1e15) { - return `${(value / 1e15).toFixed(2)}Q`; - } - if (value >= 1_000_000_000_000) { - return `${(value / 1_000_000_000_000).toFixed(2)}T`; - } - if (value >= 1_000_000_000) { - return `${(value / 1_000_000_000).toFixed(2)}B`; - } - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(2)}M`; - } - if (value >= 1_000) { - return `${(value / 1_000).toFixed(2)}K`; + for (const { threshold, suffix } of NAMED_SUFFIXES) { + if (value >= threshold) { + return `${(value / threshold).toFixed(2)}${suffix}`; + } } return value.toFixed(1); }; + +/** + * Formats a number in scientific notation: e.g. 1.23e15. + * Falls back to K/M/B/T style below 1 million. + */ +const formatScientific = (value: number): string => { + if (value < 1e6) return formatSuffix(value); + // toExponential handles all magnitudes JS can represent (up to ~1.8e308) + return value.toExponential(2).replace("e+", "e"); +}; + +/** + * 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. + */ +const formatEngineering = (value: number): string => { + if (value < 1e6) return formatSuffix(value); + const exp = Math.floor(Math.log10(value)); + const engExp = Math.floor(exp / 3) * 3; + const mantissa = value / Math.pow(10, engExp); + return `${mantissa.toFixed(2)}E${engExp}`; +}; + +/** + * Formats a number for display using the player's chosen notation style. + * Negative values are formatted with a leading minus sign. + */ +export const formatNumber = (value: number, format: NumberFormat = "suffix"): string => { + if (!isFinite(value) || isNaN(value)) return "0"; + if (value < 0) return `-${formatNumber(-value, format)}`; + + switch (format) { + case "scientific": + return formatScientific(value); + case "engineering": + return formatEngineering(value); + default: + return formatSuffix(value); + } +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a482242..a57ce85 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -41,5 +41,5 @@ export type { UpgradeTarget, } from "./interfaces/Upgrade.js"; export type { Zone, ZoneStatus } from "./interfaces/Zone.js"; -export type { ProfileSettings } from "./interfaces/ProfileSettings.js"; +export type { NumberFormat, ProfileSettings } from "./interfaces/ProfileSettings.js"; export { DEFAULT_PROFILE_SETTINGS } from "./interfaces/ProfileSettings.js"; diff --git a/packages/types/src/interfaces/ProfileSettings.ts b/packages/types/src/interfaces/ProfileSettings.ts index 40def3c..79b91b1 100644 --- a/packages/types/src/interfaces/ProfileSettings.ts +++ b/packages/types/src/interfaces/ProfileSettings.ts @@ -1,3 +1,5 @@ +export type NumberFormat = "suffix" | "scientific" | "engineering"; + export interface ProfileSettings { showTotalGold: boolean; showTotalClicks: boolean; @@ -7,6 +9,7 @@ export interface ProfileSettings { showQuestsCompleted: boolean; showAdventurersRecruited: boolean; showAchievementsUnlocked: boolean; + numberFormat: NumberFormat; } export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { @@ -18,4 +21,5 @@ export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { showQuestsCompleted: true, showAdventurersRecruited: true, showAchievementsUnlocked: true, + numberFormat: "suffix", };