import { bytesToHex } from "@add-ideas/toolbox-helpers"; import type { RandomSource } from "./source"; const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; export const DEFAULT_WORDS = [ "amber", "anchor", "apple", "april", "arch", "arrow", "atlas", "autumn", "bamboo", "beacon", "berry", "birch", "blue", "breeze", "brook", "cabin", "candle", "cedar", "cherry", "cloud", "clover", "cobalt", "comet", "coral", "crane", "crystal", "dawn", "delta", "dune", "eagle", "earth", "ember", "fern", "field", "finch", "flame", "forest", "frost", "garden", "glade", "gold", "granite", "harbor", "hazel", "hill", "honey", "indigo", "iris", "island", "ivory", "jade", "jasmine", "juniper", "lake", "lantern", "leaf", "lemon", "lilac", "lotus", "luna", "maple", "marble", "meadow", "mint", "mist", "moon", "moss", "oasis", "ocean", "olive", "opal", "orchid", "peach", "pearl", "pine", "plum", "pond", "poppy", "quartz", "rain", "raven", "reef", "river", "rose", "ruby", "sage", "sand", "sea", "shadow", "shell", "silver", "sky", "snow", "solar", "sparrow", "spring", "star", "stone", "storm", "sun", "teal", "thistle", "tide", "timber", "topaz", "trail", "tree", "tulip", "valley", "violet", "wave", "willow", "wind", "winter", "wood", "wren", "zephyr", ] as const; export function randomIntegers( source: RandomSource, count: number, minimum: number, maximumInclusive: number, ): number[] { if (!Number.isSafeInteger(count) || count < 1 || count > 100_000) throw new Error("Count must be 1–100,000."); if ( !Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximumInclusive) || maximumInclusive < minimum || maximumInclusive - minimum >= 2 ** 32 ) throw new Error( "Integer bounds must be safe, ordered, and span fewer than 2³² values.", ); return Array.from({ length: count }, () => source.integer(minimum, maximumInclusive + 1), ); } export function randomString( source: RandomSource, length: number, alphabet: string, ): string { if (alphabet.length > 131_072) throw new Error("Alphabet exceeds the 131,072 UTF-16-unit input limit."); const symbols = [...alphabet]; if (!Number.isSafeInteger(length) || length < 1 || length > 1_000_000) throw new Error("Length must be 1–1,000,000 code points."); if (symbols.length < 2 || symbols.length > 65_536) throw new Error("Alphabet must contain 2–65,536 code points."); if (new Set(symbols).size !== symbols.length) throw new Error( "Alphabet contains duplicate code points, which would bias output.", ); return Array.from( { length }, () => symbols[source.integer(0, symbols.length)]!, ).join(""); } export function uuidV4(source: RandomSource): string { const bytes = source.bytes(16); bytes[6] = (bytes[6]! & 0x0f) | 0x40; bytes[8] = (bytes[8]! & 0x3f) | 0x80; return formatUuid(bytes); } export function uuidV7(source: RandomSource, now = Date.now()): string { if (!Number.isSafeInteger(now) || now < 0 || now >= 2 ** 48) throw new Error("UUIDv7 timestamp is outside the 48-bit range."); const bytes = source.bytes(16); let timestamp = BigInt(now); for (let index = 5; index >= 0; index -= 1) { bytes[index] = Number(timestamp & 0xffn); timestamp >>= 8n; } bytes[6] = (bytes[6]! & 0x0f) | 0x70; bytes[8] = (bytes[8]! & 0x3f) | 0x80; return formatUuid(bytes); } function formatUuid(bytes: Uint8Array): string { const value = bytesToHex(bytes); return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`; } export function ulid(source: RandomSource, now = Date.now()): string { if (!Number.isSafeInteger(now) || now < 0 || now >= 2 ** 48) throw new Error("ULID timestamp is outside the 48-bit range."); let time = BigInt(now); let left = ""; for (let index = 0; index < 10; index += 1) { left = CROCKFORD[Number(time & 31n)]! + left; time >>= 5n; } let random = 0n; for (const byte of source.bytes(10)) random = (random << 8n) | BigInt(byte); let right = ""; for (let index = 0; index < 16; index += 1) { right = CROCKFORD[Number(random & 31n)]! + right; random >>= 5n; } return left + right; } export interface DiceResult { expression: string; rolls: number[]; modifier: number; total: number; } export function rollDice(source: RandomSource, expression: string): DiceResult { const match = /^\s*(\d{1,6})d(\d{1,10})(?:\s*([+-])\s*(\d{1,12}))?\s*$/iu.exec( expression, ); if (!match) throw new Error("Use dice notation such as 4d6+2."); const count = Number(match[1]); const sides = Number(match[2]); const modifier = match[3] ? Number(match[4]) * (match[3] === "-" ? -1 : 1) : 0; if ( count < 1 || count > 100_000 || sides < 2 || sides > 1_000_000_000 || !Number.isSafeInteger(modifier) ) throw new Error( "Dice limits: 1–100,000 dice, 2–1,000,000,000 sides, safe-integer modifier.", ); const rolls = Array.from({ length: count }, () => source.integer(1, sides + 1), ); return { expression: `${count}d${sides}${modifier ? (modifier > 0 ? `+${modifier}` : String(modifier)) : ""}`, rolls, modifier, total: rolls.reduce((sum, value) => sum + value, modifier), }; } export function passphrase( source: RandomSource, count: number, words: readonly string[] = DEFAULT_WORDS, separator = "-", ): { value: string; entropy: number } { if (words.length > 100_000 || words.some((word) => word.length > 10_000)) throw new Error( "Word lists are limited to 100,000 entries and 10,000 UTF-16 units per entry.", ); const clean = words.map((word) => word.trim()).filter(Boolean); if (!Number.isSafeInteger(count) || count < 1 || count > 100) throw new Error("Passphrase word count must be 1–100."); if (new Set(clean).size < 2 || new Set(clean).size !== clean.length) throw new Error( "Word list must contain at least two unique non-empty entries.", ); return { value: Array.from( { length: count }, () => clean[source.integer(0, clean.length)]!, ).join(separator), entropy: count * Math.log2(clean.length), }; } export function normalValues( source: RandomSource, count: number, mean: number, deviation: number, ): number[] { if ( !Number.isSafeInteger(count) || count < 1 || count > 100_000 || !Number.isFinite(mean) || !Number.isFinite(deviation) || deviation <= 0 ) throw new Error( "Use a count of 1–100,000 and a positive finite deviation.", ); const values: number[] = []; while (values.length < count) { const u = Math.max(Number.EPSILON, source.float()); const v = source.float(); const radius = Math.sqrt(-2 * Math.log(u)); const first = mean + deviation * radius * Math.cos(2 * Math.PI * v); const second = mean + deviation * radius * Math.sin(2 * Math.PI * v); if (!Number.isFinite(first) || !Number.isFinite(second)) throw new Error( "Normal-distribution parameters produced a non-finite result.", ); values.push(first); if (values.length < count) values.push(second); } return values; } export function sampleValues( source: RandomSource, values: readonly T[], count: number, ): T[] { if (!Number.isSafeInteger(count) || count < 1 || count > values.length) throw new Error( "Sample count must be between 1 and the number of input items.", ); return source.shuffle(values).slice(0, count); }