Skip to content

Commit aa780e3

Browse files
CodeManEthanclaude
andcommitted
Genesis prototype: a procedurally generated world that lives one day
New design-lab variant at /designs/genesis exploring the daily-reset direction: world = f(seed_of_date, time_of_day). - gen.ts: seeded map generator — meandering river, 5-7 rejection-sampled town sites, A* road router with perpendicular river crossings that structurally produce bridges, forested building plots, seeded English-countryside town/valley names (names.ts). - timeline.ts: the day as a deterministic event stream — founding house at midnight, trees chopped for plots, staged construction, roads extending, bridges built piling/deck/rails before the road crosses, towns founded as roads arrive, everything done by ~21:30. Self-pacing via bisection on a tempo knob. Snapshot/advance/scrub machinery. - scene.ts + TheGenesis.tsx: dynamic renderer reusing the vale's procedural art (buildStructure construction stages, tree/prop factories, bots), with terrain/river baked once and everything else drawn from the current snapshot. Player UI: LIVE wall-clock mode, pause, x60/x600/x3600, 0-24h scrub, event ticker. - Test harnesses: scripts/genesis-stats.mjs (map invariants, 500-seed sweep clean) and scripts/genesis-timeline-stats.mjs (10 cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5LX4oqPCScex4Eb41KDh4
1 parent 847e0d2 commit aa780e3

11 files changed

Lines changed: 6162 additions & 0 deletions

File tree

scripts/genesis-stats.mjs

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
// Genesis map generator test harness.
2+
//
3+
// Generates one or more seeded worlds, prints a compact report, and runs the
4+
// invariant checks the renderer and timeline rely on.
5+
//
6+
// node scripts/genesis-stats.mjs # seeds 1 42 20260802
7+
// node scripts/genesis-stats.mjs 7 8 9
8+
// node scripts/genesis-stats.mjs --sweep 200 # invariants only, 200 seeds
9+
//
10+
// All distances below are measured in screen-aligned u/v units (u = gx - gy,
11+
// v = gx + gy) — the same space gen.ts plans in and the space site.radius is
12+
// compared against by the renderer.
13+
14+
import { dirname, join } from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
17+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
18+
const { generateMap } = await import(join(root, 'src/components/designs/genesis/gen.ts'));
19+
const { TW } = await import(join(root, 'src/components/designs/genesis/types.ts'));
20+
21+
/* ------------------------------ uv geometry ------------------------------ */
22+
23+
const U = (p) => p[0] - p[1];
24+
const V = (p) => p[0] + p[1];
25+
const toUV = (p) => [U(p), V(p)];
26+
const uvOf = (o) => [o.gx - o.gy, o.gx + o.gy];
27+
const d2 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
28+
29+
function segDist(p, a, b) {
30+
const vx = b[0] - a[0];
31+
const vy = b[1] - a[1];
32+
const len2 = vx * vx + vy * vy || 1;
33+
let t = ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2;
34+
t = t < 0 ? 0 : t > 1 ? 1 : t;
35+
return Math.hypot(p[0] - (a[0] + vx * t), p[1] - (a[1] + vy * t));
36+
}
37+
function polyDist(p, line) {
38+
let best = Infinity;
39+
for (let i = 0; i + 1 < line.length; i++) best = Math.min(best, segDist(p, line[i], line[i + 1]));
40+
return best;
41+
}
42+
function segInt(a, b, c, d) {
43+
const r0 = b[0] - a[0];
44+
const r1 = b[1] - a[1];
45+
const s0 = d[0] - c[0];
46+
const s1 = d[1] - c[1];
47+
const den = r0 * s1 - r1 * s0;
48+
if (Math.abs(den) < 1e-9) return null;
49+
const t = ((c[0] - a[0]) * s1 - (c[1] - a[1]) * s0) / den;
50+
const u = ((c[0] - a[0]) * r1 - (c[1] - a[1]) * r0) / den;
51+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
52+
return [a[0] + r0 * t, a[1] + r1 * t];
53+
}
54+
function crossings(a, b) {
55+
const out = [];
56+
for (let i = 0; i + 1 < a.length; i++) {
57+
for (let j = 0; j + 1 < b.length; j++) {
58+
const p = segInt(a[i], a[i + 1], b[j], b[j + 1]);
59+
if (p && !out.some((q) => d2(p, q) < 0.9)) out.push(p);
60+
}
61+
}
62+
return out;
63+
}
64+
const plen = (line) => {
65+
let s = 0;
66+
for (let i = 1; i < line.length; i++) s += d2(line[i - 1], line[i]);
67+
return s;
68+
};
69+
const fpR = (w) => w / TW;
70+
const f1 = (n) => n.toFixed(1);
71+
72+
/* ------------------------------- reporting ------------------------------- */
73+
74+
function report(seed) {
75+
const map = generateMap(seed);
76+
const river = map.river.map(toUV);
77+
const roadUV = new Map(map.roads.map((r) => [r.id, r.pts.map(toUV)]));
78+
79+
const lines = [];
80+
const say = (s) => lines.push(s);
81+
82+
say(`\n${'='.repeat(74)}`);
83+
say(`SEED ${seed} "${map.valleyName}"`);
84+
say('='.repeat(74));
85+
86+
const biomeCount = {};
87+
for (const c of map.chunks) biomeCount[c.biome] = (biomeCount[c.biome] || 0) + 1;
88+
say(
89+
`bounds u[${map.bounds.u0},${map.bounds.u1}] v[${map.bounds.v0},${map.bounds.v1}] ` +
90+
`chunks ${map.chunks.length} (${Object.entries(biomeCount).map(([k, v]) => `${k} ${v}`).join(', ')})`
91+
);
92+
say(
93+
`river: ${map.river.length} pts, width ${map.riverWidth}, length ${f1(plen(river))}, ` +
94+
`mouths (${f1(river[0][0])},${f1(river[0][1])}) -> (${f1(river.at(-1)[0])},${f1(river.at(-1)[1])})`
95+
);
96+
97+
say(`\nSITES (${map.sites.length})`);
98+
for (const s of map.sites) {
99+
const p = uvOf(s);
100+
say(
101+
` ${s.id} ${s.name.padEnd(20)} u,v ${f1(p[0]).padStart(6)},${f1(p[1]).padStart(6)} ` +
102+
`r ${f1(s.radius)} river ${f1(polyDist(p, river))} ${s.accent} ` +
103+
`${s.buildings.length} bldg / ${s.props.length} props`
104+
);
105+
}
106+
107+
say(`\nROADS (${map.roads.length}, total ${f1(map.roads.reduce((n, r) => n + plen(roadUV.get(r.id)), 0))})`);
108+
for (const r of map.roads) {
109+
const line = roadUV.get(r.id);
110+
say(
111+
` ${r.id} ${r.kind.padEnd(8)} w${r.width} ${r.from}->${r.to} ` +
112+
`len ${f1(plen(line)).padStart(6)} ${line.length} pts ` +
113+
`(${f1(line[0][0])},${f1(line[0][1])}) -> (${f1(line.at(-1)[0])},${f1(line.at(-1)[1])})`
114+
);
115+
}
116+
117+
say(`\nBRIDGES (${map.bridges.length})`);
118+
for (const b of map.bridges) {
119+
const p = uvOf(b);
120+
const dr = polyDist(p, river);
121+
say(` ${b.id} on ${b.roadId} at (${f1(p[0])},${f1(p[1])}) span ${b.span} river dist ${f1(dr)} ${dr <= 1 ? 'ok' : 'OFF-RIVER'}`);
122+
}
123+
124+
const clearCounts = map.sites.flatMap((s) => s.buildings.map((b) => b.clears.length));
125+
const withClears = clearCounts.filter((n) => n > 0).length;
126+
say(
127+
`\nTREES ${map.trees.length} SCATTER ${map.scatter.length} ` +
128+
`BUILDINGS ${map.sites.reduce((n, s) => n + s.buildings.length, 0)} ` +
129+
`SITE PROPS ${map.sites.reduce((n, s) => n + s.props.length, 0)}`
130+
);
131+
say(
132+
`clears: total ${clearCounts.reduce((a, b) => a + b, 0)}, ` +
133+
`max ${Math.max(...clearCounts)}, mean ${(clearCounts.reduce((a, b) => a + b, 0) / clearCounts.length).toFixed(2)}, ` +
134+
`plots with >=1: ${withClears}/${clearCounts.length}`
135+
);
136+
137+
const roleCount = {};
138+
const roofCount = {};
139+
for (const s of map.sites)
140+
for (const b of s.buildings) {
141+
roleCount[b.role] = (roleCount[b.role] || 0) + 1;
142+
roofCount[b.roof] = (roofCount[b.roof] || 0) + 1;
143+
}
144+
say(`roles: ${Object.entries(roleCount).map(([k, v]) => `${k} ${v}`).join(', ')}`);
145+
say(`roofs: ${Object.entries(roofCount).map(([k, v]) => `${k} ${v}`).join(', ')}`);
146+
const landmarks = map.sites.map((s) => {
147+
const lm = s.buildings.find((b) => b.banner && b.role !== 'homestead');
148+
return lm ? `${s.id}:${lm.role}` : `${s.id}:NONE`;
149+
});
150+
say(`landmarks: ${landmarks.join(', ')}`);
151+
const kindCount = {};
152+
for (const t of map.trees) kindCount[t.kind] = (kindCount[t.kind] || 0) + 1;
153+
say(`tree kinds: ${Object.entries(kindCount).map(([k, v]) => `${k} ${v}`).join(', ')}`);
154+
const scatterKinds = {};
155+
for (const p of map.scatter) scatterKinds[p.kind] = (scatterKinds[p.kind] || 0) + 1;
156+
say(`scatter kinds: ${Object.entries(scatterKinds).map(([k, v]) => `${k} ${v}`).join(', ')}`);
157+
158+
console.log(lines.join('\n'));
159+
return map;
160+
}
161+
162+
/* ------------------------------- invariants ------------------------------ */
163+
164+
function checks(seed, map) {
165+
const river = map.river.map(toUV);
166+
const roadUV = new Map(map.roads.map((r) => [r.id, r.pts.map(toUV)]));
167+
const results = [];
168+
const check = (name, ok, detail = '') => results.push({ name, ok, detail });
169+
170+
/* (a) every site centre is >= 4 from the river centreline */
171+
{
172+
let worst = Infinity;
173+
let who = '';
174+
for (const s of map.sites) {
175+
const d = polyDist(uvOf(s), river);
176+
if (d < worst) {
177+
worst = d;
178+
who = s.id;
179+
}
180+
}
181+
check('a sites >= 4 from river', worst >= 4, `min ${f1(worst)} at ${who}`);
182+
}
183+
184+
/* (b) the road network connects every site (union-find) */
185+
{
186+
const idx = new Map(map.sites.map((s, i) => [s.id, i]));
187+
const par = map.sites.map((_, i) => i);
188+
const find = (x) => (par[x] === x ? x : (par[x] = find(par[x])));
189+
for (const r of map.roads) par[find(idx.get(r.from))] = find(idx.get(r.to));
190+
const roots = new Set(map.sites.map((_, i) => find(i)));
191+
check('b roads connect all sites', roots.size === 1, `${roots.size} component(s)`);
192+
}
193+
194+
/* (c) every road/river crossing carries a bridge within 1.5 */
195+
{
196+
let total = 0;
197+
let bad = 0;
198+
let worst = 0;
199+
for (const r of map.roads) {
200+
for (const x of crossings(roadUV.get(r.id), river)) {
201+
total++;
202+
let best = Infinity;
203+
for (const b of map.bridges) {
204+
if (b.roadId !== r.id) continue;
205+
best = Math.min(best, d2(uvOf(b), x));
206+
}
207+
worst = Math.max(worst, best === Infinity ? 99 : best);
208+
if (!(best <= 1.5)) bad++;
209+
}
210+
}
211+
check(
212+
'c every crossing has a bridge',
213+
bad === 0 && total === map.bridges.length,
214+
`${total} crossings, ${map.bridges.length} bridges, ${bad} unbridged, worst offset ${f1(worst)}`
215+
);
216+
}
217+
218+
/* (d) no two buildings inside a site overlap footprints */
219+
{
220+
let bad = 0;
221+
let worst = Infinity;
222+
for (const s of map.sites) {
223+
for (let i = 0; i < s.buildings.length; i++) {
224+
for (let j = i + 1; j < s.buildings.length; j++) {
225+
const a = s.buildings[i];
226+
const b = s.buildings[j];
227+
const need = fpR(a.w) + fpR(b.w);
228+
const gap = d2(uvOf(a), uvOf(b)) - need;
229+
worst = Math.min(worst, gap);
230+
if (gap < -1e-6) bad++;
231+
}
232+
}
233+
}
234+
check('d no overlapping footprints', bad === 0, `${bad} overlaps, tightest gap ${f1(worst)}`);
235+
}
236+
237+
/* (e) every clears id exists in trees */
238+
{
239+
const ids = new Set(map.trees.map((t) => t.id));
240+
let bad = 0;
241+
for (const s of map.sites) for (const b of s.buildings) for (const id of b.clears) if (!ids.has(id)) bad++;
242+
check('e clears reference live trees', bad === 0, `${bad} dangling`);
243+
}
244+
245+
/* (f) determinism */
246+
{
247+
const a = JSON.stringify(generateMap(seed));
248+
const b = JSON.stringify(generateMap(seed));
249+
check('f deterministic', a === b, `${a.length} bytes`);
250+
}
251+
252+
/* (g) town names unique */
253+
{
254+
const names = map.sites.map((s) => s.name);
255+
check('g town names unique', new Set(names).size === names.length, names.join(' / '));
256+
}
257+
258+
/* extra, non-fatal but worth watching */
259+
{
260+
const f = map.sites[0].buildings[0];
261+
check(
262+
'h founding house is s0-b0 homestead',
263+
f.id === 's0-b0' && f.role === 'homestead' && f.clears.length === 0 && f.floors === 2 && f.w >= 40 && f.w <= 48,
264+
`${f.label} w${f.w} floors${f.floors} clears${f.clears.length}`
265+
);
266+
let treesOnFounding = 0;
267+
const fu = uvOf(f);
268+
for (const t of map.trees) if (d2(uvOf(t), fu) < fpR(f.w) + 2.5) treesOnFounding++;
269+
check('i founding plot is clear of trees', treesOnFounding === 0, `${treesOnFounding} trees`);
270+
}
271+
{
272+
let bad = 0;
273+
let worst = Infinity;
274+
for (const r of map.roads) {
275+
const line = roadUV.get(r.id);
276+
for (const s of map.sites) {
277+
if (s.id === r.from || s.id === r.to) continue;
278+
const d = polyDist(uvOf(s), line);
279+
worst = Math.min(worst, d - (s.radius + 1));
280+
if (d < s.radius + 1) bad++;
281+
}
282+
}
283+
check('j roads clear non-endpoint towns', bad === 0, `${bad} intrusions, margin ${f1(worst)}`);
284+
}
285+
{
286+
let bad = 0;
287+
for (const t of map.trees) {
288+
if (polyDist(uvOf(t), river) < 1.5 - 1e-6) bad++;
289+
else
290+
for (const r of map.roads) {
291+
if (polyDist(uvOf(t), roadUV.get(r.id)) < 1.2 - 1e-6) {
292+
bad++;
293+
break;
294+
}
295+
}
296+
}
297+
check('k trees clear river/roads', bad === 0, `${bad} violations`);
298+
}
299+
{
300+
const ok =
301+
map.trees.length >= 400 &&
302+
map.trees.length <= 950 &&
303+
map.scatter.length >= 90 &&
304+
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;
309+
check(
310+
'l populations in spec range',
311+
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`
313+
);
314+
}
315+
{
316+
const w = map.sites.flatMap((s) => s.buildings).filter((b) => b.w % 4 !== 0 || b.w < 24 || b.w > 64);
317+
check('m building widths legal', w.length === 0, `${w.length} bad`);
318+
}
319+
320+
const pass = results.every((r) => r.ok);
321+
console.log('\nINVARIANTS');
322+
for (const r of results) {
323+
console.log(` ${r.ok ? 'PASS' : 'FAIL'} ${r.name.padEnd(38)} ${r.detail}`);
324+
}
325+
console.log(` => seed ${seed}: ${pass ? 'ALL PASS' : 'FAILURES'}`);
326+
return pass;
327+
}
328+
329+
/* ---------------------------------- main --------------------------------- */
330+
331+
const argv = process.argv.slice(2);
332+
let allPass = true;
333+
334+
if (argv[0] === '--sweep') {
335+
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'));
347+
}
348+
}
349+
console.log(`\nsweep ${n} seeds: ${fails.length ? `FAIL ${fails.join(',')}` : 'ALL PASS'}`);
350+
allPass = fails.length === 0;
351+
} else {
352+
const seeds = argv.length ? argv.map(Number) : [1, 42, 20260802];
353+
for (const s of seeds) {
354+
const map = report(s);
355+
if (!checks(s, map)) allPass = false;
356+
}
357+
}
358+
359+
process.exit(allPass ? 0 : 1);

0 commit comments

Comments
 (0)