Files
rand-tools/src/random/generators.ts
T
2026-09-01 02:53:47 +02:00

323 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 1100,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 11,000,000 code points.");
if (symbols.length < 2 || symbols.length > 65_536)
throw new Error("Alphabet must contain 265,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: 1100,000 dice, 21,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 1100.");
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 1100,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<T>(
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);
}