diff --git a/src/common/random.ts b/src/common/random.ts index 21540b3..0f2e1fc 100644 --- a/src/common/random.ts +++ b/src/common/random.ts @@ -17,7 +17,7 @@ * rng.setState(saved); // restore */ -import { stringHash } from "./utils"; +import { capitalize, stringHash } from "./utils"; // --------------------------------------------------------------------------- // Types @@ -26,6 +26,34 @@ import { stringHash } from "./utils"; /** Four-word xoshiro128** state. Serialisable to / from JSON. */ export type RNGState = [number, number, number, number]; +export interface NameParts { + onsets?: string[]; + vowels?: string[]; + codas?: string[]; + prefixes?: string[]; + suffixes?: string[]; +} + +export interface NameOptions { + parts?: NameParts; + + minSyllables?: number; + maxSyllables?: number; + + prefixChance?: number; + onsetChance?: number; + codaChance?: number; + suffixChance?: number; + + capitalize?: boolean; + minLength?: number; + maxLength?: number; + + maxAttempts?: number; + + bannedPatterns?: RegExp[]; +}; + // --------------------------------------------------------------------------- // Helpers (module-private) // --------------------------------------------------------------------------- @@ -42,7 +70,7 @@ function rotl(x: number, k: number): number { function splitMix32Step(h: number): [number, number] { h = (h + 0x9e3779b9) >>> 0; let v = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0; - v = Math.imul(v ^ (v >>> 13), 0xc2b2ae35) >>> 0; + v = Math.imul(v ^ (v >>> 13), 0xc2b2ae35) >>> 0; return [(v ^ (v >>> 16)) >>> 0, h]; } @@ -53,12 +81,49 @@ function buildState(seed: number | string): RNGState { [s0, h] = splitMix32Step(h); [s1, h] = splitMix32Step(h); [s2, h] = splitMix32Step(h); - [s3] = splitMix32Step(h); + [s3] = splitMix32Step(h); // Guard: xoshiro128** must never start in the all-zero state. if ((s0 | s1 | s2 | s3) === 0) s0 = 1; return [s0, s1, s2, s3]; } +function cleanupName(raw: string): string { + let s = raw.toLowerCase(); + + // Keep letters, apostrophes and hyphens only. + s = s.replace(/[^a-z'-]/g, ""); + + // Collapse repeated separators. + s = s.replace(/(['-])\1+/g, "$1"); + + // Collapse extreme repeats. + s = s.replace(/([bcdfghjklmnpqrstvwxyz])\1{2,}/g, "$1$1"); + s = s.replace(/([aeiouy])\1{2,}/g, "$1$1"); + + // Remove separators at edges. + s = s.replace(/^['-]+|['-]+$/g, ""); + + // Remove awkward separator chains. + s = s.replace(/['-]{2,}/g, "-"); + + return s; +} + +function looksBad(name: string, bannedPatterns: RegExp[]): boolean { + if (name.length < 2) return true; + + const tests = [ + /[bcdfghjklmnpqrstvwxyz]{5,}/i, + /[aeiouy]{5,}/i, + /(.)\1\1/i, + /^['-]|['-]$/i, + /['-]{2,}/i, + ...bannedPatterns, + ]; + + return tests.some((re) => re.test(name)); +} + // --------------------------------------------------------------------------- // Main class // --------------------------------------------------------------------------- @@ -143,6 +208,77 @@ export class SeededRandom { return (this.next() & 1) === 1; } + nextName(options: NameOptions = {}): string { + const parts: Required = { + onsets: options.parts?.onsets?.length ? options.parts.onsets : DEFAULT_PARTS.onsets, + vowels: options.parts?.vowels?.length ? options.parts.vowels : DEFAULT_PARTS.vowels, + codas: options.parts?.codas?.length ? options.parts.codas : DEFAULT_PARTS.codas, + prefixes: options.parts?.prefixes?.length ? options.parts.prefixes : DEFAULT_PARTS.prefixes, + suffixes: options.parts?.suffixes?.length ? options.parts.suffixes : DEFAULT_PARTS.suffixes + }; + + const minSyllables = options.minSyllables ?? 2; + const maxSyllables = options.maxSyllables ?? 3; + + const prefixChance = options.prefixChance ?? 0.18; + const onsetChance = options.onsetChance ?? 0.92; + const codaChance = options.codaChance ?? 0.58; + const suffixChance = options.suffixChance ?? 0.12; + + const capitalizeOutput = options.capitalize ?? true; + const minLength = options.minLength ?? 3; + const maxLength = options.maxLength ?? 12; + + const maxAttempts = options.maxAttempts ?? 32; + const bannedPatterns = options.bannedPatterns ?? DEFAULT_BANNED; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let name = ""; + + if (parts.prefixes.length > 0 && this.nextFloat() < prefixChance) { + name += this.choice(parts.prefixes); + } + + const syllables = this.nextInt(minSyllables, maxSyllables); + + for (let i = 0; i < syllables; i++) { + if (parts.onsets.length > 0 && this.nextFloat() < onsetChance) { + name += this.choice(parts.onsets); + } + + name += this.choice(parts.vowels); + + if (parts.codas.length > 0 && this.nextFloat() < codaChance) { + name += this.choice(parts.codas); + } + } + + if (parts.suffixes.length > 0 && this.nextFloat() < suffixChance) { + name += this.choice(parts.suffixes); + } + + name = cleanupName(name); + + if ( + name.length >= minLength && + name.length <= maxLength && + !looksBad(name, bannedPatterns) + ) { + return capitalizeOutput ? capitalize(name) : name; + } + } + + // Fallback: force something usable even if the filters were too strict. + let fallback = cleanupName( + this.choice(parts.onsets) + this.choice(parts.vowels) + this.choice(parts.codas) + ); + + if (!fallback) fallback = "name"; + fallback = fallback.slice(0, Math.max(minLength, Math.min(maxLength, fallback.length))); + + return capitalizeOutput ? capitalize(fallback) : fallback; + } + /** * Uniform random element from any iterable. * Arrays are read in O(1); other iterables are materialised first. @@ -165,7 +301,7 @@ export class SeededRandom { } if (k === 0) { - return []; + return []; } if (!Number.isInteger(k) || k < 0) { @@ -211,7 +347,7 @@ export class SeededRandom { ); } if (k === 0) { - return []; + return []; } if (k !== undefined && (!Number.isInteger(k) || k < 1)) { throw new RangeError(`weightedChoice: k must be a positive integer, got ${k}`); @@ -293,16 +429,16 @@ export class SeededRandom { * Create an independent child RNG for a sub-resource (room, NPC, particle * system, …). * - * The child starts from the *current* state. The parent then advances by - * one step so parent and child immediately diverge — neither will reproduce + * The child starts from the *current* state. The parent then advances + * so parent and child immediately diverge — neither will reproduce * the other's sequence. * * Typical pattern: * const worldRng = new SeededRandom('seed-42'); - * const dungeonRng = worldRng.clone(); // independent dungeon stream - * const npcRng = worldRng.clone(); // independent NPC stream + * const dungeonRng = worldRng.fork(); // independent dungeon stream + * const npcRng = worldRng.fork(); // independent NPC stream */ - clone(): SeededRandom { + fork(): SeededRandom { const child = new SeededRandom(this.getState()); this.jump(); // diverge parent so the two streams never overlap return child; @@ -369,3 +505,173 @@ export class SeededRandom { return new SeededRandom(json.state); } } + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +const DEFAULT_PARTS: Required = { + onsets: [ + "", "", "", + "b", "br", "c", "cr", "d", "dr", "f", "f", "g", "gr", + "k", "kr", "l", "m", "n", "p", "pr", "r", "s", "sh", + "st", "str", "t", "th", "tr", "v", "w", "z" + ], + vowels: [ + "a", "a", "a", "a", "e", "e", "e", "i", "i", "o", "o", "o", "o", "u", "u", + "ae", "ai", "ea", "ie", "oa", "ou", "ui" + ], + codas: [ + "", "", "", "", "n", "r", "s", "th", "k", "l", "m", "nd", + "rd", "st", "sh", "x", "z" + ], + prefixes: [ + "Al", "Bel", "Cor", "Dar", "El", "Fae", "Gal", "Har", + "Is", "Kel", "Mar", "Nor", "Or", "Val", "Wil" + ], + suffixes: [ + "a", "an", "ar", "as", "en", "er", "eth", "ia", "in", + "ir", "or", "os", "um", "us", "wyn", "iel" + ] +}; + +const DEFAULT_BANNED: RegExp[] = [ + /aaa/i, + /eee/i, + /iii/i, + /ooo/i, + /uuu/i, + /[bcdfghjklmnpqrstvwxyz]{5,}/i, + /[aeiouy]{5,}/i, + /q(?!u)/i, + /xq|qx|zx|qz/i, + /vv/i +]; + +export const MALE_NAME_OPTIONS: NameOptions = { + minSyllables: 2, + maxSyllables: 3, + + prefixChance: 0.12, + suffixChance: 0.08, + + maxLength: 11, + + parts: { + onsets: [ + "", "", + "b", "br", "d", "dr", "f", "g", "gr", + "k", "kr", "l", "m", "n", "p", "pr", + "r", "s", "st", "t", "th", "tr", "v", "w" + ], + + vowels: [ + "a", "a", "e", "e", "i", "o", "o", "u", + "ae", "ai", "oa" + ], + + codas: [ + "", "", "", + "n", "r", "s", "k", "l", "m", + "nd", "rd", "st", "th" + ], + + prefixes: [ + "Al", "Bar", "Cor", "Dar", "Ed", + "Gar", "Hal", "Kor", "Mor", "Tor", "Var" + ], + + suffixes: [ + "an", "ar", "en", "or", "ric", + "us", "ik", "vald", "mir" + ] + } +}; + +export const FEMALE_NAME_OPTIONS: NameOptions = { + minSyllables: 2, + maxSyllables: 4, + + prefixChance: 0.18, + suffixChance: 0.22, + + maxLength: 12, + + parts: { + onsets: [ + "", "", "", + "l", "l", "m", "n", "r", "s", + "sh", "th", "v", "f", "el", "al", + "cel", "ly", "my" + ], + + vowels: [ + "a", "a", "e", "e", "i", "i", "o", + "u", "ae", "ia", "ea", "ie", "ou" + ], + + codas: [ + "", "", "", "", + "l", "n", "ra", "na", "s", "th" + ], + + prefixes: [ + "Ael", "Ela", "Fae", "Lia", + "Mira", "Ny", "Syl", "Vela" + ], + + suffixes: [ + "a", "ia", "elle", "eth", "ina", + "ira", "is", "wyn", "ara", "iel" + ] + } +}; + +export const ANIMAL_NAME_OPTIONS: NameOptions = { + minSyllables: 1, + maxSyllables: 2, + + prefixChance: 0.05, + suffixChance: 0.02, + + minLength: 3, + maxLength: 9, + + parts: { + onsets: [ + "", "", + "b", "br", "ch", "cl", "cr", + "f", "fl", "gr", "h", "k", + "kr", "m", "p", "pr", "r", + "sc", "sk", "sn", "sp", "st", + "t", "tr", "w", "z" + ], + + vowels: [ + "a", "e", "i", "o", "u", + "oo", "ee", "ai" + ], + + codas: [ + "", "", "", + "f", "g", "k", "l", "m", + "n", "p", "r", "rk", "sh", + "sk", "t", "x", "z" + ], + + prefixes: [ + "Black", "Grey", "Red", "Swift", + "Wild", "Ash", "Stone" + ], + + suffixes: [ + "claw", "fang", "fur", + "tail", "paw", "snout" + ] + }, + + bannedPatterns: [ + ...DEFAULT_BANNED, + /human/i + ] +}; diff --git a/src/common/utils.ts b/src/common/utils.ts index cebec80..269d08f 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -119,6 +119,11 @@ export const throttle = function (func: F, m export const callUpdater = (f: StateUpdater, prev: T) => typeof f === 'function' ? (f as Function)(prev) : f; +export function capitalize(text: string): string { + if (!text) return text; + return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); +} + export const formatNumber = (n: number): string => { if (n === 0) return '0'; if (n < 0) return `-${formatNumber(-n)}`; diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 701f595..1ee9652 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,19 +1,17 @@ -import { createCanvas } from "@common/display/canvas"; -import { BSP } from "@common/level/bsp"; -import { stringHash } from "@common/utils"; - -const S = 512; +import { SeededRandom, ANIMAL_NAME_OPTIONS, FEMALE_NAME_OPTIONS, MALE_NAME_OPTIONS } from "@common/random"; export default async function main() { - const canvas = createCanvas(S, S); + const rnd = new SeededRandom(42); - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - BSP.generateLevel(S, S, (x, y, node) => { - ctx.fillStyle = node?.id ? `hsl(${stringHash(node.id) % 360}, 100%, 20%)`: 'black'; - ctx.fillRect(x, y, 1, 1); - }, { depth: 16, minWidth: 8, minHeight: 8 }); - - console.log('done'); + for (let i = 0; i < 10; i++) { + console.log(rnd.nextName(MALE_NAME_OPTIONS)); + } + console.log('------------------------------------'); + for (let i = 0; i < 10; i++) { + console.log(rnd.nextName(FEMALE_NAME_OPTIONS)); + } + console.log('------------------------------------'); + for (let i = 0; i < 10; i++) { + console.log(rnd.nextName(ANIMAL_NAME_OPTIONS)); + } }