diff --git a/src/test.ts b/src/test.ts index d02b798..cbd4f09 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,9 +1,7 @@ -import { NS, Server } from "@ns"; -import { ezgame } from "./ezgame"; -import { scan } from "./utils/scan"; -import { getNormalServer } from "./utils/get-normal-server"; +import { NS } from "@ns"; +import { format } from "./utils/format-number"; export const main = (ns: NS) => { // console.log(ns.formulas.hacking.growTime(getNormalServer(ns, "n00dles"), ns.getPlayer())); - console.log(ns.getServer("n00dles")); + console.log(format.number(ns.cloud.getServerCost(2048))); }; diff --git a/src/utils/format-number.ts b/src/utils/format-number.ts new file mode 100644 index 0000000..2c20ace --- /dev/null +++ b/src/utils/format-number.ts @@ -0,0 +1,307 @@ +const numberSuffixList = ["", "k", "m", "b", "t", "q", "Q", "s", "S", "o", "n"]; +// exponents associated with each suffix +const numberExpList = numberSuffixList.map((_, i) => parseFloat(`1e${i * 3}`)); + +// Ram suffixes +const decByteSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; +const binByteSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + +// Default configuration +interface FormatConfig { + useEngineeringNotation?: boolean; + hideThousandsSeparator?: boolean; + useIEC60027_2?: boolean; + hideTrailingDecimalZeros?: boolean; + locale?: string; + disableSuffixes?: boolean; + fractionalDigits?: number; + currencySymbol?: string; + currencySymbolAfterValue?: boolean; +} + +const defaultConfig: Required = { + useEngineeringNotation: false, + hideThousandsSeparator: false, + useIEC60027_2: false, + hideTrailingDecimalZeros: false, + locale: "en", + disableSuffixes: false, + fractionalDigits: 3, + currencySymbol: "$", + currencySymbolAfterValue: false, +}; + +let config = { ...defaultConfig }; + +// Items that get initialized in the initializer function. +let digitFormats = {} as Record, + percentFormats = {} as Record, + basicFormatter: Intl.NumberFormat, + exponentialFormatter: Intl.NumberFormat, + unitSuffixes: string[], + unitExpList: number[], + unitLogFn: (n: number) => number, + unitLogDivisor: number; + +// Initialize formatters with default or provided config +function initializeFormatters(newConfig?: Partial): void { + if (newConfig) { + config = { ...config, ...newConfig }; + } + + // Clear all cached formatters + digitFormats = {}; + percentFormats = {}; + exponentialFormatter = makeFormatter(3, { notation: config.useEngineeringNotation ? "engineering" : "scientific" }); + basicFormatter = new Intl.NumberFormat([config.locale, "en"], { useGrouping: !config.hideThousandsSeparator }); + [unitSuffixes, unitLogFn, unitLogDivisor] = config.useIEC60027_2 + ? // log2 of 1024 is 10 as divisor for log base 1024 + [binByteSuffixes, Math.log2, 10] + : // log10 of 1000 is 3 as divisor for log base 1000 + [decByteSuffixes, Math.log10, 3]; + unitExpList = unitSuffixes.map((_, i) => (config.useIEC60027_2 ? 1024 : 1000) ** i); +} + +// Initialize on load +initializeFormatters(); + +/** Update formatting configuration and reinitialize formatters */ +export function updateFormatConfig(newConfig: Partial): void { + initializeFormatters(newConfig); +} + +/** Makes a new formatter */ +function makeFormatter(fractionalDigits: number, otherOptions: Intl.NumberFormatOptions = {}): Intl.NumberFormat { + if (config.hideThousandsSeparator) otherOptions.useGrouping = false; + return new Intl.NumberFormat([config.locale, "en"], { + minimumFractionDigits: config.hideTrailingDecimalZeros ? 0 : fractionalDigits, + maximumFractionDigits: fractionalDigits, + ...otherOptions, + }); +} +/** Returns a cached formatter if it already exists, otherwise makes and returns a new formatter */ +function getFormatter( + fractionalDigits: number, + formatList = digitFormats, + options: Intl.NumberFormatOptions = {}, +): Intl.NumberFormat { + if (formatList[fractionalDigits]) { + return formatList[fractionalDigits]; + } + return (formatList[fractionalDigits] = makeFormatter(fractionalDigits, options)); +} + +/** Display standard byte formatting. */ +function formatBytes(n: number, fractionalDigits = 1): string { + return formatSize(n, fractionalDigits, 0); +} + +/** Display standard ram formatting. */ +function formatRam(n: number, fractionalDigits = 2): string { + return formatSize(n, fractionalDigits, 3); +} + +function formatSize(n: number, fractionalDigits = 2, unitOffset = 3) { + const base = config.useIEC60027_2 ? 1024 : 1000; + const nAbs = Math.abs(n); + + // Special handling for NaN, Infinities and zero + if (Number.isNaN(n)) return `NaN${unitSuffixes[0 + unitOffset]}`; + if (nAbs === Infinity) return `${n < 0 ? "-∞" : "∞"}${unitSuffixes.at(-1)}`; + + // Early return if using first suffix. + if (nAbs < base) return getFormatter(fractionalDigits).format(n) + unitSuffixes[unitOffset]; + + // convert input units to bytes + let nBytes = n * base ** unitOffset; + + const suffixIndex = Math.min(Math.floor(unitLogFn(nBytes) / unitLogDivisor), unitSuffixes.length - 1); + nBytes /= unitExpList[suffixIndex]; + /* Not really concerned with 1000-rounding or 1024-rounding for ram due to the actual values ram gets displayed at. + If display of e.g. 1,000.00GB instead of 1.00TB for 999.995GB, or 1,024.00GiB instead of 1.00TiB for 1,023.995GiB + becomes an actual issue we can add smart rounding, but ram values like that really don't happen ingame so it's + probably not worth the performance overhead to check and correct these. */ + return getFormatter(fractionalDigits).format(nBytes) + unitSuffixes[suffixIndex]; +} + +function formatExponential(n: number) { + return exponentialFormatter.format(n).toLocaleLowerCase(); +} + +// Default suffixing starts at 1e9 % which is 1e7. +function formatPercent(n: number, fractionalDigits = 2, multStart = 1e6) { + // NaN does not get formatted + if (Number.isNaN(n)) return "NaN%"; + const nAbs = Math.abs(n); + + // Special handling for Infinities + if (nAbs * 100 === Infinity) return n < 0 ? "-∞%" : "∞%"; + + // Mult form. There are probably some areas in the game this wouldn't make sense, but they hopefully won't ever have huge %. + if (nAbs >= multStart) return "x" + formatNumber(n, fractionalDigits); + + return getFormatter(fractionalDigits, percentFormats, { style: "percent" }).format(n); +} + +function formatNumber(n: number, fractionalDigits = config.fractionalDigits, suffixStart = 1000, isInteger = false) { + // NaN does not get formatted + if (Number.isNaN(n)) return "NaN"; + const nAbs = Math.abs(n); + + // Special handling for Infinities + if (nAbs === Infinity) return n < 0 ? "-∞" : "∞"; + if (suffixStart < 1000) { + throw new Error("suffixStart must be greater than or equal to 1000"); + } + + // Early return for non-suffix or if number and suffix are 0 + if (nAbs < suffixStart) { + if (isInteger) return basicFormatter.format(n); + return getFormatter(fractionalDigits).format(n); + } + + // Exponential form + if (config.disableSuffixes || nAbs >= 1e33) return formatExponential(n); + + // Calculate suffix index. 1000 = 10^3 + let suffixIndex = Math.floor(Math.log10(nAbs) / 3); + + n /= numberExpList[suffixIndex]; + // Todo: Find a better way to detect if number is rounding to 1000${suffix}, or find a simple way to truncate to x digits instead of rounding + // Detect if number rounds to 1000.000 (based on number of digits given) + if (Math.abs(n).toFixed(fractionalDigits).length === fractionalDigits + 5 && numberSuffixList[suffixIndex + 1]) { + suffixIndex += 1; + n = n < 0 ? -1 : 1; + } + return getFormatter(fractionalDigits).format(n) + numberSuffixList[suffixIndex]; +} + +/** Format a number without suffixes. Still show exponential form if >= 1e33. */ +const formatNumberNoSuffix = (n: number, fractionalDigits = 0) => { + return formatNumber(n, fractionalDigits, 1e33); +}; +const formatFavor = (n: number) => formatNumberNoSuffix(n, 3); + +/** Standard noninteger formatting with no options set. Collapses to suffix at 1000 and shows 3 fractional digits. */ +const formatBigNumber = (n: number) => formatNumber(n); +const formatExp = formatBigNumber; +const formatHashes = (n: number) => { + if (n < 0.00001) { + return formatNumber(n, 8); + } + if (n < 0.001) { + return formatNumber(n, 6); + } + if (n < 0.01) { + return formatNumber(n, 4); + } + return formatNumber(n); +}; +const formatReputation = formatBigNumber; +const formatPopulation = formatBigNumber; +const formatSecurity = formatBigNumber; +const formatStamina = formatBigNumber; +const formatStaneksGiftCharge = formatBigNumber; +const formatCorpMultiplier = (n: number) => "×" + formatBigNumber(n); + +/** Format a number with suffixes starting at 1000 and 2 fractional digits */ +const formatQuality = (n: number) => formatNumber(n, 2); + +/** Format an integer that uses suffixed form at 1000 and 3 fractional digits. */ +const formatInt = (n: number) => formatNumber(n, 3, 1000, true); +const formatSleeveMemory = formatInt; +const formatShares = formatInt; + +/** + * Format a number using basicFormatter for values below 1e6, and a suffixed form with up to 3 fractional digits for + * values at or above 1e6. This uses formatNumber, so check that function for nuanced details. + * + * Values in the range (0, 0.001) are displayed in exponential notation. + */ +const formatHp = (n: number) => { + if (n > 0 && n < 0.001) { + return formatExponential(n); + } + return formatNumber(n, 3, 1e6, true); +}; +const formatThreads = formatHp; + +/** Display an integer up to 999,999,999 before collapsing to suffixed form with 3 fractional digits */ +const formatSkill = (n: number) => formatNumber(n, 3, 1e9, true); + +/** Display standard money formatting, including the currency symbol. */ +const formatMoney = (n: number, useExponentialFormForSmallValue = false): string => { + const value = !useExponentialFormForSmallValue || n === 0 || n >= 0.001 ? formatNumber(n) : n.toExponential(3); + return config.currencySymbolAfterValue ? `${value}${config.currencySymbol}` : `${config.currencySymbol}${value}`; +}; + +/** Display a decimal number with increased precision (5 fractional digits) */ +const formatRespect = (n: number) => formatNumber(n, 5); +const formatWanted = formatRespect; +const formatPreciseMultiplier = formatRespect; + +/** Format a number with 3 fractional digits. */ +const formatMaterialSize = (n: number) => formatNumber(n, 3); + +/** Format a number with no suffix and 2 fractional digits. */ +const formatMultiplier = (n: number) => formatNumberNoSuffix(n, 2); +const formatStaneksGiftPower = formatMultiplier; +const formatMatPurchaseAmount = formatMultiplier; + +/** Format a number with no suffix and 3 fractional digits. */ +const formatSleeveShock = (n: number) => formatNumberNoSuffix(n, 3); +const formatSleeveSynchro = formatSleeveShock; +const formatCorpStat = formatSleeveShock; + +/** Parsing numbers does not use the locale as this causes complications. */ +function parseBigNumber(str: string): number { + str = str.trim(); + // Remove all commas in case the player is typing a longform number + str = str.replace(/,/g, ""); + // Handle special returns + if (["infinity", "Infinity", "∞"].includes(str)) return Infinity; + if (["-infinity", "-Infinity", "-∞"].includes(str)) return -Infinity; + + const suffixIndex = numberSuffixList.indexOf(str.substring(str.length - 1)); + // If there's no valid suffix at the end, just return parseFloated string + if (suffixIndex === -1) return parseFloat(str); + return parseFloat(str.substring(0, str.length - 1) + "e" + suffixIndex * 3); +} + +/** Namespace for all number formatting functions */ +export const format = { + bytes: formatBytes, + ram: formatRam, + percent: formatPercent, + number: formatNumber, + numberNoSuffix: formatNumberNoSuffix, + favor: formatFavor, + bigNumber: formatBigNumber, + exp: formatExp, + hashes: formatHashes, + reputation: formatReputation, + population: formatPopulation, + security: formatSecurity, + stamina: formatStamina, + staneksGiftCharge: formatStaneksGiftCharge, + corpMultiplier: formatCorpMultiplier, + quality: formatQuality, + int: formatInt, + sleeveMemory: formatSleeveMemory, + shares: formatShares, + hp: formatHp, + threads: formatThreads, + skill: formatSkill, + money: formatMoney, + respect: formatRespect, + wanted: formatWanted, + preciseMultiplier: formatPreciseMultiplier, + materialSize: formatMaterialSize, + multiplier: formatMultiplier, + staneksGiftPower: formatStaneksGiftPower, + matPurchaseAmount: formatMatPurchaseAmount, + sleeveShock: formatSleeveShock, + sleeveSynchro: formatSleeveSynchro, + corpStat: formatCorpStat, + parseBigNumber: parseBigNumber, +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ebe7c2d..493ec87 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,10 +1,10 @@ import { Server } from "@ns"; export const utils = { - utilityFormatDate, - utilityFormatSize, - utilityUnformatSize, - utilityExtractFilename, + utilityFormatDate: formatDate, + utilityFormatSize: formatSize, + utilityUnformatSize: unformatSize, + utilityExtractFilename: extractFilename, clampNumber, clampInteger, isValidNumber, @@ -19,7 +19,7 @@ export const utils = { * @param dateString The date string to format * @returns Formatted date/time string */ -function utilityFormatDate(dateString: string): string { +function formatDate(dateString: string): string { const date = new Date(dateString); const today = new Date(); @@ -44,7 +44,7 @@ function utilityFormatDate(dateString: string): string { * @param bytes Number of bytes to format * @returns Formatted size string */ -function utilityFormatSize(bytes: number): string { +function formatSize(bytes: number): string { if (bytes === 0) return "0 Bytes"; const k = 1024; @@ -60,7 +60,7 @@ function utilityFormatSize(bytes: number): string { * @param formattedSize Formatted size string (must be in the same format produced by `utilityFormatSize()`) * @returns Number of bytes */ -function utilityUnformatSize(formattedSize: string): number { +function unformatSize(formattedSize: string): number { const sizes = ["Bytes", "KB", "MB", "GB"]; const [value, unit] = formattedSize.split(" "); const index = sizes.indexOf(unit); @@ -73,7 +73,7 @@ function utilityUnformatSize(formattedSize: string): number { * @returns Object containing `fileStem` and `fileExtension`. `fileStem` is the filename without the extension, and `fileExtension` is the extension in lowercase (or an empty string if there is no extension). * @throws Error if the file path is invalid */ -function utilityExtractFilename(filePath: string): { fileStem: string; fileExtension: string } | null { +function extractFilename(filePath: string): { fileStem: string; fileExtension: string } | null { const lastSlashIndex = filePath.lastIndexOf("/"); const lastDotIndex = filePath.lastIndexOf(".");