Skip to content

Commit 02bb3bc

Browse files
CodeManEthanclaude
andcommitted
Genesis: pace now means more gets built, not finished sooner
generateMap(seed, scale) with subset stability: the full scale-4 roster of town sites, buildings and names is generated deterministically on every call, then trimmed to the requested scale — so the same seed at a higher pace keeps identical terrain, river and founding towns, and simply settles deeper into the valley (2 lonely sites at 0.25x, 5-7 at 1x, up to ~16 towns at 4x). Roads join each town to the nearest town earlier in roster order (prefix-safe by construction, unlike Prim); trees, scatter and biomes are computed against the full roster so the landscape never shifts with pace. The PACE button regenerates the map at the new scale; buildTimeline's self-pacing absorbs the extra content and still lands the last roof before dark (verified: 4x finishes ~20:49). The timeline pace parameter is retired from the UI. Harness: invariants at 5 scales x 3 seeds, cross-scale subset checks, 250-seed sweep at 1x and 4x, subset stability over 60 seeds - all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5LX4oqPCScex4Eb41KDh4
1 parent 9c353a1 commit 02bb3bc

4 files changed

Lines changed: 387 additions & 148 deletions

File tree

scripts/genesis-stats.mjs

Lines changed: 141 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
// node scripts/genesis-stats.mjs 7 8 9
88
// node scripts/genesis-stats.mjs --sweep 200 # invariants only, 200 seeds
99
//
10+
// Each seed is reported at scale 1 and then checked at every scale in
11+
// SCALES, plus the subset-stability checks that guarantee a smaller scale is a
12+
// strict prefix of a larger one.
13+
//
1014
// All distances below are measured in screen-aligned u/v units (u = gx - gy,
1115
// v = gx + gy) — the same space gen.ts plans in and the space site.radius is
1216
// compared against by the renderer.
@@ -161,7 +165,9 @@ function report(seed) {
161165

162166
/* ------------------------------- invariants ------------------------------ */
163167

164-
function checks(seed, map) {
168+
const SCALES = [0.25, 0.5, 1, 2, 4];
169+
170+
function checks(seed, map, scale = 1) {
165171
const river = map.river.map(toUV);
166172
const roadUV = new Map(map.roads.map((r) => [r.id, r.pts.map(toUV)]));
167173
const results = [];
@@ -244,8 +250,8 @@ function checks(seed, map) {
244250

245251
/* (f) determinism */
246252
{
247-
const a = JSON.stringify(generateMap(seed));
248-
const b = JSON.stringify(generateMap(seed));
253+
const a = JSON.stringify(generateMap(seed, scale));
254+
const b = JSON.stringify(generateMap(seed, scale));
249255
check('f deterministic', a === b, `${a.length} bytes`);
250256
}
251257

@@ -297,19 +303,22 @@ function checks(seed, map) {
297303
check('k trees clear river/roads', bad === 0, `${bad} violations`);
298304
}
299305
{
306+
const s0b = map.sites[0].buildings.length;
300307
const ok =
301308
map.trees.length >= 400 &&
302-
map.trees.length <= 950 &&
309+
map.trees.length <= 1100 &&
303310
map.scatter.length >= 90 &&
304311
map.scatter.length <= 220 &&
305-
map.sites.length >= 5 &&
306-
map.sites.length <= 7 &&
307-
map.sites[0].buildings.length >= 8 &&
308-
map.sites[0].buildings.length <= 10;
312+
map.sites.length >= 2 &&
313+
map.sites.length <= 16 &&
314+
s0b >= 3 &&
315+
s0b <= 14 &&
316+
// Scale 1 must still land on the hand-tuned baseline.
317+
(scale !== 1 || (map.sites.length >= 5 && map.sites.length <= 7 && s0b >= 8 && s0b <= 10));
309318
check(
310319
'l populations in spec range',
311320
ok,
312-
`${map.sites.length} sites, s0 ${map.sites[0].buildings.length} bldg, ${map.trees.length} trees, ${map.scatter.length} scatter, ${map.bridges.length} bridges`
321+
`${map.sites.length} sites, s0 ${s0b} bldg, ${map.trees.length} trees, ${map.scatter.length} scatter, ${map.bridges.length} bridges`
313322
);
314323
}
315324
{
@@ -318,11 +327,94 @@ function checks(seed, map) {
318327
}
319328

320329
const pass = results.every((r) => r.ok);
321-
console.log('\nINVARIANTS');
330+
console.log(`\nINVARIANTS seed ${seed} scale ${scale}`);
322331
for (const r of results) {
323332
console.log(` ${r.ok ? 'PASS' : 'FAIL'} ${r.name.padEnd(38)} ${r.detail}`);
324333
}
325-
console.log(` => seed ${seed}: ${pass ? 'ALL PASS' : 'FAILURES'}`);
334+
console.log(` => seed ${seed} @ ${scale}x: ${pass ? 'ALL PASS' : 'FAILURES'}`);
335+
return pass;
336+
}
337+
338+
/* --------------------------- subset stability ---------------------------- */
339+
340+
/**
341+
* A smaller scale must be a strict prefix of a larger one: same land, same
342+
* names, same positions, with sites / roads / per-site buildings only ever
343+
* appended. This is what makes the pace control a live knob instead of a
344+
* reroll, so it is checked pairwise across every adjacent pair of scales.
345+
*/
346+
function subsetChecks(seed) {
347+
const maps = SCALES.map((s) => generateMap(seed, s));
348+
const results = [];
349+
const check = (name, ok, detail = '') => results.push({ name, ok, detail });
350+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
351+
352+
// Terrain is scale-invariant, full stop.
353+
for (const field of ['river', 'riverWidth', 'chunks', 'trees', 'scatter', 'bounds', 'content', 'valleyName']) {
354+
const ok = maps.every((m) => same(m[field], maps[0][field]));
355+
check(`terrain: ${field} identical`, ok);
356+
}
357+
358+
for (let k = 1; k < maps.length; k++) {
359+
const lo = maps[k - 1];
360+
const hi = maps[k];
361+
const tag = `${SCALES[k - 1]}x < ${SCALES[k]}x`;
362+
363+
check(
364+
`${tag}: sites grow`,
365+
hi.sites.length >= lo.sites.length,
366+
`${lo.sites.length} -> ${hi.sites.length}`
367+
);
368+
// Site identity, position, radius, accent, name and dressing must not move.
369+
let bad = 0;
370+
let bldBad = 0;
371+
let bldShrink = 0;
372+
for (let i = 0; i < lo.sites.length; i++) {
373+
const a = lo.sites[i];
374+
const b = hi.sites[i];
375+
if (!b) {
376+
bad++;
377+
continue;
378+
}
379+
const meta = (x) => ({ id: x.id, name: x.name, gx: x.gx, gy: x.gy, radius: x.radius, accent: x.accent, props: x.props });
380+
if (!same(meta(a), meta(b))) bad++;
381+
if (b.buildings.length < a.buildings.length) bldShrink++;
382+
for (let j = 0; j < a.buildings.length; j++) {
383+
if (!same(a.buildings[j], b.buildings[j])) bldBad++;
384+
}
385+
}
386+
check(`${tag}: site prefix identical`, bad === 0, `${bad} mismatched sites`);
387+
check(`${tag}: buildings are a prefix`, bldBad === 0 && bldShrink === 0, `${bldBad} changed, ${bldShrink} shrank`);
388+
389+
const roadPrefix =
390+
hi.roads.length >= lo.roads.length &&
391+
lo.roads.every((r, i) => same(r, hi.roads[i]));
392+
check(`${tag}: roads are a prefix`, roadPrefix, `${lo.roads.length} -> ${hi.roads.length}`);
393+
394+
const bridgePrefix =
395+
hi.bridges.length >= lo.bridges.length &&
396+
lo.bridges.every((b, i) => same(b, hi.bridges[i]));
397+
check(`${tag}: bridges are a prefix`, bridgePrefix, `${lo.bridges.length} -> ${hi.bridges.length}`);
398+
}
399+
400+
// The default argument must be exactly scale 1.
401+
check(
402+
'default scale === explicit 1',
403+
JSON.stringify(generateMap(seed)) === JSON.stringify(generateMap(seed, 1))
404+
);
405+
// Out-of-range scales clamp rather than explode.
406+
check(
407+
'scale clamps to [0.25, 4]',
408+
JSON.stringify(generateMap(seed, 0.01)) === JSON.stringify(generateMap(seed, 0.25)) &&
409+
JSON.stringify(generateMap(seed, 99)) === JSON.stringify(generateMap(seed, 4))
410+
);
411+
412+
const pass = results.every((r) => r.ok);
413+
console.log(`\nSUBSET STABILITY seed ${seed}`);
414+
for (const r of results) {
415+
console.log(` ${r.ok ? 'PASS' : 'FAIL'} ${r.name.padEnd(38)} ${r.detail}`);
416+
}
417+
console.log(` => seed ${seed} subset: ${pass ? 'ALL PASS' : 'FAILURES'}`);
326418
return pass;
327419
}
328420

@@ -331,28 +423,51 @@ function checks(seed, map) {
331423
const argv = process.argv.slice(2);
332424
let allPass = true;
333425

426+
const quietly = (fn) => {
427+
const out = [];
428+
const orig = console.log;
429+
console.log = (...a) => out.push(a.join(' '));
430+
let ok;
431+
try {
432+
ok = fn();
433+
} finally {
434+
console.log = orig;
435+
}
436+
return { ok, out };
437+
};
438+
334439
if (argv[0] === '--sweep') {
335440
const n = Number(argv[1] || 100);
336-
const fails = [];
337-
for (let s = 1; s <= n; s++) {
338-
const map = generateMap(s);
339-
const out = [];
340-
const orig = console.log;
341-
console.log = (...a) => out.push(a.join(' '));
342-
const ok = checks(s, map);
343-
console.log = orig;
344-
if (!ok) {
345-
fails.push(s);
346-
console.log(out.join('\n'));
441+
for (const scale of [1, 4]) {
442+
const fails = [];
443+
for (let s = 1; s <= n; s++) {
444+
const r = quietly(() => checks(s, generateMap(s, scale), scale));
445+
if (!r.ok) {
446+
fails.push(s);
447+
console.log(r.out.join('\n'));
448+
}
449+
}
450+
console.log(`sweep ${n} seeds @ ${scale}x: ${fails.length ? `FAIL ${fails.join(',')}` : 'ALL PASS'}`);
451+
if (fails.length) allPass = false;
452+
}
453+
const subFails = [];
454+
for (let s = 1; s <= Math.min(n, 60); s++) {
455+
const r = quietly(() => subsetChecks(s));
456+
if (!r.ok) {
457+
subFails.push(s);
458+
console.log(r.out.join('\n'));
347459
}
348460
}
349-
console.log(`\nsweep ${n} seeds: ${fails.length ? `FAIL ${fails.join(',')}` : 'ALL PASS'}`);
350-
allPass = fails.length === 0;
461+
console.log(`sweep ${Math.min(n, 60)} seeds subset stability: ${subFails.length ? `FAIL ${subFails.join(',')}` : 'ALL PASS'}`);
462+
if (subFails.length) allPass = false;
351463
} else {
352464
const seeds = argv.length ? argv.map(Number) : [1, 42, 20260802];
353465
for (const s of seeds) {
354-
const map = report(s);
355-
if (!checks(s, map)) allPass = false;
466+
report(s);
467+
for (const scale of SCALES) {
468+
if (!checks(s, generateMap(s, scale), scale)) allPass = false;
469+
}
470+
if (!subsetChecks(s)) allPass = false;
356471
}
357472
}
358473

src/components/designs/genesis/TheGenesis.tsx

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ import {
5858
export interface World {
5959
map: GenesisMap;
6060
timeline: Timeline;
61-
/** Re-lay the same map's day at a different work pace. */
62-
retime: (map: GenesisMap, pace: number) => Timeline;
6361
emptySnapshot: (map: GenesisMap) => WorldSnapshot;
6462
snapshotAt: (map: GenesisMap, tl: Timeline, t: number) => WorldSnapshot;
6563
advance: (snap: WorldSnapshot, tl: Timeline, toT: number) => void;
@@ -78,26 +76,28 @@ import { advance, buildTimeline, emptySnapshot, snapshotAt } from './timeline';
7876

7977
const USE_GENERATED = true;
8078

79+
/**
80+
* `pace` is how ambitious the day is, so it belongs to the *map*: a bigger pace
81+
* generates more of the valley. The timeline then paces whatever it is handed
82+
* across the same 24 hours, which is why nothing here passes it along.
83+
*/
8184
function loadWorld(seed: number, pace = 1): World {
8285
if (USE_GENERATED) {
83-
const map = generateMap(seed);
86+
const map = generateMap(seed, pace);
8487
return {
8588
map,
86-
timeline: buildTimeline(map, pace),
87-
retime: buildTimeline,
89+
timeline: buildTimeline(map),
8890
emptySnapshot,
8991
snapshotAt,
9092
advance,
9193
};
9294
}
95+
// The fixture is one hand-written valley; there is no more of it to build.
9396
const map = fixtureMap();
9497
map.seed = seed;
95-
// The fixture is a hand-written day; there is nothing in it to re-pace.
96-
const retime = (m: GenesisMap) => fixtureTimeline(m);
9798
return {
9899
map,
99-
timeline: retime(map),
100-
retime,
100+
timeline: fixtureTimeline(map),
101101
emptySnapshot: fixtureEmptySnapshot,
102102
snapshotAt: fixtureSnapshotAt,
103103
advance: fixtureAdvance,
@@ -107,8 +107,9 @@ function loadWorld(seed: number, pace = 1): World {
107107
/* --------------------------------- helpers ------------------------------- */
108108

109109
const SPEEDS = [60, 600, 3600];
110-
/** How hard the valley works, which is a different question from how fast the
111-
* clock runs. 1 is a full day's work; 4 has the last roof on by lunchtime. */
110+
/** How much the valley builds today, which is a different question from how
111+
* fast the clock runs. The day is always a day; at 4 it simply fills with four
112+
* times the settlement, and at ½ it stays a hamlet. */
112113
const PACES = [0.5, 1, 2, 4];
113114
const PACE_LABELS = ['½', '1', '2', '4'];
114115
const clamp = (n: number, lo: number, hi: number) => (n < lo ? lo : n > hi ? hi : n);
@@ -374,6 +375,30 @@ export default function TheGenesis() {
374375
dirtyRef.current = true;
375376
pushLog();
376377
};
378+
379+
/** A new seed or a new pace both mean a new map, and a new map is a whole
380+
* new world: ledger, baked terrain, crowd and framing all go. The clock
381+
* does not — the visitor keeps their hour, and LIVE stays LIVE. */
382+
const rebuild = (s: number, p: number) => {
383+
seedRef.current = s;
384+
paceRef.current = p;
385+
world = loadWorld(s, p);
386+
worldRef.current = world;
387+
scene = buildGenesisScene(world.map);
388+
sceneRef.current = scene;
389+
setAmbientPace(amb, p);
390+
snapRef.current = world.snapshotAt(world.map, world.timeline, tRef.current);
391+
resetAmbient(scene, amb, snapRef.current);
392+
settleAmbient(scene, amb, snapRef.current);
393+
camRef.current = clampCam(fitCam());
394+
logLenRef.current = -1;
395+
pushLog();
396+
setSeed(s);
397+
setValley(world.map.valleyName);
398+
dirtyRef.current = true;
399+
paintOnce();
400+
};
401+
377402
api.current = {
378403
applyT,
379404
paint: () => {
@@ -408,42 +433,17 @@ export default function TheGenesis() {
408433
dirtyRef.current = true;
409434
if (reducedRef.current) paintOnce();
410435
},
411-
// A different valley is a whole new world: map, ledger, baked terrain and
412-
// crowd all go. The clock does not — the visitor keeps their hour, and
413-
// LIVE stays LIVE.
414436
chooseSeed: (nextSeed) => {
415437
const s = nextSeed >>> 0;
416438
if (s === seedRef.current) return;
417-
seedRef.current = s;
418-
world = loadWorld(s, paceRef.current);
419-
worldRef.current = world;
420-
scene = buildGenesisScene(world.map);
421-
sceneRef.current = scene;
422-
amb = makeAmbient(paceRef.current);
423-
ambRef.current = amb;
424-
snapRef.current = world.snapshotAt(world.map, world.timeline, tRef.current);
425-
settleAmbient(scene, amb, snapRef.current);
426-
camRef.current = clampCam(fitCam());
427-
logLenRef.current = -1;
428-
pushLog();
429-
setSeed(s);
430-
setValley(world.map.valleyName);
431-
dirtyRef.current = true;
432-
paintOnce();
439+
rebuild(s, paceRef.current);
433440
},
434-
// Same map, same hour, a different day's work in it.
441+
// A bigger pace is a bigger valley, so it regenerates the map too. The
442+
// seed guarantees everything already on screen stays exactly where it is
443+
// and simply gains neighbours.
435444
choosePace: (nextPace) => {
436445
if (nextPace === paceRef.current) return;
437-
paceRef.current = nextPace;
438-
world.timeline = world.retime(world.map, nextPace);
439-
setAmbientPace(amb, nextPace);
440-
snapRef.current = world.snapshotAt(world.map, world.timeline, tRef.current);
441-
resetAmbient(scene, amb, snapRef.current);
442-
settleAmbient(scene, amb, snapRef.current);
443-
logLenRef.current = -1;
444-
pushLog();
445-
dirtyRef.current = true;
446-
paintOnce();
446+
rebuild(seedRef.current, nextPace);
447447
},
448448
};
449449

@@ -585,7 +585,8 @@ export default function TheGenesis() {
585585
const n = (paceIndex(paceRef.current) + 1) % PACES.length;
586586
setPaceIdx(n);
587587
api.current?.choosePace(PACES[n]);
588-
syncUrl(seedRef.current, PACES[n], false);
588+
// A different pace is a different map, so any hand-set camera is stale too.
589+
syncUrl(seedRef.current, PACES[n], true);
589590
}, [syncUrl]);
590591

591592
const goLive = useCallback(() => {
@@ -735,8 +736,8 @@ export default function TheGenesis() {
735736
type="button"
736737
className="gen-pace"
737738
onClick={cyclePace}
738-
aria-label={`Work pace: ${PACE_LABELS[paceIdx]} times a normal day's work`}
739-
title="Work pace — how much the valley gets built in a day"
739+
aria-label={`Ambition: ${PACE_LABELS[paceIdx]}× as much valley built today`}
740+
title="Ambition — how much the valley builds today"
740741
>
741742
<span className="gen-plab" aria-hidden="true">
742743
pace

0 commit comments

Comments
 (0)