Skip to content

Commit fea2bec

Browse files
committed
Merge branch 'worktree-agent-a2873d0b142349d7f'
# Conflicts: # scripts/genesis-stats.mjs # src/components/designs/genesis/scene.ts # src/components/designs/genesis/types.ts
2 parents 528eb85 + 3b6268f commit fea2bec

7 files changed

Lines changed: 1370 additions & 41 deletions

File tree

scripts/genesis-stats.mjs

Lines changed: 290 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { dirname, join } from 'node:path';
1919
import { fileURLToPath } from 'node:url';
2020

2121
const 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
);
2525
const { 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) => {
9292
const fpR = (w) => w / TW;
9393
const 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

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

Comments
 (0)