323 lines
7.6 KiB
TypeScript
323 lines
7.6 KiB
TypeScript
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<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);
|
||
}
|