Files
vanity-delve/core/rng.mjs
T
tevansandClaude Opus 5 07148df8e6 DELVE core: seeded generator, fiction kernel, Barrow pack, CLI
Build steps 1, 2 and 4 of the plan in DESIGN.md.

core/ is pure JS with zero Foundry globals and 11 passing invariants. The
fiction model has one root draw — an APPETITE plus the ACCOMMODATION someone
made for it — and projects the motif, danger, prize, faction, cue fragments and
the appeasement move from it, so the fiction coheres instead of being four
unrelated draws. 336 kernels from 21 authored lines; 60 generated delves gave
51 distinct kernels.

Cue fragments are tagged by facet (institution/ritual/demand/wound/anchor) and
dealt without replacement. The facet progression across the arc reproduces the
hand-authored paper delve's structure, which had arrived at it by instinct.

Generation and table dice stay apart: pressure.mjs decides when a clock roll is
due and what it means, and never rolls. That is what makes the skeleton seed an
honest promise rather than a half-truth.

paper/ holds the hand-written delve and both playtest logs. spike/ holds the
instrumented harness that measured the Forge outside Foundry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:16:58 +01:00

111 lines
3.4 KiB
JavaScript
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.
/**
* 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)}`;
}