@@ -19,7 +19,7 @@ import { dirname, join } from 'node:path';
1919import { fileURLToPath } from 'node:url' ;
2020
2121const root = join ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' ) ;
22- const { generateMap, generateMapUncached, woodCharacter } = await import (
22+ const { generateMap, generateMapUncached, woodCharacter, QUARRY_REACH } = await import (
2323 join ( root , 'src/components/designs/genesis/gen.ts' )
2424) ;
2525const { TW } = await import ( join ( root , 'src/components/designs/genesis/types.ts' ) ) ;
@@ -36,7 +36,7 @@ const PROP_SPRITES = new Set([
3636 ...TREE_SPRITES ,
3737 'bush' , 'rock' , 'flowers' , 'reeds' , 'stump' , 'crop' , 'haystack' , 'fenceL' ,
3838 'fenceR' , 'shed' , 'cart' , 'crates' , 'lumber' , 'barrels' , 'well' , 'lamp' ,
39- 'sheep' , 'campfire' ,
39+ 'sheep' , 'campfire' , 'quarry-blocks' ,
4040 // resolved by name in propSprite() rather than from a pool
4141 'nameboard' , 'signpost' , 'stake' , 'crane' ,
4242] ) ;
@@ -92,6 +92,64 @@ const plen = (line) => {
9292const fpR = ( w ) => w / TW ;
9393const f1 = ( n ) => n . toFixed ( 1 ) ;
9494
95+ /* --------------------------------- lakes --------------------------------- */
96+
97+ // The lake tests below are written entirely against `lk.pts`, the stored
98+ // outline, and never against the analytic rx/ry/rot — deliberately. `pts` is
99+ // what the renderer fills, so testing it is testing what a visitor sees; and a
100+ // bug in the generator's own distance approximation cannot hide behind a
101+ // harness that reuses the same approximation.
102+
103+ /** The outline in u/v, scaled about the lake's centre — same as scene.ts. */
104+ function lakeRingUV ( lk , k = 1 ) {
105+ const c = uvOf ( lk ) ;
106+ return lk . pts . map ( ( p ) => {
107+ const q = toUV ( p ) ;
108+ return [ c [ 0 ] + ( q [ 0 ] - c [ 0 ] ) * k , c [ 1 ] + ( q [ 1 ] - c [ 1 ] ) * k ] ;
109+ } ) ;
110+ }
111+
112+ /** Exact even-odd point-in-polygon. */
113+ function inPoly ( p , poly ) {
114+ let inside = false ;
115+ for ( let i = 0 , j = poly . length - 1 ; i < poly . length ; j = i ++ ) {
116+ const yi = poly [ i ] [ 1 ] ;
117+ const yj = poly [ j ] [ 1 ] ;
118+ if ( yi > p [ 1 ] !== yj > p [ 1 ] ) {
119+ const x = ( ( poly [ j ] [ 0 ] - poly [ i ] [ 0 ] ) * ( p [ 1 ] - yi ) ) / ( yj - yi ) + poly [ i ] [ 0 ] ;
120+ if ( p [ 0 ] < x ) inside = ! inside ;
121+ }
122+ }
123+ return inside ;
124+ }
125+
126+ /** Signed shore distance from the outline: negative inside the water. */
127+ function lakeShoreDist ( p , ring ) {
128+ let best = Infinity ;
129+ for ( let i = 0 ; i < ring . length ; i ++ ) {
130+ best = Math . min ( best , segDist ( p , ring [ i ] , ring [ ( i + 1 ) % ring . length ] ) ) ;
131+ }
132+ return inPoly ( p , ring ) ? - best : best ;
133+ }
134+
135+ /** Nearest shore over every lake; +Infinity when the valley has none. */
136+ function lakesShoreDist ( p , rings ) {
137+ let best = Infinity ;
138+ for ( const ring of rings ) best = Math . min ( best , lakeShoreDist ( p , ring ) ) ;
139+ return best ;
140+ }
141+
142+ /** Perimeter of a closed polygon. */
143+ const ringLen = ( ring ) => {
144+ let s = 0 ;
145+ for ( let i = 0 ; i < ring . length ; i ++ ) s += d2 ( ring [ i ] , ring [ ( i + 1 ) % ring . length ] ) ;
146+ return s ;
147+ } ;
148+
149+ /** A town builds in stone iff every plot on its roster says so. */
150+ const isQuarryTown = ( s ) =>
151+ s . buildings . length > 0 && s . buildings . every ( ( b ) => b . material === 'stone' ) ;
152+
95153/* ------------------------------- reporting ------------------------------- */
96154
97155function report ( seed ) {
@@ -117,13 +175,48 @@ function report(seed) {
117175 `mouths (${ f1 ( river [ 0 ] [ 0 ] ) } ,${ f1 ( river [ 0 ] [ 1 ] ) } ) -> (${ f1 ( river . at ( - 1 ) [ 0 ] ) } ,${ f1 ( river . at ( - 1 ) [ 1 ] ) } )`
118176 ) ;
119177
178+ const rings = ( map . lakes ?? [ ] ) . map ( ( lk ) => lakeRingUV ( lk ) ) ;
179+ say ( `\nLAKES (${ map . lakes ?. length ?? 0 } )` ) ;
180+ for ( let i = 0 ; i < ( map . lakes ?? [ ] ) . length ; i ++ ) {
181+ const lk = map . lakes [ i ] ;
182+ const c = uvOf ( lk ) ;
183+ const ring = rings [ i ] ;
184+ let wu = 0 ;
185+ let wv = 0 ;
186+ for ( const q of ring ) {
187+ wu = Math . max ( wu , Math . abs ( q [ 0 ] - c [ 0 ] ) * 2 ) ;
188+ wv = Math . max ( wv , Math . abs ( q [ 1 ] - c [ 1 ] ) * 2 ) ;
189+ }
190+ say (
191+ ` ${ lk . id } u,v ${ f1 ( c [ 0 ] ) . padStart ( 6 ) } ,${ f1 ( c [ 1 ] ) . padStart ( 6 ) } ` +
192+ `axes ${ f1 ( lk . rx * 2 ) } x${ f1 ( lk . ry * 2 ) } extent ${ f1 ( wu ) } x${ f1 ( wv ) } ` +
193+ `perimeter ${ f1 ( ringLen ( ring ) ) } ${ lk . pts . length } pts ` +
194+ `river ${ f1 ( polyDist ( c , river ) ) } ${ lk . fed ? 'RIVER-FED' : 'standalone' } `
195+ ) ;
196+ }
197+
198+ say ( `\nOUTCROPS (${ map . outcrops ?. length ?? 0 } )` ) ;
199+ for ( const o of map . outcrops ?? [ ] ) {
200+ const c = uvOf ( o ) ;
201+ const near = map . sites
202+ . map ( ( s ) => ( { s, d : d2 ( c , uvOf ( s ) ) - o . radius - s . radius } ) )
203+ . sort ( ( a , b ) => a . d - b . d || ( a . s . id < b . s . id ? - 1 : 1 ) ) [ 0 ] ;
204+ say (
205+ ` ${ o . id } u,v ${ f1 ( c [ 0 ] ) . padStart ( 6 ) } ,${ f1 ( c [ 1 ] ) . padStart ( 6 ) } r ${ f1 ( o . radius ) } ` +
206+ `nearest town ${ near ? `${ near . s . id } at ${ f1 ( near . d ) } ` : 'none' } ` +
207+ `${ near && near . d <= QUARRY_REACH ? ' -> QUARRY' : '' } `
208+ ) ;
209+ }
210+
120211 say ( `\nSITES (${ map . sites . length } )` ) ;
121212 for ( const s of map . sites ) {
122213 const p = uvOf ( s ) ;
123214 say (
124215 ` ${ s . id } ${ s . name . padEnd ( 20 ) } u,v ${ f1 ( p [ 0 ] ) . padStart ( 6 ) } ,${ f1 ( p [ 1 ] ) . padStart ( 6 ) } ` +
125- `r ${ f1 ( s . radius ) } river ${ f1 ( polyDist ( p , river ) ) } ${ s . accent } ` +
126- `${ s . buildings . length } bldg / ${ s . props . length } props`
216+ `r ${ f1 ( s . radius ) } river ${ f1 ( polyDist ( p , river ) ) } ` +
217+ `lake ${ rings . length ? f1 ( lakesShoreDist ( p , rings ) ) : '-' } ${ s . accent } ` +
218+ `${ s . buildings . length } bldg / ${ s . props . length } props` +
219+ `${ isQuarryTown ( s ) ? ' STONE' : '' } `
127220 ) ;
128221 }
129222
@@ -360,8 +453,11 @@ function checks(seed, map, scale = 1) {
360453 const ok =
361454 map . trees . length >= 1400 &&
362455 map . trees . length <= 2400 &&
456+ // The upper bound carries the lake shores and the boulder fields as well
457+ // as the wild scatter: two lakes ringed with reeds and two outcrops laid
458+ // out in stone is a legitimate ~120 props on top of the base 100..180.
363459 map . scatter . length >= 90 &&
364- map . scatter . length <= 220 &&
460+ map . scatter . length <= 340 &&
365461 map . sites . length >= 2 &&
366462 map . sites . length <= 16 &&
367463 s0b >= 3 &&
@@ -555,6 +651,193 @@ function checks(seed, map, scale = 1) {
555651 ) ;
556652 }
557653
654+ /* --------------------------- lakes and stone --------------------------- */
655+
656+ const rings = ( map . lakes ?? [ ] ) . map ( ( lk ) => lakeRingUV ( lk ) ) ;
657+
658+ /* (t) lakes are terrain: byte-identical however much the day builds.
659+ Run once per seed (at scale 1) rather than once per scale, because the
660+ comparison is between scales and doing it five times says nothing new. */
661+ if ( scale === 1 ) {
662+ const a = JSON . stringify ( generateMapUncached ( seed , 0.25 ) . lakes ) ;
663+ const b = JSON . stringify ( generateMapUncached ( seed , 4 ) . lakes ) ;
664+ const c = JSON . stringify ( map . lakes ) ;
665+ const oa = JSON . stringify ( generateMap ( seed , 0.25 ) . outcrops ) ;
666+ const ob = JSON . stringify ( generateMap ( seed , 4 ) . outcrops ) ;
667+ check (
668+ 't lakes/outcrops identical 0.25x..4x' ,
669+ a === b && a === c && oa === ob ,
670+ `${ map . lakes . length } lakes (${ a . length } B), ${ map . outcrops . length } outcrops`
671+ ) ;
672+ }
673+
674+ /* (u) the lake shape itself is sane: closed, non-degenerate, in bounds */
675+ {
676+ let bad = 0 ;
677+ let detail = '' ;
678+ for ( let i = 0 ; i < ( map . lakes ?? [ ] ) . length ; i ++ ) {
679+ const lk = map . lakes [ i ] ;
680+ const ring = rings [ i ] ;
681+ const c = uvOf ( lk ) ;
682+ const across = 2 * Math . max ( ...ring . map ( ( q ) => d2 ( q , c ) ) ) ;
683+ if ( ring . length < 12 ) bad ++ ;
684+ // 4..9 units across on the axes is the spec; the wobble is allowed to
685+ // bulge a little past that at whichever angle it peaks.
686+ if ( ! ( lk . rx * 2 >= 4 && lk . rx * 2 <= 9 && lk . ry * 2 >= 2.6 && lk . ry <= lk . rx ) ) {
687+ bad ++ ;
688+ detail += ` ${ lk . id } axes ${ f1 ( lk . rx * 2 ) } x${ f1 ( lk . ry * 2 ) } ` ;
689+ }
690+ if ( across > 12 ) {
691+ bad ++ ;
692+ detail += ` ${ lk . id } bulges to ${ f1 ( across ) } ` ;
693+ }
694+ if ( ! ( lk . rx > 0 && lk . ry > 0 ) ) bad ++ ;
695+ for ( const q of ring ) {
696+ if ( q [ 0 ] < map . bounds . u0 || q [ 0 ] > map . bounds . u1 ) bad ++ ;
697+ if ( q [ 1 ] < map . bounds . v0 || q [ 1 ] > map . bounds . v1 ) bad ++ ;
698+ }
699+ // Lakes never overlap one another.
700+ for ( let j = i + 1 ; j < map . lakes . length ; j ++ ) {
701+ if ( lakeShoreDist ( uvOf ( map . lakes [ j ] ) , ring ) < 0 ) bad ++ ;
702+ }
703+ }
704+ check (
705+ 'u lake outlines well formed' ,
706+ bad === 0 ,
707+ `${ map . lakes ?. length ?? 0 } lakes${ detail } `
708+ ) ;
709+ }
710+
711+ /* (v) nothing stands in the water: no tree, roof, prop, scatter or town */
712+ if ( rings . length ) {
713+ let bad = 0 ;
714+ let worst = Infinity ;
715+ let who = '' ;
716+ const test = ( p , margin , tag ) => {
717+ const d = lakesShoreDist ( p , rings ) ;
718+ if ( d - margin < worst ) {
719+ worst = d - margin ;
720+ who = tag ;
721+ }
722+ if ( d < margin ) bad ++ ;
723+ } ;
724+ // Margins are the generator's, less a hair of slack for the fact that the
725+ // generator measures against its analytic shape and this measures against
726+ // the resolved polygon — a chord always sits marginally inside the curve.
727+ for ( const t of map . trees ) test ( uvOf ( t ) , 0.6 , `tree ${ t . id } ` ) ;
728+ for ( const p of map . scatter ) test ( uvOf ( p ) , 0.25 , `scatter ${ p . id } ` ) ;
729+ for ( const s of map . sites ) {
730+ test ( uvOf ( s ) , s . radius + 1.9 , `site ${ s . id } ` ) ;
731+ for ( const b of s . buildings ) test ( uvOf ( b ) , fpR ( b . w ) + 0.9 , `bldg ${ b . id } ` ) ;
732+ for ( const p of s . props ) test ( uvOf ( p ) , 0.8 , `prop ${ p . id } ` ) ;
733+ }
734+ check (
735+ 'v nothing stands in a lake' ,
736+ bad === 0 ,
737+ `${ bad } in the water, least slack ${ f1 ( worst ) } at ${ who } `
738+ ) ;
739+ } else {
740+ check ( 'v nothing stands in a lake' , true , 'no lakes' ) ;
741+ }
742+
743+ /* (w) no road ever crosses lake water — not a vertex, not a segment */
744+ if ( rings . length ) {
745+ let bad = 0 ;
746+ let worst = Infinity ;
747+ let who = '' ;
748+ for ( const r of map . roads ) {
749+ const line = roadUV . get ( r . id ) ;
750+ for ( let i = 0 ; i + 1 < line . length ; i ++ ) {
751+ // Sample the segment as well as its ends: a straight chord can slice a
752+ // narrow neck of water with both endpoints comfortably on dry land.
753+ const steps = Math . max ( 2 , Math . ceil ( d2 ( line [ i ] , line [ i + 1 ] ) * 3 ) ) ;
754+ for ( let k = 0 ; k <= steps ; k ++ ) {
755+ const t = k / steps ;
756+ const p = [
757+ line [ i ] [ 0 ] + ( line [ i + 1 ] [ 0 ] - line [ i ] [ 0 ] ) * t ,
758+ line [ i ] [ 1 ] + ( line [ i + 1 ] [ 1 ] - line [ i ] [ 1 ] ) * t ,
759+ ] ;
760+ const d = lakesShoreDist ( p , rings ) ;
761+ if ( d < worst ) {
762+ worst = d ;
763+ who = r . id ;
764+ }
765+ if ( d < 0 ) bad ++ ;
766+ }
767+ }
768+ }
769+ check (
770+ 'w roads never cross lake water' ,
771+ bad === 0 ,
772+ `${ bad } samples in the water, closest approach ${ f1 ( worst ) } on ${ who } `
773+ ) ;
774+ } else {
775+ check ( 'w roads never cross lake water' , true , 'no lakes' ) ;
776+ }
777+
778+ /* (x) quarry towns: stone is all-or-nothing per town, earned by an outcrop,
779+ never thatched, and always kitted out with the stone yard */
780+ {
781+ let bad = 0 ;
782+ let detail = [ ] ;
783+ for ( const s of map . sites ) {
784+ const stone = s . buildings . filter ( ( b ) => b . material === 'stone' ) . length ;
785+ const timber = s . buildings . filter ( ( b ) => b . material !== undefined && b . material !== 'stone' ) . length ;
786+ if ( timber ) {
787+ bad ++ ;
788+ detail . push ( `${ s . id } explicit-timber` ) ;
789+ }
790+ if ( stone && stone !== s . buildings . length ) {
791+ bad ++ ;
792+ detail . push ( `${ s . id } mixed ${ stone } /${ s . buildings . length } ` ) ;
793+ }
794+ if ( ! stone ) continue ;
795+ // Earned: an outcrop within reach of the rim.
796+ const near = Math . min (
797+ ...( map . outcrops ?? [ ] ) . map ( ( o ) => d2 ( uvOf ( s ) , uvOf ( o ) ) - o . radius - s . radius )
798+ ) ;
799+ if ( ! ( near <= QUARRY_REACH + 1e-6 ) ) {
800+ bad ++ ;
801+ detail . push ( `${ s . id } stone but nearest rock ${ f1 ( near ) } ` ) ;
802+ }
803+ if ( s . buildings . some ( ( b ) => b . roof === 'thatch' ) ) {
804+ bad ++ ;
805+ detail . push ( `${ s . id } thatch on stone` ) ;
806+ }
807+ if ( ! s . props . some ( ( p ) => p . kind === 'quarry-blocks' ) ) {
808+ bad ++ ;
809+ detail . push ( `${ s . id } no stone yard` ) ;
810+ }
811+ }
812+ const stoneTowns = map . sites . filter ( isQuarryTown ) . length ;
813+ check (
814+ 'x quarry towns coherent' ,
815+ bad === 0 ,
816+ detail . length ? detail . join ( ', ' ) : `${ stoneTowns } /${ map . sites . length } stone towns`
817+ ) ;
818+ }
819+
820+ /* (y) outcrops sit clear of the towns that quarry them, and of the water */
821+ {
822+ let bad = 0 ;
823+ let worst = Infinity ;
824+ for ( const o of map . outcrops ?? [ ] ) {
825+ const c = uvOf ( o ) ;
826+ for ( const s of map . sites ) {
827+ const gap = d2 ( c , uvOf ( s ) ) - o . radius - s . radius ;
828+ worst = Math . min ( worst , gap ) ;
829+ if ( gap < 0 ) bad ++ ;
830+ }
831+ if ( polyDist ( c , river ) < o . radius ) bad ++ ;
832+ if ( rings . length && lakesShoreDist ( c , rings ) < o . radius ) bad ++ ;
833+ }
834+ check (
835+ 'y outcrops clear of towns and water' ,
836+ bad === 0 ,
837+ `${ map . outcrops ?. length ?? 0 } outcrops, tightest town gap ${ worst === Infinity ? '-' : f1 ( worst ) } `
838+ ) ;
839+ }
840+
558841 const pass = results . every ( ( r ) => r . ok ) ;
559842 console . log ( `\nINVARIANTS seed ${ seed } scale ${ scale } ` ) ;
560843 for ( const r of results ) {
@@ -583,7 +866,8 @@ function subsetChecks(seed) {
583866 // it, who finds it and whether the day's digging turns it up at all are all a
584867 // pure function of the seed. Only the REWARD is allowed to notice the pace,
585868 // and it does that through the ordinary site/building prefix below.
586- for ( const field of [ 'river' , 'riverWidth' , 'chunks' , 'trees' , 'scatter' , 'chests' , 'bounds' , 'content' , 'valleyName' ] ) {
869+ // `lakes` and `outcrops` are terrain for the same reason.
870+ for ( const field of [ 'river' , 'riverWidth' , 'lakes' , 'outcrops' , 'chunks' , 'trees' , 'scatter' , 'chests' , 'bounds' , 'content' , 'valleyName' ] ) {
587871 const ok = maps . every ( ( m ) => same ( m [ field ] , maps [ 0 ] [ field ] ) ) ;
588872 check ( `terrain: ${ field } identical` , ok ) ;
589873 }
0 commit comments