What was on git could not be installed. foundry-module/ referenced core/ rather than containing it, so an installed copy had no generator at all — it only ever worked here because the live module directory had core/ copied in by hand. core/ is now vendored into foundry-module/module/core/, minus cli.mjs and test.mjs which are desk tools. module.json points manifest and download at release assets, the way mapwright distributes, so a remote server can install by URL without reaching into the repo tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
3.4 KiB
JavaScript
111 lines
3.4 KiB
JavaScript
/**
|
||
* Seeded randomness with derived streams.
|
||
*
|
||
* Draft 5 §11: a delve is reproducible from its skeleton seed. That only holds if every
|
||
* component draws from its *own* stream — otherwise materialising areas in a different order
|
||
* changes every subsequent draw. So each component derives a named sub-seed:
|
||
*
|
||
* seed::skeleton
|
||
* seed::area::a3::feature
|
||
* seed::area::a3::encounter
|
||
*
|
||
* Two rules make this work:
|
||
* 1. Never share an Rng between components.
|
||
* 2. Never let draw *count* in one component affect another.
|
||
*/
|
||
|
||
/** xmur3: string → 32-bit seed. */
|
||
function xmur3(str) {
|
||
let h = 1779033703 ^ str.length;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||
h = (h << 13) | (h >>> 19);
|
||
}
|
||
return () => {
|
||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||
return (h ^= h >>> 16) >>> 0;
|
||
};
|
||
}
|
||
|
||
/** sfc32: small, fast, good enough for content generation. */
|
||
function sfc32(a, b, c, d) {
|
||
return () => {
|
||
a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
|
||
let t = (a + b) | 0;
|
||
a = b ^ (b >>> 9);
|
||
b = (c + (c << 3)) | 0;
|
||
c = (c << 21) | (c >>> 11);
|
||
d = (d + 1) | 0;
|
||
t = (t + d) | 0;
|
||
c = (c + t) | 0;
|
||
return (t >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
export class Rng {
|
||
constructor(seedString) {
|
||
this.seed = String(seedString);
|
||
const h = xmur3(this.seed);
|
||
this._next = sfc32(h(), h(), h(), h());
|
||
this.draws = 0;
|
||
}
|
||
|
||
/** A new independent stream. Order-independent by construction. */
|
||
derive(...parts) {
|
||
return new Rng(`${this.seed}::${parts.join('::')}`);
|
||
}
|
||
|
||
float() { this.draws++; return this._next(); }
|
||
|
||
/** Integer in [0, n). */
|
||
int(n) { return Math.floor(this.float() * n); }
|
||
|
||
/** Integer in [min, max] inclusive. */
|
||
between(min, max) { return min + this.int(max - min + 1); }
|
||
|
||
chance(p) { return this.float() < p; }
|
||
|
||
pick(arr) {
|
||
if (!Array.isArray(arr) || arr.length === 0) return undefined;
|
||
return arr[this.int(arr.length)];
|
||
}
|
||
|
||
/** n distinct items, or as many as exist. Never returns undefined holes. */
|
||
pickN(arr, n) {
|
||
const pool = [...(arr ?? [])];
|
||
const out = [];
|
||
while (n-- > 0 && pool.length) out.push(pool.splice(this.int(pool.length), 1)[0]);
|
||
return out;
|
||
}
|
||
|
||
shuffle(arr) {
|
||
const a = [...(arr ?? [])];
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = this.int(i + 1);
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
/** Weighted pick from {key: weight} or [[value, weight], ...]. */
|
||
weighted(spec) {
|
||
const entries = Array.isArray(spec) ? spec : Object.entries(spec);
|
||
const total = entries.reduce((s, [, w]) => s + w, 0);
|
||
if (total <= 0) return undefined;
|
||
let r = this.float() * total;
|
||
for (const [value, w] of entries) { r -= w; if (r < 0) return value; }
|
||
return entries[entries.length - 1][0];
|
||
}
|
||
|
||
/** d6 pool; VANITY counts 5–6 as Successes. Used for generation only, never table dice. */
|
||
pool(n) { return Array.from({ length: n }, () => this.between(1, 6)); }
|
||
}
|
||
|
||
/** A random, human-sayable seed — two words and a number. */
|
||
export function coinSeed(rng = new Rng(String(Date.now()))) {
|
||
const a = ['gilded', 'hollow', 'salt', 'ember', 'quiet', 'crooked', 'pale', 'brazen'];
|
||
const b = ['court', 'crown', 'mirror', 'vigil', 'debt', 'mask', 'hymn', 'tally'];
|
||
return `${rng.pick(a)}-${rng.pick(b)}-${rng.between(100, 999)}`;
|
||
}
|