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) {
331423const argv = process . argv . slice ( 2 ) ;
332424let 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+
334439if ( 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
0 commit comments