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>
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)}`;
|
||
}
|