|
| 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